import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:miaoji_zhang/shared/api/backend_identity.dart'; import 'package:miaoji_zhang/shared/api/business_api.dart'; import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/services/screenshot_channel.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; void main() { test('本地账号命名空间同时隔离后端和用户', () { final first = BackendIdentity.accountNamespaceFor( 'https://api-a.example.com/', 1, ); final normalized = BackendIdentity.accountNamespaceFor( 'https://API-A.example.com', 1, ); final otherBackend = BackendIdentity.accountNamespaceFor( 'https://api-b.example.com', 1, ); final otherUser = BackendIdentity.accountNamespaceFor( 'https://api-a.example.com', 2, ); expect(first, normalized); expect(first, isNot(otherBackend)); expect(first, isNot(otherUser)); }); test( 'SSE framing preserves split UTF-8 characters and trailing events', () async { const source = 'data: {"t":"收入"}\r\n\r\ndata: {"done":true,"c":"收入100"}'; final bytes = utf8.encode(source); final chunks = >[ bytes.sublist(0, 13), bytes.sublist(13, 14), bytes.sublist(14, 21), bytes.sublist(21), ]; final accumulator = SseFrameAccumulator(); final frames = []; await for (final chunk in Stream>.fromIterable( chunks, ).transform(const Utf8Decoder())) { frames.addAll(accumulator.add(chunk)); } frames.addAll(accumulator.close()); expect(frames, hasLength(2)); expect(frames.first, contains('收入')); expect(frames.last, contains('"done":true')); }, ); test('transaction and ledger DTOs retain type, deletion and UTC instant', () { final transaction = TxItem.fromJson({ 'id': 7, 'ledgerId': 3, 'categoryId': 5, 'categoryName': '工资', 'categoryIcon': 'money', 'type': 'income', 'amount': 100, 'note': '七月工资', 'paymentMethod': '银行卡', 'source': 'ai_chat', 'sourceText': '我赚了100', 'occurredAt': '2026-06-30T16:30:00Z', 'isDeleted': true, }); final ledger = LedgerInfo.fromJson({ 'id': 3, 'name': '家庭账本', 'iconKey': 'wallet', 'isDefault': true, 'txCount': 9, }); expect(transaction.isIncome, isTrue); expect(transaction.isDeleted, isTrue); expect(transaction.occurredAt.toUtc(), DateTime.utc(2026, 6, 30, 16, 30)); expect(ledger.name, '家庭账本'); expect(ledger.transactionCount, 9); }); test('transfer DTO uses direction for effective income and expense', () { TxItem transfer(String direction) => TxItem.fromJson({ 'id': direction == 'in' ? 8 : 9, 'ledgerId': 3, 'categoryId': direction == 'in' ? 101 : 1, 'categoryName': direction == 'in' ? '工资' : '餐饮', 'categoryIcon': direction == 'in' ? 'money' : 'food', 'type': 'transfer', 'transferDirection': direction, 'counterparty': '张三', 'amount': 20, 'source': 'accessibility', 'occurredAt': '2026-07-25T03:00:00Z', }); final transferIn = transfer('in'); final transferOut = transfer('out'); expect(transferIn.isIncome, isTrue); expect(transferIn.isExpense, isFalse); expect(transferIn.typeLabel, '转入'); expect(transferOut.isExpense, isTrue); expect(transferOut.isIncome, isFalse); expect(transferOut.typeLabel, '转出'); expect(transferOut.counterparty, '张三'); }); test('budget refinement DTO keeps conversation summary and constraints', () { final recommendation = BudgetRecommendations.fromJson({ 'year': 2026, 'month': 7, 'suggestedTotal': 130, 'message': '草稿已调整', 'adjustmentSummary': '餐饮预算已降低', 'warnings': ['餐饮不能低于已花 ¥80'], 'items': [ { 'categoryId': 1, 'categoryName': '餐饮', 'categoryIcon': 'food', 'suggestedAmount': 80, 'currentSpent': 80, 'historicalAverage': 120, 'confidence': 'high', }, { 'categoryId': 2, 'categoryName': '交通', 'categoryIcon': 'transport', 'suggestedAmount': 50, 'currentSpent': 20, 'historicalAverage': 60, 'confidence': 'medium', }, ], }); expect(recommendation.suggestedTotal, 130); expect(recommendation.adjustmentSummary, '餐饮预算已降低'); expect(recommendation.warnings, contains('餐饮不能低于已花 ¥80')); expect(recommendation.items, hasLength(2)); }); test('周报与年报 DTO 保留周期、峰值和 AI 占比', () { final report = PeriodReport.fromJson({ 'periodType': 'yearly', 'periodLabel': '2026年', 'startDate': '2026-01-01T00:00:00', 'endDate': '2027-01-01T00:00:00', 'count': 12, 'income': 8000, 'expense': 5600, 'balance': 2400, 'aiRatio': 75, 'peakLabel': '7月', 'peakAmount': 1300, 'peakNote': '旅行', 'topCategory': '餐饮', 'topCategoryAmount': 1600, 'topCategoryPercent': 28.57, 'commentary': '这一年记录得很完整。', }); expect(report.periodType, 'yearly'); expect(report.peakLabel, '7月'); expect(report.aiRatio, 75); expect(report.balance, 2400); }); test('无障碍连接状态区分重连、正常和异常', () { final reconnecting = RecognitionStatus.fromMap({ 'accessibilityAuthorized': true, 'accessibilityConnected': false, 'accessibilityConnectionState': 'reconnecting', 'accessibilityLastConnectedAt': 1724472000000, 'keepAliveExpected': true, 'keepAliveRunning': false, 'keepAliveError': '系统限制了后台保护启动', 'recentsProtectionExpected': true, 'recentsProtectionActive': true, 'lastRecognitionExitAt': 1788077518000, 'lastRecognitionExitReason': 'low_memory: single-cleaner', 'taskCleanerRecoveryNeeded': true, 'settings': jsonEncode({'accessibilityEvents': true}), }); final connected = RecognitionStatus.fromMap({ 'accessibilityAuthorized': true, 'accessibilityConnected': true, 'accessibilityConnectionState': 'connected', 'settings': '{}', }); final disconnected = RecognitionStatus.fromMap({ 'accessibilityAuthorized': true, 'accessibilityConnected': false, 'accessibilityConnectionState': 'disconnected', 'settings': '{}', }); expect( reconnecting.accessibilityConnectionState, AccessibilityConnectionState.reconnecting, ); expect(reconnecting.accessibilityConnectionLabel, '正在重连'); expect(reconnecting.accessibilityLastConnectedAt, isNotNull); expect(reconnecting.keepAliveExpected, isTrue); expect(reconnecting.keepAliveRunning, isFalse); expect(reconnecting.keepAliveNeedsRecovery, isTrue); expect(reconnecting.keepAliveError, '系统限制了后台保护启动'); expect(reconnecting.recentsProtectionExpected, isTrue); expect(reconnecting.recentsProtectionActive, isTrue); expect(reconnecting.lastRecognitionExitAt, isNotNull); expect(reconnecting.lastRecognitionExitReason, contains('single-cleaner')); expect(reconnecting.taskCleanerRecoveryNeeded, isTrue); expect(connected.accessibilityConnectionLabel, '已连接'); expect(disconnected.accessibilityConnectionLabel, '服务未连接'); expect(disconnected.accessibilityNeedsRecovery, isTrue); }); test('分类图标目录至少 32 个且键值不重复', () { final keys = AppIcons.categoryCatalog.map((item) => item.key).toList(); expect(keys.length, greaterThanOrEqualTo(32)); expect(keys.toSet().length, keys.length); expect(keys, containsAll(['cart', 'metro', 'house', 'money'])); expect(keys, isNot(contains('shopping'))); expect(keys, isNot(contains('transport'))); }); }