Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.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/widgets/app_icons.dart';
void main() {
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('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('分类图标目录至少 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')));
});
}
@@ -0,0 +1,89 @@
import 'dart:convert';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
void main() {
test('local export creates readable CSV and privacy-safe JSON', () {
final bytes = LocalExportService.buildZipFromSnapshot(
{
'schemaVersion': 1,
'exportedAt': '2026-07-21T00:00:00.000Z',
'ledgers': [
{
'id': 1,
'name': '日常账本',
'iconKey': 'wallet',
'isDefault': true,
'updatedAt': '2026-07-21T00:00:00.000Z',
},
],
'categories': [
{
'id': 2,
'name': '餐饮',
'iconKey': 'food',
'colorKey': 'coral',
'type': 'expense',
'sortOrder': 10,
'isCustom': false,
'isDeleted': false,
'updatedAt': '2026-07-21T00:00:00.000Z',
},
],
'transactions': [
{
'id': 3,
'ledgerId': 1,
'categoryId': 2,
'categoryName': '餐饮',
'categoryIcon': 'food',
'categoryColor': 'coral',
'type': 'expense',
'amount': 12.5,
'note': '午饭,"套餐"\n已报销',
'paymentMethod': '微信',
'source': 'manual',
'sourceText': null,
'occurredAt': '2026-07-21T04:00:00.000Z',
'isDeleted': false,
'updatedAt': '2026-07-21T04:00:00.000Z',
},
],
'budgets': [
{
'ledgerId': 1,
'period': 202607,
'categoryId': 2,
'amount': 800.0,
'recurring': true,
'updatedAt': '2026-07-21T00:00:00.000Z',
},
],
},
profile: const {'mode': 'guest', 'nickname': '游客'},
);
final archive = ZipDecoder().decodeBytes(bytes);
expect(
archive.map((file) => file.name),
containsAll(['transactions.csv', 'budgets.csv', 'jizhi-backup.json']),
);
final transactionCsv = utf8.decode(
archive.findFile('transactions.csv')!.readBytes()!,
);
expect(transactionCsv, contains('12.50'));
expect(transactionCsv, contains('"午饭,""套餐""\n已报销"'));
final json = utf8.decode(
archive.findFile('jizhi-backup.json')!.readBytes()!,
);
final document = jsonDecode(json) as Map<String, dynamic>;
expect(document['transactions'], hasLength(1));
expect((document['profile'] as Map)['mode'], 'guest');
expect(json, isNot(contains('passwordHash')));
expect(json, isNot(contains('local_db_key')));
});
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miaoji_zhang/shared/services/recognition_import_service.dart';
import 'package:miaoji_zhang/shared/services/recognition_diagnostic_formatter.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/theme/theme_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('上海时区周期边界不受设备时区影响', () {
final civil = ShanghaiTime.toCivil(DateTime.utc(2026, 6, 30, 16, 30));
expect(civil, DateTime(2026, 7, 1, 0, 30));
final month = ShanghaiTime.monthRangeUtc(2026, 7);
expect(month.start, DateTime.utc(2026, 6, 30, 16));
expect(month.end, DateTime.utc(2026, 7, 31, 16));
final week = ShanghaiTime.weekRangeUtc(DateTime(2026, 7, 19));
expect(week.start, DateTime.utc(2026, 7, 12, 16));
expect(week.end, DateTime.utc(2026, 7, 19, 16));
});
test('无障碍运行时长不会被当作 Unix 时间戳', () {
final now = DateTime.utc(2026, 7, 22, 8, 30);
expect(RecognitionImportService.validOccurredAtUtc(1234, now: now), now);
expect(
RecognitionImportService.validOccurredAtUtc(
DateTime.utc(2026, 7, 22, 8).millisecondsSinceEpoch,
now: now,
),
DateTime.utc(2026, 7, 22, 8),
);
});
test('三态主题从本地恢复并持久化', () async {
SharedPreferences.setMockInitialValues({'theme_preference': 'dark'});
await ThemeStore.instance.initialize();
expect(ThemeStore.instance.preference, JzThemePreference.dark);
expect(ThemeStore.instance.themeMode, ThemeMode.dark);
await ThemeStore.instance.setPreference(JzThemePreference.light);
final preferences = await SharedPreferences.getInstance();
expect(preferences.getString('theme_preference'), 'light');
expect(ThemeStore.instance.themeMode, ThemeMode.light);
});
test('旧版无时区接口时间按 UTC 解释后显示上海时间', () {
expect(
ShanghaiTime.parseUtcInstant('2026-07-22T14:35:00'),
DateTime.utc(2026, 7, 22, 14, 35),
);
expect(
ShanghaiTime.parseCivil('2026-07-22T14:35:00'),
DateTime(2026, 7, 22, 22, 35),
);
expect(
ShanghaiTime.parseCivil('2026-07-22T14:35:00Z'),
DateTime(2026, 7, 22, 22, 35),
);
});
test('原生识别候选保留红包分类与金额来源', () {
final candidate = RecognitionCandidate.fromJson({
'id': 'candidate-1',
'clientRequestId': 'recognition-candidate-1',
'state': 'auto_ready',
'confidence': 'auto',
'type': 'income',
'source': 'local_ocr',
'appName': '微信',
'amount': 8.88,
'occurredAtEpochMs': DateTime.utc(
2026,
7,
22,
14,
35,
).millisecondsSinceEpoch,
'recognitionKind': 'red_packet_receive',
'categoryHint': '红包',
'amountSource': 'result',
'resultFingerprint': 'abc123',
});
expect(candidate.recognitionKind, 'red_packet_receive');
expect(candidate.categoryHint, '红包');
expect(candidate.amountSource, 'result');
expect(candidate.resultFingerprint, 'abc123');
});
test('智能识别诊断摘要能区分关键失败类型', () {
RecognitionDiagnostic diagnostic(String result, String reason) {
return RecognitionDiagnostic(
at: DateTime.utc(2026, 7, 22, 14, 35),
appName: '微信',
stage: 'ocr',
result: result,
reason: reason,
nodeCount: 12,
amountCandidates: 1,
);
}
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('failed', 'capture_timeout'),
).summaryLabel,
'截图失败',
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('failed', 'no_text'),
).summaryLabel,
'OCR 无结果',
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('rejected', 'payment_input_page'),
).summaryLabel,
'规则拒绝',
);
expect(
RecognitionDiagnosticDisplay.from(
diagnostic('ignored', 'duplicate_result_surface'),
).summaryLabel,
'已合并',
);
});
}
+269
View File
@@ -0,0 +1,269 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/update/update_api.dart';
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
import 'package:miaoji_zhang/shared/update/update_prompt.dart';
import 'package:miaoji_zhang/shared/version.dart';
import 'package:package_info_plus/package_info_plus.dart';
void main() {
const release = AppRelease(
id: 'release-7',
versionName: '1.2.2',
buildNumber: 7,
downloadUrl: 'https://downloads.example.com/jizhi-7.apk',
releaseNotes: '## 本次更新\n\n- 修复识别问题\n- 优化更新体验',
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
fileSize: 1024,
publishedAt: null,
);
test('使用原生 versionName 和 buildNumber 展示版本', () async {
PackageInfo.setMockInitialValues(
appName: '记之',
packageName: 'com.nx.miaoji.internal',
version: '1.2.1-internal',
buildNumber: '6',
buildSignature: '',
);
await AppVersion.initialize();
expect(AppVersion.versionName, '1.2.1-internal');
expect(AppVersion.buildNumber, 6);
expect(AppVersion.display, 'v1.2.1-internal (6)');
});
test('更新响应保留发布信息并且只按 buildNumber 判断新旧', () {
final response = UpdateCheckResponse.fromJson({
'hasUpdate': true,
'forceUpdate': false,
'release': {
'id': 'release-7',
'versionName': '0.1.0',
'buildNumber': 7,
'downloadUrl': 'https://downloads.example.com/jizhi-7.apk',
'releaseNotes': '测试版本',
'sha256': release.sha256,
'fileSize': 1024,
'publishedAt': '2026-07-21T12:00:00Z',
},
});
expect(UpdatePolicy.availableRelease(response, currentBuild: 6), isNotNull);
expect(UpdatePolicy.availableRelease(response, currentBuild: 7), isNull);
expect(response.release?.versionName, '0.1.0');
});
test('忽略普通版本但不影响手动检查和强制更新', () {
expect(
UpdatePolicy.shouldPresent(
release: release,
forced: false,
manual: false,
ignoredReleaseId: release.id,
),
isFalse,
);
expect(
UpdatePolicy.shouldPresent(
release: release,
forced: false,
manual: true,
ignoredReleaseId: release.id,
),
isTrue,
);
expect(
UpdatePolicy.shouldPresent(
release: release,
forced: true,
manual: false,
ignoredReleaseId: release.id,
),
isTrue,
);
});
test('UpdateApi 发送公开参数且不附带登录请求配置', () async {
final adapter = _FakeAdapter(
statusCode: 200,
body: jsonEncode({
'hasUpdate': true,
'forceUpdate': false,
'release': {
'id': release.id,
'versionName': release.versionName,
'buildNumber': release.buildNumber,
'downloadUrl': release.downloadUrl,
'releaseNotes': release.releaseNotes,
'sha256': release.sha256,
'fileSize': release.fileSize,
'publishedAt': '2026-07-21T12:00:00Z',
},
}),
);
final dio = Dio(BaseOptions(baseUrl: 'https://version.test'))
..httpClientAdapter = adapter;
final result = await UpdateApi(
dio: dio,
).check(platform: 'android', channel: 'beta', currentBuild: 6);
expect(result.release?.buildNumber, 7);
expect(adapter.request?.path, '/api/client/v1/update');
expect(adapter.request?.queryParameters['platform'], 'android');
expect(adapter.request?.queryParameters['channel'], 'beta');
expect(adapter.request?.queryParameters['currentBuild'], 6);
expect(adapter.request?.headers['Authorization'], isNull);
});
test('协调器合并并发检查请求', () async {
final adapter = _FakeAdapter(
statusCode: 200,
body: '{"hasUpdate":false,"forceUpdate":false,"release":null}',
delay: const Duration(milliseconds: 20),
);
final dio = Dio(BaseOptions(baseUrl: 'https://version.test'))
..httpClientAdapter = adapter;
final coordinator = UpdateCoordinator(api: UpdateApi(dio: dio));
await Future.wait([
coordinator.debugCheck(platform: 'android'),
coordinator.debugCheck(platform: 'android'),
]);
expect(adapter.callCount, 1);
});
test('Internal 更新缺少 SHA-256 时在下载前拒绝', () async {
const unsafeRelease = AppRelease(
id: 'unsafe',
versionName: '1.2.2',
buildNumber: 7,
downloadUrl: 'https://downloads.example.com/jizhi-7.apk',
releaseNotes: '',
sha256: null,
fileSize: null,
publishedAt: null,
);
await expectLater(
UpdateDownloader().downloadAndVerify(unsafeRelease, onProgress: (_) {}),
throwsA(
isA<UpdateDownloadException>().having(
(error) => error.message,
'message',
contains('SHA-256'),
),
),
);
});
test('UpdateApi 将 404 和 429 转换为稳定错误', () async {
for (final entry in {
404: UpdateFailureKind.unavailable,
429: UpdateFailureKind.rateLimited,
}.entries) {
final dio = Dio(BaseOptions(baseUrl: 'https://version.test'))
..httpClientAdapter = _FakeAdapter(statusCode: entry.key, body: '{}');
await expectLater(
UpdateApi(
dio: dio,
).check(platform: 'ios', channel: 'stable', currentBuild: 6),
throwsA(
isA<UpdateCheckException>().having(
(error) => error.kind,
'kind',
entry.value,
),
),
);
}
});
testWidgets('普通更新面板展示说明并允许忽略', (tester) async {
var ignored = false;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: UpdatePromptSheet(
release: release,
forced: false,
onIgnore: () async => ignored = true,
),
),
),
);
expect(find.text('发现新版本'), findsOneWidget);
expect(find.textContaining('修复识别问题'), findsOneWidget);
expect(find.text('本次更新'), findsOneWidget);
expect(find.byType(MarkdownBody), findsOneWidget);
expect(find.text('忽略此版本'), findsOneWidget);
await tester.tap(find.text('忽略此版本'));
await tester.pump();
expect(ignored, isTrue);
});
testWidgets('强制更新面板没有忽略入口并适配深色主题', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark,
home: Scaffold(
body: UpdatePromptSheet(
release: release,
forced: true,
onIgnore: () async {},
),
),
),
);
expect(find.text('必须更新后继续使用'), findsOneWidget);
expect(find.text('忽略此版本'), findsNothing);
expect(find.text('立即更新'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
class _FakeAdapter implements HttpClientAdapter {
final int statusCode;
final String body;
RequestOptions? request;
int callCount = 0;
final Duration delay;
_FakeAdapter({
required this.statusCode,
required this.body,
this.delay = Duration.zero,
});
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
request = options;
callCount++;
if (delay > Duration.zero) await Future<void>.delayed(delay);
return ResponseBody.fromString(
body,
statusCode,
headers: {
Headers.contentTypeHeader: ['application/json'],
},
);
}
@override
void close({bool force = false}) {}
}
+204
View File
@@ -0,0 +1,204 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('启动页只读取本地缓存,不等待远程接口', () {
final source = File(
'lib/features/auth/pages/splash_page.dart',
).readAsStringSync();
expect(source, contains('loadCached'));
expect(source, isNot(contains('PublicConfigApi')));
expect(source, isNot(contains('AuthApi.me')));
expect(source, isNot(contains('ensureLoaded')));
});
test('业务页面不再直接使用系统感强的控件或旧青绿色板', () {
final banned = <String>[
'AlertDialog(',
'DropdownButtonFormField',
'showDatePicker(',
'showTimePicker(',
'PopupMenuButton',
'LinearGradient(',
'RadialGradient(',
'SweepGradient(',
'BackdropFilter(',
'0xFF087F6D',
'0xFF066B5C',
'0xFFE3F2EF',
];
final violations = <String>[];
for (final file
in Directory('lib/features')
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.path.endsWith('.dart'))) {
final source = file.readAsStringSync();
for (final token in banned) {
if (source.contains(token)) {
violations.add('${file.path}: $token');
}
}
}
expect(violations, isEmpty, reason: violations.join('\n'));
});
test('深色模式的选中与未选中状态使用语义色板', () {
final controls = File(
'lib/shared/widgets/app_controls.dart',
).readAsStringSync();
final addPage = File('lib/features/add/add_page.dart').readAsStringSync();
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
final companion = File(
'lib/features/settings/companion_page.dart',
).readAsStringSync();
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
final report = File(
'lib/features/stats/report_page.dart',
).readAsStringSync();
expect(controls, contains(': context.jz.card'));
expect(addPage, isNot(contains('selected ? _activeColor : Colors.white')));
expect(chat, isNot(contains('isMe ? AppTheme.primary : Colors.white')));
expect(
companion,
isNot(contains('context.jz.aiBackground : Colors.white')),
);
expect(me, isNot(contains('context.jz.primaryBackground : Colors.white')));
expect(report, isNot(contains('selected ? Colors.white')));
});
test('聊天附件只保留拍照和相册导入', () {
final source = File('lib/features/chat/chat_page.dart').readAsStringSync();
expect(source, contains('ImageSource.camera'));
expect(source, contains('ImageSource.gallery'));
expect(source, isNot(contains('截屏记账')));
expect(source, isNot(contains('手动输入文字')));
expect(source, isNot(contains('ScreenshotChannel.capture')));
});
test('表情包具备离线缓存、内置兜底和展开刷新', () {
final api = File('lib/shared/api/business_api.dart').readAsStringSync();
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
expect(api, contains('global_stickers_v1'));
expect(api, contains('_fallback'));
expect(api, contains('SharedPreferences.getInstance'));
expect(
chat,
contains('setState(() => _composerPanel = _ComposerPanel.stickers)'),
);
expect(chat, contains('_stkLoad();'));
});
test('无障碍只解析明确成功页且使用墙钟时间', () {
final parser = File(
'android/app/src/main/kotlin/com/nx/miaoji/PaymentParser.kt',
).readAsStringSync();
expect(parser, contains('occurredAt = System.currentTimeMillis()'));
expect(parser, contains('historyTitles'));
expect(parser, contains('"扫码支付成功"'));
expect(parser, contains('"转账已完成"'));
expect(parser, contains('STANDALONE_AMOUNT'));
expect(parser, contains('MAX_PAYMENT_SCAN_LINES = 48'));
expect(parser, isNot(contains('listOf("收款成功", "已收款", "到账"')));
expect(parser, isNot(contains('"支出", "消费成功"')));
expect(parser, isNot(contains('listOf("微信支付", "支付宝", "交易成功")')));
final service = File(
'android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt',
).readAsStringSync();
expect(service, contains('AccessibilityEvent.TYPE_WINDOWS_CHANGED'));
expect(service, contains('AccessibilityEvent.TYPE_VIEW_CLICKED'));
expect(service, contains('PaymentFlow('));
expect(service, contains('PAYMENT_FLOW_TTL_MS = 90_000L'));
expect(service, contains('VISUAL_STABILITY_DELAY_MS = 700L'));
expect(service, contains('takeScreenshotOfWindow'));
expect(service, contains('bitmap.recycle()'));
expect(service, contains('CAPTURE_CALLBACK_TIMEOUT_MS = 6_000L'));
expect(service, contains('OCR_CALLBACK_TIMEOUT_MS = 12_000L'));
final localOcr = File(
'android/app/src/main/kotlin/com/nx/miaoji/LocalPaymentOcr.kt',
).readAsStringSync();
expect(localOcr, contains('ChineseTextRecognizerOptions'));
expect(localOcr, contains('InputImage.fromBitmap'));
expect(localOcr, contains('distinctCents.size == 1'));
expect(localOcr, contains('evidenceConfidence = if (highConfidence)'));
final store = File(
'android/app/src/main/kotlin/com/nx/miaoji/RecognitionStore.kt',
).readAsStringSync();
expect(store, contains('"local_ocr" -> 8'));
expect(store, contains('flowSessionId'));
expect(store, contains('ACCESSIBILITY_NOTIFICATION_MASK'));
final gradle = File('android/app/build.gradle.kts').readAsStringSync();
expect(
gradle,
contains('com.google.mlkit:text-recognition-chinese:16.0.1'),
);
});
test('本地 OCR 需要一次性用途确认并提供无隐私诊断', () {
final settings = File(
'lib/features/settings/screenshot_settings_page.dart',
).readAsStringSync();
final legal = File(
'lib/features/settings/legal_document_page.dart',
).readAsStringSync();
final diagnostics = File(
'android/app/src/main/kotlin/com/nx/miaoji/RecognitionDiagnostics.kt',
).readAsStringSync();
final formatter = File(
'lib/shared/services/recognition_diagnostic_formatter.dart',
).readAsStringSync();
expect(settings, contains('local_ocr_consent_v1'));
expect(settings, contains('图片只在内存中由本地 OCR 处理'));
expect(settings, contains('_RecognitionDiagnosticCard'));
expect(settings, contains('Timer.periodic'));
expect(formatter, contains('operation_interrupted'));
expect(formatter, contains('截图失败'));
expect(formatter, contains('OCR 无结果'));
expect(formatter, contains('规则拒绝'));
expect(formatter, contains('已合并'));
expect(legal, contains('ML Kit 中文文字识别(Google'));
expect(diagnostics, isNot(contains('sourceText')));
expect(diagnostics, isNot(contains('image')));
expect(diagnostics, contains('STALE_OPERATION_MS = 20_000L'));
final service = File(
'android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt',
).readAsStringSync();
expect(service, contains('resolveAccessibilityWindowId'));
final localOcr = File(
'android/app/src/main/kotlin/com/nx/miaoji/LocalPaymentOcr.kt',
).readAsStringSync();
expect(localOcr, contains('MlKitContext.initializeIfNeeded'));
expect(localOcr, contains('ocr_model_unavailable'));
expect(localOcr, contains('OcrDiagnosticRedactor'));
expect(localOcr, contains('qualifiesWeakAuto'));
expect(settings, contains('OCR 诊断预览'));
expect(settings, contains('10 分钟后自动关闭'));
expect(diagnostics, contains('statusStrength'));
expect(diagnostics, contains('expectedAmountMatched'));
expect(diagnostics, contains('resultTransitionObserved'));
expect(diagnostics, contains('ocrPreview'));
expect(service, contains('shouldFallbackToDisplay'));
expect(service, contains('ERROR_TAKE_SCREENSHOT_INVALID_WINDOW'));
expect(service, contains('WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L'));
});
test('智能识别授权未完成时不会提前打开开关', () {
final source = File(
'lib/features/settings/screenshot_settings_page.dart',
).readAsStringSync();
expect(source, contains('_pendingAuthorizationKey'));
expect(source, contains('未完成系统授权,识别开关仍保持关闭'));
expect(source, contains('setRecognitionToggle(key, false)'));
expect(source, contains('_BackgroundKeepAliveCard'));
});
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miaoji_zhang/features/ai_mode/pages/ai_mode_page.dart';
import 'package:miaoji_zhang/features/auth/pages/login_page.dart';
import 'package:miaoji_zhang/features/settings/me_page.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/ai_access_gate.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
import 'package:miaoji_zhang/shared/widgets/brand_logo.dart';
void main() {
testWidgets('错误状态无需真实 API 即可重试', (tester) async {
var retries = 0;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: AsyncErrorView(
message: '网络开小差了',
onRetry: () async => retries++,
),
),
),
);
expect(find.text('网络开小差了'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pump();
expect(retries, 1);
});
testWidgets('全 AI 模式预算条展示未设置状态并可进入设置', (tester) async {
var taps = 0;
final data = BudgetsData.fromJson({'total': null, 'categories': []});
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: AiBudgetStrip(data: data, onTap: () => taps++),
),
),
);
expect(find.text('本月还没设置预算'), findsOneWidget);
expect(find.text('去设置'), findsOneWidget);
await tester.tap(find.byType(AiBudgetStrip));
expect(taps, 1);
});
testWidgets('全 AI 模式预算条明确展示超支金额', (tester) async {
final data = BudgetsData.fromJson({
'total': {
'categoryId': null,
'categoryName': null,
'categoryIcon': null,
'amount': 1000,
'spent': 1250,
'isRecurring': false,
},
'categories': [],
});
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: AiBudgetStrip(data: data, onTap: () {}),
),
),
);
expect(find.text('已超 ¥250'), findsOneWidget);
expect(find.text('已用 ¥1250'), findsOneWidget);
expect(find.text('总额 ¥1000'), findsOneWidget);
});
testWidgets('登录与启动页共用的新品牌图标可加载', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: const Scaffold(body: Center(child: BrandLogo(size: 64))),
),
);
await tester.pumpAndSettle();
expect(find.byType(BrandLogo), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('登录页合规入口只展示协议和隐私政策', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: const Scaffold(body: LoginLegalLinks()),
),
);
expect(find.text('用户协议'), findsOneWidget);
expect(find.text('隐私政策'), findsOneWidget);
expect(find.text('权限用途'), findsNothing);
expect(find.text('第三方 SDK'), findsNothing);
});
testWidgets('游客本地状态是信息卡而不是禁用开关', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: const Scaffold(body: GuestLocalStatusCard()),
),
);
expect(find.text('本机保存中'), findsOneWidget);
expect(find.text('游客模式'), findsOneWidget);
expect(find.byType(JzSwitchTile), findsNothing);
});
testWidgets('游客 AI 页面只展示登录引导', (tester) async {
var actions = 0;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: AiAccessGate(
state: AiAccessState.guest,
onAction: () async => actions++,
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('登录后使用 AI 助手'), findsOneWidget);
expect(find.text('登录后使用'), findsOneWidget);
await tester.tap(find.text('登录后使用'));
await tester.pump();
expect(actions, 1);
});
}