238 lines
8.3 KiB
Dart
238 lines
8.3 KiB
Dart
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 = <List<int>>[
|
|
bytes.sublist(0, 13),
|
|
bytes.sublist(13, 14),
|
|
bytes.sublist(14, 21),
|
|
bytes.sublist(21),
|
|
];
|
|
final accumulator = SseFrameAccumulator();
|
|
final frames = <String>[];
|
|
|
|
await for (final chunk in Stream<List<int>>.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')));
|
|
});
|
|
}
|