Files
jizhi/frontend/test/api_models_test.dart
T

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')));
});
}