Initial project import
This commit is contained in:
@@ -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')));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
'已合并',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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}) {}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user