From d0ceccf10d8e3e6e3bada89a16259aea476cd06f Mon Sep 17 00:00:00 2001 From: yuqei Date: Tue, 26 May 2026 08:37:28 +0800 Subject: [PATCH] feat: close pc and android product smoke flows --- .gitignore | 2 + frontend/src/app/admin/page.tsx | 25 + frontend/src/app/contracts/[id]/page.tsx | 11 +- frontend/src/app/contracts/page.tsx | 20 +- mobile/STATUS.md | 65 +++ mobile/lib/app/shell/app_shell.dart | 13 + .../presentation/dashboard_screen.dart | 93 +++- .../domain/legal_document_review_context.dart | 201 ++++++++ .../legal_document_builder_screen.dart | 41 ++ .../legal_document_type_list_screen.dart | 60 ++- .../presentation/review_detail_screen.dart | 52 ++ .../presentation/dashboard_screen_test.dart | 89 ++++ .../legal_document_review_context_test.dart | 172 +++++++ .../legal_document_screens_test.dart | 113 ++++- mobile/tools/real-device-smoke.ps1 | 469 ++++++++++++++++-- .../run-real-device-product-smoke-suite.ps1 | 231 +++++++++ 16 files changed, 1572 insertions(+), 85 deletions(-) create mode 100644 mobile/lib/features/documents/domain/legal_document_review_context.dart create mode 100644 mobile/test/features/dashboard/presentation/dashboard_screen_test.dart create mode 100644 mobile/test/features/documents/domain/legal_document_review_context_test.dart create mode 100644 mobile/tools/run-real-device-product-smoke-suite.ps1 diff --git a/.gitignore b/.gitignore index fa8763cc..0b824a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ __pycache__/ backend/.pytest_cache/ # Local secrets and generated artifacts +.env.deploytmp +storage/ backend/.env backend/storage/ backend/tmp_migration_check.db diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 2b4282f7..72d7f732 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,4 +1,10 @@ +"use client"; + import Link from "next/link"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Result } from "antd"; +import { useAuthStore } from "@/stores/auth.store"; const adminSections = [ { @@ -64,6 +70,25 @@ const adminSections = [ ]; export default function AdminPage() { + const router = useRouter(); + const currentUser = useAuthStore((state) => state.currentUser); + + useEffect(() => { + if (currentUser && currentUser.role !== "admin") { + router.replace("/"); + } + }, [currentUser, router]); + + if (currentUser && currentUser.role !== "admin") { + return ( +
+
+ +
+
+ ); + } + return (
diff --git a/frontend/src/app/contracts/[id]/page.tsx b/frontend/src/app/contracts/[id]/page.tsx index d6f842e9..96f9852c 100644 --- a/frontend/src/app/contracts/[id]/page.tsx +++ b/frontend/src/app/contracts/[id]/page.tsx @@ -357,10 +357,17 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis return; } const previewResponse = await getGeneratedContractPreview(id); - const lifecycleTaskResponse = await listContractLifecycleTasks(id); + let lifecycleTaskResponse: Awaited> | null = null; + try { + lifecycleTaskResponse = await listContractLifecycleTasks(id); + } catch (error: unknown) { + if (!(isRequestError(error) && error.status === 404)) { + throw error; + } + } setDetail(detailResponse.data); setPreview(previewResponse.code === 0 ? previewResponse.data : null); - setLifecycleTasks(lifecycleTaskResponse.code === 0 ? lifecycleTaskResponse.data.items : []); + setLifecycleTasks(lifecycleTaskResponse?.code === 0 ? lifecycleTaskResponse.data.items : []); } catch (error: unknown) { if (isRequestError(error) && error.status === 404) { setNotFound(true); diff --git a/frontend/src/app/contracts/page.tsx b/frontend/src/app/contracts/page.tsx index 6a500d8a..23f97827 100644 --- a/frontend/src/app/contracts/page.tsx +++ b/frontend/src/app/contracts/page.tsx @@ -297,6 +297,10 @@ export default function ContractsPage() { const [lifecycleModalOpen, setLifecycleModalOpen] = useState(false); const [lifecycleRecord, setLifecycleRecord] = useState(null); const [lifecycleUpdating, setLifecycleUpdating] = useState(false); + const canDownloadContracts = currentUser?.role === "admin"; + const canOpenReviewDetail = (autoReview?: AutoReviewSummary | null) => Boolean(autoReview?.detail_url); + const canOpenReviewChat = (autoReview?: AutoReviewSummary | null) => Boolean(autoReview?.chat_url); + const canDownloadReview = (autoReview?: AutoReviewSummary | null) => Boolean(autoReview?.report_download_url); const clearRestoreQuery = useCallback(() => { if (!searchParams.get("restore")) { @@ -599,9 +603,9 @@ export default function ContractsPage() { return; } - const target = autoReview.detail_url ?? (autoReview.review_id ? `/reviews/${autoReview.review_id}` : null); + const target = autoReview.detail_url; if (!target) { - messageApi.warning("自动审查详情尚未生成"); + messageApi.warning("自动审查详情尚未生成或当前记录未绑定可访问详情"); return; } @@ -813,7 +817,7 @@ export default function ContractsPage() { {autoReview.current_stage_label ?? autoReview.next_action ?? "-"} - @@ -843,7 +847,7 @@ export default function ContractsPage() { - @@ -1103,13 +1107,13 @@ export default function ContractsPage() { {generatedContract.auto_review.review_opinion ? 审查意见: {generatedContract.auto_review.review_opinion} : null} - - - @@ -1138,7 +1142,7 @@ export default function ContractsPage() { {currentUser?.role === "admin" && generatedContract.file_id ? ( ) : null} - diff --git a/mobile/STATUS.md b/mobile/STATUS.md index f42a714f..683aba47 100644 --- a/mobile/STATUS.md +++ b/mobile/STATUS.md @@ -64,6 +64,14 @@ Created on 2026-04-18. - the `AI工具` tab now exposes a legal-document workspace alongside contract generation - document types, element schemas, drafts, validation, and generate-from-draft are modeled on the client - focused tests now cover the new legal-document models and controller flows +- Legal-document drafting now accepts review-context handoff from review detail: + - review detail shows a dedicated `起草法律文书` entry point + - the legal-document type list pre-fills its search query from the review result recommendation + - the legal-document builder pre-fills title and matching schema fields from the review result + - focused tests now cover `LegalDocumentReviewContext` mapping plus the type-list/builder handoff + - validation completed with `flutter analyze --no-pub`, `flutter test --no-pub`, and `flutter build apk --debug --no-pub --dart-define=API_BASE_URL=http://192.168.3.90:8001/api/v1 --dart-define=APP_ENV=server-dogfood` + - `mobile/tools/real-device-smoke.ps1 -ConfigureDevice -SmokeDepth documents-workspace` is still blocked by wireless ADB discovery on the current machine (`SMOKE_BLOCKER=wireless_adb_unavailable`) + - the refreshed wireless-debug port `192.168.3.93:35593` passed `documents-workspace` smoke on the real device with `SMOKE_OK=True` - Chat bubbles now surface source relevance when available, and will explicitly warn when a round returns no directly citeable sources - Legal chat steadiness is now stronger on mobile: - assistant bubbles classify the answer into `已引用来源 / 通用建议 / 结论不确定 / 需补充资料` @@ -608,3 +616,60 @@ Created on 2026-04-18. - result summary rendered `风险重点` with `保密义务建议细化` and `付款与验收条件建议明确` - result summary rendered `修改建议` with concrete confidentiality and payment / acceptance suggestions - proof screenshot: `mobile/qa-artifacts/manual-review-detail-insights/review-detail-insights.png` + +## Review to legal-document handoff closure (2026-05-26) + +- The mobile review-history -> legal-document drafting handoff now works on the real wireless X60PRO flow. +- The smoke script now scrolls to the actual review card, opens the review detail page, enters legal-document drafting, and verifies both the type list and builder are carrying the review context forward. +- Validation: + - `flutter analyze --no-pub` + - `flutter test --no-pub` + - `flutter build apk --debug --no-pub --dart-define=API_BASE_URL=http://192.168.3.90:8001/api/v1 --dart-define=APP_ENV=server-dogfood` + - `mobile/tools/real-device-smoke.ps1 -DeviceSerial 192.168.3.93:35593 -ConfigureDevice -SmokeDepth review-legal-document -LoginAccount cyuqei@gmail.com` returned `SMOKE_OK=True` + - artifact bundle: `mobile/qa-artifacts/real-device-smoke/2026-05-26T02-02-19-720/` + +## Dashboard recent-review handoff closure (2026-05-26) + +- The dashboard's recent review preview now acts as a real entry point into review detail instead of a dead preview card. +- Tapping a recent review now switches to history and opens the matching review detail page with the review title prefilled. +- Validation: + - `flutter analyze --no-pub` + - `flutter test --no-pub` + - `flutter build apk --debug --no-pub --dart-define=API_BASE_URL=http://192.168.3.90:8001/api/v1 --dart-define=APP_ENV=server-dogfood` + - `mobile/tools/real-device-smoke.ps1 -DeviceSerial 192.168.3.93:40011 -ConfigureDevice -SmokeDepth dashboard-review-preview -LoginAccount cyuqei@gmail.com` returned `SMOKE_OK=True` + - artifact bundle: `mobile/qa-artifacts/real-device-smoke/2026-05-26T06-53-17-885/` + +## Business auxiliary real-device closure (2026-05-26) + +- The mobile `AI工具` business auxiliary strip is now validated as a real user path on the wireless X60PRO device. +- The smoke helper now taps the shortcut by Android `content-desc` button bounds, which matches Flutter's accessibility tree for these quick-prompt chips. +- Real-device proof confirms all 4 business auxiliary entries are visible and that `提取履约义务` can trigger the shared Chat flow without leaving the app surface. +- Validation: + - PowerShell parser check for `mobile/tools/real-device-smoke.ps1` returned `PARSE_OK` + - `flutter test --no-pub test/features/tools/presentation/tools_hub_screen_test.dart` + - `flutter analyze --no-pub` + - `flutter test --no-pub` + - `mobile/tools/real-device-smoke.ps1 -DeviceSerial 192.168.3.93:40011 -ConfigureDevice -SmokeDepth business-auxiliary -LoginAccount cyuqei@gmail.com` returned `SMOKE_OK=True` + - artifact bundle: `mobile/qa-artifacts/real-device-smoke/2026-05-26T07-01-10-567/` + +## Product smoke suite scaffold (2026-05-26) + +- A batch runner now exists at `mobile/tools/run-real-device-product-smoke-suite.ps1`. +- The suite chains the current Android product paths into one reportable run: `shell-only`, `share-proof`, `contract-taxonomy`, `documents-workspace`, `review-legal-document`, `dashboard-review-preview`, and `business-auxiliary`. +- The suite writes both `suite-summary.json` and `suite-summary.txt`, so later Android rounds can capture one artifact bundle instead of many ad hoc shell invocations. +- Validation: + - PowerShell parser check for `mobile/tools/run-real-device-product-smoke-suite.ps1` returned `PARSE_OK` + - a one-step failure-path run on `192.168.3.93:40011` still produced a suite report and per-step summary + - current blocker is external: wireless ADB for `192.168.3.93:40011` is unavailable right now, so the suite could not complete a live device pass + - suite artifact root: `mobile/qa-artifacts/real-device-product-smoke-suite/2026-05-26T07-46-28-254/` + +## Product smoke suite pass (2026-05-26) + +- The real-device Android product smoke suite now passes end-to-end on the wireless device `192.168.3.93:43767`. +- Two remaining failures were closed in the smoke helper rather than the app itself: + - `share-proof` now scrolls to the real report section, taps the real `分享报告` button, and dismisses the low-battery system warning if it appears before the Android chooser is captured. + - `documents-workspace` now only accepts real `open-legal-document-*` entry buttons and no longer falls back to the generic management buttons that caused false starts. +- Latest validation: + - `mobile/tools/run-real-device-product-smoke-suite.ps1 -DeviceSerial 192.168.3.93:43767 -SkipInitialInstall` returned `SUITE_OK=True` + - passed steps: `shell-only`, `share-proof`, `contract-taxonomy`, `documents-workspace`, `review-legal-document`, `dashboard-review-preview`, `business-auxiliary` + - suite artifact root: `mobile/qa-artifacts/real-device-product-smoke-suite/2026-05-26T08-24-01-233/` diff --git a/mobile/lib/app/shell/app_shell.dart b/mobile/lib/app/shell/app_shell.dart index db23ad7f..136944ff 100644 --- a/mobile/lib/app/shell/app_shell.dart +++ b/mobile/lib/app/shell/app_shell.dart @@ -324,6 +324,19 @@ class _AiContractAppShellState extends State onOpenLegalChat: _openTools, onOpenContracts: _openContractWorkspace, onOpenDocuments: _openDocumentWorkspace, + onOpenReviewDetail: (item) { + _selectTab(AppTab.history); + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReviewDetailScreen( + reviewId: item.id, + initialTitle: item.title, + ), + ), + ), + ); + }, ); case AppTab.review: return const ReviewEntryScreen(key: ValueKey(AppTab.review)); diff --git a/mobile/lib/features/dashboard/presentation/dashboard_screen.dart b/mobile/lib/features/dashboard/presentation/dashboard_screen.dart index 3a952424..6d39e030 100644 --- a/mobile/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/mobile/lib/features/dashboard/presentation/dashboard_screen.dart @@ -18,6 +18,8 @@ class DashboardScreen extends StatefulWidget { required this.onOpenLegalChat, required this.onOpenContracts, required this.onOpenDocuments, + this.historyController, + this.onOpenReviewDetail, }); final VoidCallback onStartReview; @@ -26,17 +28,20 @@ class DashboardScreen extends StatefulWidget { final VoidCallback onOpenLegalChat; final VoidCallback onOpenContracts; final VoidCallback onOpenDocuments; + final ReviewHistoryController? historyController; + final ValueChanged? onOpenReviewDetail; @override State createState() => _DashboardScreenState(); } class _DashboardScreenState extends State { - final ReviewHistoryController _historyController = ReviewHistoryController(); + late final ReviewHistoryController _historyController; @override void initState() { super.initState(); + _historyController = widget.historyController ?? ReviewHistoryController(); _historyController.load(); } @@ -70,6 +75,7 @@ class _DashboardScreenState extends State { _RecentReviews( items: items, onOpenHistory: widget.onOpenHistory, + onOpenReviewDetail: widget.onOpenReviewDetail, onRefresh: _historyController.load, ), ], @@ -210,11 +216,13 @@ class _RecentReviews extends StatelessWidget { const _RecentReviews({ required this.items, required this.onOpenHistory, + required this.onOpenReviewDetail, required this.onRefresh, }); final List items; final VoidCallback onOpenHistory; + final ValueChanged? onOpenReviewDetail; final Future Function() onRefresh; @override @@ -236,7 +244,15 @@ class _RecentReviews extends StatelessWidget { const Text('暂无审查记录。') else for (final item in latestItems) ...[ - _ReviewPreview(item: item), + _ReviewPreview( + item: item, + onTap: () { + final handler = onOpenReviewDetail; + if (handler != null) { + handler(item); + } + }, + ), if (item != latestItems.last) const Divider(height: 24), ], const SizedBox(height: 12), @@ -384,44 +400,67 @@ class _ActionCopy extends StatelessWidget { } class _ReviewPreview extends StatelessWidget { - const _ReviewPreview({required this.item}); + const _ReviewPreview({ + required this.item, + required this.onTap, + }); final ReviewHistoryItem item; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 40, - height: 40, + return Semantics( + label: 'open-review-preview-${item.id}', + button: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(BrandAssets.radius), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFFE0F7F4), - borderRadius: BorderRadius.circular(8), + color: BrandAssets.surfaceSoft, + borderRadius: BorderRadius.circular(BrandAssets.radius), + border: Border.all(color: BrandAssets.line), ), - child: const Icon(Icons.article_outlined, color: BrandAssets.brand), - ), - const SizedBox(width: 12), - Expanded( - child: Column( + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.title, style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 4), - Text( - '${ReviewLabels.contractType(item.contractType)} · ${ReviewLabels.status(item.status)}', - style: Theme.of(context).textTheme.bodySmall, - ), - if (item.riskLevel != null && item.riskLevel!.isNotEmpty) - Text( - ReviewLabels.riskLevel(item.riskLevel!), - style: Theme.of(context).textTheme.bodySmall, + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFE0F7F4), + borderRadius: BorderRadius.circular(8), ), + child: const Icon(Icons.article_outlined, + color: BrandAssets.brand), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, + style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + Text( + '${ReviewLabels.contractType(item.contractType)} · ${ReviewLabels.status(item.status)}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (item.riskLevel != null && item.riskLevel!.isNotEmpty) + Text( + ReviewLabels.riskLevel(item.riskLevel!), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), ], ), ), - ], + ), ); } } diff --git a/mobile/lib/features/documents/domain/legal_document_review_context.dart b/mobile/lib/features/documents/domain/legal_document_review_context.dart new file mode 100644 index 00000000..4d13c0c9 --- /dev/null +++ b/mobile/lib/features/documents/domain/legal_document_review_context.dart @@ -0,0 +1,201 @@ +import '../../review_history/domain/review_detail.dart'; +import '../../review_history/domain/review_result_summary.dart'; +import 'legal_document_models.dart'; + +class LegalDocumentReviewContext { + const LegalDocumentReviewContext({ + required this.reviewId, + required this.reviewTitle, + required this.contractType, + required this.overallRisk, + required this.reviewOpinion, + required this.recommendation, + required this.riskItems, + required this.suggestions, + required this.sourceTextPreview, + }); + + final String reviewId; + final String reviewTitle; + final String contractType; + final String overallRisk; + final String reviewOpinion; + final String recommendation; + final List riskItems; + final List suggestions; + final String? sourceTextPreview; + + factory LegalDocumentReviewContext.fromReview({ + required ReviewDetail detail, + required ReviewResultSummary result, + }) { + return LegalDocumentReviewContext( + reviewId: detail.id, + reviewTitle: detail.title, + contractType: result.contractType == 'unknown' + ? detail.contractType + : result.contractType, + overallRisk: result.riskSummary.overall, + reviewOpinion: result.reviewOpinion, + recommendation: result.recommendation, + riskItems: result.riskItemLabels, + suggestions: result.suggestionHighlights, + sourceTextPreview: detail.sourceTextPreview, + ); + } + + List get suggestedDocumentTypeCodes { + final text = _combinedText.toLowerCase(); + final codes = []; + if (_hasAny(text, const ['解除', '终止', 'rescission', 'terminate'])) { + codes.add('rescission_notice'); + } + if (_hasAny(text, const ['违约', '催告', '逾期', '付款', '交付', '履约'])) { + codes.add('demand_letter'); + } + if (_hasAny(text, const ['授权', '委托', '代理', 'power', 'attorney'])) { + codes.add('power_of_attorney'); + } + if (_hasAny( + text, const ['法律意见', '人工复核', '重大风险', 'legal opinion'])) { + codes.add('legal_opinion'); + } + if (overallRisk == 'high' && !codes.contains('legal_opinion')) { + codes.add('legal_opinion'); + } + if (codes.isEmpty) { + codes.addAll(const ['demand_letter', 'legal_opinion']); + } + return codes.toSet().toList(growable: false); + } + + String get recommendedSearchQuery => + _documentTypeLabel(suggestedDocumentTypeCodes.first); + + String buildInitialTitle(LegalDocumentTypeItem documentType) { + final title = reviewTitle.trim().isNotEmpty ? reviewTitle.trim() : reviewId; + return '${documentType.name} - $title'; + } + + Map buildInitialElementValues( + LegalDocumentElementSchema schema, + ) { + final values = {}; + for (final section in schema.sections) { + for (final field in section.fields) { + final value = buildInitialFieldValue(field); + if (value is String && value.trim().isNotEmpty) { + values[field.key] = value.trim(); + } else if (value is bool) { + values[field.key] = value; + } + } + } + return values; + } + + dynamic buildInitialFieldValue(LegalDocumentElementField field) { + if (field.type == 'checkbox' || field.type == 'clause_toggle') { + return false; + } + + final matcher = + '${field.key} ${field.label} ${field.description}'.toLowerCase(); + final riskSummary = _riskSummary; + final actionSummary = _actionSummary; + + if (_matches(matcher, r'title|name|subject|标题|名称|项目名称|合同名称')) { + return reviewTitle; + } + if (_matches(matcher, r'contract.*type|document.*type|类型|合同类型|文书类型')) { + return contractType; + } + if (_matches( + matcher, r'scope|background|context|fact|核查范围|事实基础|背景|项目情况|基础材料')) { + return _compactLines([ + '审查任务:$reviewTitle', + if (sourceTextPreview?.trim().isNotEmpty == true) + '原文摘要:${sourceTextPreview!.trim()}', + if (riskSummary.isNotEmpty) riskSummary, + ]); + } + if (_matches(matcher, r'risk|issue|finding|problem|风险|问题|争议|重点')) { + return riskSummary; + } + if (_matches( + matcher, r'suggestion|action|remedy|strategy|建议|处理|整改|谈判|催告')) { + return actionSummary.isNotEmpty ? actionSummary : riskSummary; + } + if (_matches(matcher, r'evidence|source|material|document|证据|材料|文件|原文')) { + return _compactLines([ + if (sourceTextPreview?.trim().isNotEmpty == true) + sourceTextPreview!.trim(), + if (riskItems.isNotEmpty) '风险项:${riskItems.join(';')}', + ]); + } + if (_matches(matcher, r'purpose|goal|出具目的|用途')) { + return '基于审查任务 $reviewId 的结果,辅助准备后续法律文书。'; + } + if (_matches(matcher, r'opinion|analysis|conclusion|legal|法律分析|意见|结论|依据')) { + return _compactLines([ + if (reviewOpinion.trim().isNotEmpty) '审查意见:${reviewOpinion.trim()}', + if (riskSummary.isNotEmpty) riskSummary, + '移动端已从审查结果带入初始信息,正式出具前仍需人工复核事实、证据和法源。', + ]); + } + return ''; + } + + String get _combinedText => _compactLines([ + reviewTitle, + contractType, + overallRisk, + reviewOpinion, + recommendation, + ...riskItems, + ...suggestions, + if (sourceTextPreview != null) sourceTextPreview!, + ]); + + String get _riskSummary => _compactLines([ + if (overallRisk.trim().isNotEmpty) '总体风险:$overallRisk', + if (riskItems.isNotEmpty) '重点风险:${riskItems.join(';')}', + if (reviewOpinion.trim().isNotEmpty) '审查意见:${reviewOpinion.trim()}', + ]); + + String get _actionSummary => _compactLines([ + if (recommendation.trim().isNotEmpty) '建议动作:${recommendation.trim()}', + if (suggestions.isNotEmpty) '处理建议:${suggestions.join(';')}', + if (reviewOpinion.trim().isNotEmpty) '审查意见:${reviewOpinion.trim()}', + ]); +} + +bool _hasAny(String text, List needles) { + return needles.any((needle) => text.contains(needle.toLowerCase())); +} + +bool _matches(String text, String pattern) { + return RegExp(pattern, caseSensitive: false).hasMatch(text); +} + +String _compactLines(Iterable lines) { + return lines + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .join('\n'); +} + +String _documentTypeLabel(String code) { + switch (code) { + case 'rescission_notice': + return '解除合同通知书'; + case 'demand_letter': + return '催告函'; + case 'power_of_attorney': + return '授权委托书'; + case 'legal_opinion': + return '法律意见书'; + default: + return code; + } +} diff --git a/mobile/lib/features/documents/presentation/legal_document_builder_screen.dart b/mobile/lib/features/documents/presentation/legal_document_builder_screen.dart index 1aacff3b..c245e600 100644 --- a/mobile/lib/features/documents/presentation/legal_document_builder_screen.dart +++ b/mobile/lib/features/documents/presentation/legal_document_builder_screen.dart @@ -9,15 +9,18 @@ import '../../../shared/widgets/app_section_card.dart'; import '../../contracts/presentation/generated_contract_result_screen.dart'; import '../application/legal_document_builder_controller.dart'; import '../domain/legal_document_models.dart'; +import '../domain/legal_document_review_context.dart'; class LegalDocumentBuilderScreen extends StatefulWidget { const LegalDocumentBuilderScreen({ super.key, required this.documentType, + this.reviewContext, this.controller, }); final LegalDocumentTypeItem documentType; + final LegalDocumentReviewContext? reviewContext; final LegalDocumentBuilderController? controller; @override @@ -84,12 +87,20 @@ class _LegalDocumentBuilderScreenState _suppressAutoSave = true; try { final draftValues = draft?.elementValues ?? {}; + final reviewContext = widget.reviewContext; + if (draft == null && reviewContext != null) { + draftValues.addAll(reviewContext.buildInitialElementValues(schema)); + } _titleController.text = draft?.title.isNotEmpty == true ? draft!.title : '${widget.documentType.name}草稿'; _selectedTemplateId = draft?.templateId.isNotEmpty == true ? draft!.templateId : null; _exportFormat = 'docx'; + if (draft == null && reviewContext != null) { + _titleController.text = + reviewContext.buildInitialTitle(widget.documentType); + } for (final section in schema.sections) { for (final field in section.fields) { @@ -386,6 +397,36 @@ class _LegalDocumentBuilderScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (widget.reviewContext != null) ...[ + Semantics( + label: 'legal-document-builder-review-context', + child: AppSectionCard( + title: '已带入审查结果', + subtitle: '审查标题、风险摘要和建议动作会优先填入合适的文书字段。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLine( + label: '审查标题', + value: widget.reviewContext!.reviewTitle, + ), + const SizedBox(height: 8), + _InfoLine( + label: '总体风险', + value: widget.reviewContext!.overallRisk, + ), + const SizedBox(height: 8), + _InfoLine( + label: '推荐文书', + value: + widget.reviewContext!.recommendedSearchQuery, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], BrandHeroBand( title: widget.documentType.name, subtitle: widget.documentType.description.isNotEmpty diff --git a/mobile/lib/features/documents/presentation/legal_document_type_list_screen.dart b/mobile/lib/features/documents/presentation/legal_document_type_list_screen.dart index 78ea311a..14293f28 100644 --- a/mobile/lib/features/documents/presentation/legal_document_type_list_screen.dart +++ b/mobile/lib/features/documents/presentation/legal_document_type_list_screen.dart @@ -6,11 +6,17 @@ import '../../../shared/widgets/app_scaffold_page.dart'; import '../../../shared/widgets/app_section_card.dart'; import '../application/legal_document_type_controller.dart'; import '../domain/legal_document_models.dart'; +import '../domain/legal_document_review_context.dart'; import 'legal_document_builder_screen.dart'; class LegalDocumentTypeListScreen extends StatefulWidget { - const LegalDocumentTypeListScreen({super.key, this.controller}); + const LegalDocumentTypeListScreen({ + super.key, + this.reviewContext, + this.controller, + }); + final LegalDocumentReviewContext? reviewContext; final LegalDocumentTypeController? controller; @override @@ -33,7 +39,11 @@ class _LegalDocumentTypeListScreenState super.initState(); _controller = widget.controller ?? LegalDocumentTypeController(); _ownsController = widget.controller == null; - _controller.load(); + final reviewContext = widget.reviewContext; + if (reviewContext != null) { + _searchController.text = reviewContext.recommendedSearchQuery; + } + _controller.load(query: _searchController.text); } @override @@ -209,7 +219,10 @@ class _LegalDocumentTypeListScreenState Future _openBuilder(LegalDocumentTypeItem item) async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => LegalDocumentBuilderScreen(documentType: item), + builder: (_) => LegalDocumentBuilderScreen( + documentType: item, + reviewContext: widget.reviewContext, + ), ), ); } @@ -250,6 +263,39 @@ class _LegalDocumentTypeListScreenState ); }, ), + if (widget.reviewContext != null) ...[ + const SizedBox(height: 16), + Semantics( + label: 'legal-document-review-context', + child: AppSectionCard( + title: '从审查结果继续起草', + subtitle: '已按审查结论推荐文书类型,进入表单后会自动带入风险摘要和处理建议。', + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + BrandChip( + label: + widget.reviewContext!.recommendedSearchQuery, + color: BrandAssets.brand, + icon: Icons.auto_awesome_outlined, + ), + BrandChip( + label: widget.reviewContext!.overallRisk, + color: BrandAssets.accentAmber, + icon: Icons.warning_amber_rounded, + ), + BrandChip( + label: _compactChipLabel( + widget.reviewContext!.reviewTitle), + color: BrandAssets.primaryDark, + icon: Icons.article_outlined, + ), + ], + ), + ), + ), + ], const SizedBox(height: 16), AppSectionCard( title: '搜索与筛选', @@ -395,6 +441,14 @@ class _LegalDocumentTypeListScreenState } } +String _compactChipLabel(String value) { + final text = value.trim(); + if (text.length <= 24) { + return text; + } + return '${text.substring(0, 24)}...'; +} + class _DocumentTypeCard extends StatelessWidget { const _DocumentTypeCard({ required this.item, diff --git a/mobile/lib/features/review_history/presentation/review_detail_screen.dart b/mobile/lib/features/review_history/presentation/review_detail_screen.dart index 45cd2ac7..b06a7414 100644 --- a/mobile/lib/features/review_history/presentation/review_detail_screen.dart +++ b/mobile/lib/features/review_history/presentation/review_detail_screen.dart @@ -12,6 +12,8 @@ import '../../chat/domain/review_chat_context.dart'; import '../../chat/presentation/review_chat_screen.dart'; import '../../contracts/domain/contract_review_context.dart'; import '../../contracts/presentation/contract_template_list_screen.dart'; +import '../../documents/domain/legal_document_review_context.dart'; +import '../../documents/presentation/legal_document_type_list_screen.dart'; import '../application/review_detail_controller.dart'; import '../application/review_report_controller.dart'; import '../domain/review_detail.dart'; @@ -128,6 +130,23 @@ class _ReviewDetailScreenState extends State { ); } + Future _openLegalDocumentDrafting({ + required ReviewDetail detail, + required ReviewResultSummary result, + }) async { + final reviewContext = LegalDocumentReviewContext.fromReview( + detail: detail, + result: result, + ); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LegalDocumentTypeListScreen( + reviewContext: reviewContext, + ), + ), + ); + } + void _handleNextAction({ required ReviewDetailNextAction action, required ReviewDetail detail, @@ -535,6 +554,39 @@ class _ReviewDetailScreenState extends State { ], ), ), + const SizedBox(height: 16), + AppSectionCard( + title: '起草法律文书', + subtitle: '把当前审查结果转成催告函、解除通知、授权委托书或法律意见书的起草入口,减少重复录入。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Semantics( + label: 'draft-legal-document-from-review', + button: true, + child: FilledButton.icon( + onPressed: () => _openLegalDocumentDrafting( + detail: detail, + result: result, + ), + icon: const Icon(Icons.description_outlined), + label: const Text('带入审查结果起草文书'), + ), + ), + ), + const SizedBox(height: 12), + Text( + '移动端会按风险点和建议动作推荐文书类型,并在进入表单后预填审查标题、风险摘要、处理建议和原文摘要。', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: BrandAssets.mutedStrong, + height: 1.45, + ), + ), + ], + ), + ), ], const SizedBox(height: 16), AppSectionCard( diff --git a/mobile/test/features/dashboard/presentation/dashboard_screen_test.dart b/mobile/test/features/dashboard/presentation/dashboard_screen_test.dart new file mode 100644 index 00000000..c8e980fd --- /dev/null +++ b/mobile/test/features/dashboard/presentation/dashboard_screen_test.dart @@ -0,0 +1,89 @@ +import 'package:ai_contract_mobile/features/auth/domain/auth_session.dart'; +import 'package:ai_contract_mobile/features/dashboard/presentation/dashboard_screen.dart'; +import 'package:ai_contract_mobile/features/review_history/application/review_history_controller.dart'; +import 'package:ai_contract_mobile/features/review_history/domain/review_history_item.dart'; +import 'package:ai_contract_mobile/shared/storage/session_store.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _session = AuthSession( + accessToken: 'token', + userId: 'user-1', + tenantId: 'tenant-1', + username: 'member@example.com', + userDisplayName: 'Dashboard Member', + role: 'member', + status: 'active', +); + +const _recentReview = ReviewHistoryItem( + id: 'review-1', + title: 'Recent Review', + contractType: 'service', + reviewAs: 'party_a', + status: 'completed', + riskLevel: 'medium', + createdAt: '2026-05-26T00:00:00Z', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SessionStore.useStorageDriverForTesting(InMemorySessionStorageDriver()); + await SessionStore.save(_session); + }); + + tearDown(() async { + await SessionStore.clear(); + SessionStore.resetStorageDriverForTesting(); + }); + + testWidgets('recent review preview opens the review detail handoff', + (tester) async { + final controller = _DashboardHistoryController(); + + ReviewHistoryItem? openedItem; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DashboardScreen( + onStartReview: () {}, + onOpenHistory: () {}, + onOpenProfile: () {}, + onOpenLegalChat: () {}, + onOpenContracts: () {}, + onOpenDocuments: () {}, + historyController: controller, + onOpenReviewDetail: (item) { + openedItem = item; + }, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Recent Review'), findsOneWidget); + + await tester.ensureVisible(find.text('Recent Review')); + await tester.tap(find.text('Recent Review')); + await tester.pump(); + + expect(openedItem, isNotNull); + expect(openedItem!.id, _recentReview.id); + expect(openedItem!.title, _recentReview.title); + }); +} + +class _DashboardHistoryController extends ReviewHistoryController { + _DashboardHistoryController() { + items.value = const [_recentReview]; + } + + @override + Future load() async { + items.value = const [_recentReview]; + } +} diff --git a/mobile/test/features/documents/domain/legal_document_review_context_test.dart b/mobile/test/features/documents/domain/legal_document_review_context_test.dart new file mode 100644 index 00000000..0c024000 --- /dev/null +++ b/mobile/test/features/documents/domain/legal_document_review_context_test.dart @@ -0,0 +1,172 @@ +import 'package:ai_contract_mobile/features/documents/domain/legal_document_models.dart'; +import 'package:ai_contract_mobile/features/documents/domain/legal_document_review_context.dart'; +import 'package:ai_contract_mobile/features/review_history/domain/review_detail.dart'; +import 'package:ai_contract_mobile/features/review_history/domain/review_result_summary.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'maps review result into legal-document recommendations and field values', + () { + final context = LegalDocumentReviewContext.fromReview( + detail: _reviewDetail, + result: _reviewResult, + ); + + expect(context.recommendedSearchQuery, '催告函'); + expect(context.suggestedDocumentTypeCodes, + containsAllInOrder(['demand_letter', 'legal_opinion'])); + + expect(context.buildInitialTitle(_legalOpinionType), '法律意见书 - 逾期付款催告'); + + final values = context.buildInitialElementValues(_schema); + expect(values['opinion.title'], '逾期付款催告'); + expect(values['opinion.contract_type'], 'service'); + expect(values['opinion.scope'], contains('审查任务:逾期付款催告')); + expect(values['opinion.risk'], contains('总体风险:high')); + expect(values['opinion.action'], contains('建议动作:优先发送催告函')); + expect(values['opinion.evidence'], contains('对方已逾期付款')); + expect(values['opinion.purpose'], contains('后续法律文书')); + }); +} + +const _reviewDetail = ReviewDetail( + id: 'review-1', + title: '逾期付款催告', + contractType: 'service', + reviewAs: 'party_a', + status: 'completed', + sourceType: 'text', + sourceTextLength: 120, + sourceTextPreview: '对方已逾期付款并多次拖延交付。', + processingTimeMs: 1234, + createdAt: '2026-05-26T08:00:00Z', + completedAt: '2026-05-26T08:01:00Z', + errorMessage: null, +); + +const _reviewResult = ReviewResultSummary( + contractType: 'service', + partyA: '甲方', + partyB: '乙方', + amount: '100000', + term: '12个月', + riskSummary: ReviewRiskSummary( + high: 1, + medium: 1, + low: 0, + overall: 'high', + ), + reviewOpinion: '存在逾期付款与履约拖延风险', + recommendation: '优先发送催告函并保留后续追责权利', + clauseCount: 12, + riskItemCount: 2, + riskItemLabels: [ + '逾期付款', + '履约拖延', + ], + suggestionHighlights: [ + '先发催告函', + '补充证据并保留追责权利', + ], +); + +const _legalOpinionType = LegalDocumentTypeItem( + id: 'ldt-1', + code: 'legal_opinion', + name: '法律意见书', + category: 'company-commercial', + industry: 'general-enterprise', + scenario: 'legal-opinion', + description: '', + riskLevel: 'medium', + isActive: true, + sortOrder: 10, +); + +const _schema = LegalDocumentElementSchema( + id: 'ldes-1', + documentTypeCode: 'legal_opinion', + versionLabel: 'v1.0', + schemaJson: {}, + sections: [ + LegalDocumentElementSection( + key: 'core', + title: '核心信息', + description: '', + fields: [ + LegalDocumentElementField( + key: 'opinion.title', + label: '标题', + type: 'text', + required: true, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.contract_type', + label: '合同类型', + type: 'text', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.scope', + label: '核查范围', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.risk', + label: '风险摘要', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.action', + label: '处理建议', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.evidence', + label: '原文摘要', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.purpose', + label: '出具目的', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + ], + ), + ], + status: 'published', + publishedAt: '', + publishedBy: '', + rollbackFromSchemaId: '', + createdBy: '', + updatedBy: '', + createdAt: '', + updatedAt: '', +); diff --git a/mobile/test/features/documents/presentation/legal_document_screens_test.dart b/mobile/test/features/documents/presentation/legal_document_screens_test.dart index b823daed..bc7d9087 100644 --- a/mobile/test/features/documents/presentation/legal_document_screens_test.dart +++ b/mobile/test/features/documents/presentation/legal_document_screens_test.dart @@ -3,6 +3,7 @@ import 'package:ai_contract_mobile/features/contracts/domain/contract_models.dar import 'package:ai_contract_mobile/features/documents/application/legal_document_builder_controller.dart'; import 'package:ai_contract_mobile/features/documents/application/legal_document_type_controller.dart'; import 'package:ai_contract_mobile/features/documents/domain/legal_document_models.dart'; +import 'package:ai_contract_mobile/features/documents/domain/legal_document_review_context.dart'; import 'package:ai_contract_mobile/features/documents/presentation/legal_document_builder_screen.dart'; import 'package:ai_contract_mobile/features/documents/presentation/legal_document_type_list_screen.dart'; import 'package:ai_contract_mobile/shared/network/api_client.dart'; @@ -26,21 +27,27 @@ void main() { testWidgets('legal-document type list exposes stable automation labels', (tester) async { final semantics = tester.ensureSemantics(); + final apiClient = _DocumentsPresentationApiClient(); final controller = LegalDocumentTypeController( - apiClient: _DocumentsPresentationApiClient(), + apiClient: apiClient, ); addTearDown(controller.dispose); try { await tester.pumpWidget( MaterialApp( - home: LegalDocumentTypeListScreen(controller: controller), + home: LegalDocumentTypeListScreen( + controller: controller, + reviewContext: _reviewContext, + ), ), ); await tester.pumpAndSettle(); expect( find.bySemanticsLabel('legal-documents-workspace'), findsOneWidget); + expect(find.text('从审查结果继续起草'), findsOneWidget); + expect(apiClient.lastTypeQuery, '催告函'); expect( find.bySemanticsLabel('open-legal-document-legal_opinion'), findsOneWidget, @@ -53,8 +60,9 @@ void main() { testWidgets('legal-document builder exposes stable action labels', (tester) async { final semantics = tester.ensureSemantics(); + final apiClient = _DocumentsPresentationApiClient(); final controller = LegalDocumentBuilderController( - apiClient: _DocumentsPresentationApiClient(), + apiClient: apiClient, notifyGenerated: (_) async {}, ); addTearDown(controller.dispose); @@ -65,6 +73,7 @@ void main() { home: LegalDocumentBuilderScreen( documentType: _legalOpinionType, controller: controller, + reviewContext: _reviewContext, ), ), ); @@ -74,6 +83,7 @@ void main() { find.bySemanticsLabel('legal-document-builder-legal_opinion'), findsOneWidget, ); + expect(find.text('已带入审查结果'), findsOneWidget); expect( find.bySemanticsLabel('validate-legal-document-draft'), findsOneWidget, @@ -98,6 +108,20 @@ void main() { find.bySemanticsLabel('generate-saved-legal-document-draft-ldd-1'), findsOneWidget, ); + + final textFields = tester.widgetList(find.byType(TextField)); + TextField fieldWithLabel(String label) => textFields.firstWhere( + (field) => field.decoration?.labelText?.contains(label) == true, + ); + + expect(fieldWithLabel('草稿标题').controller?.text, contains('逾期付款催告')); + expect(fieldWithLabel('标题').controller?.text, contains('逾期付款催告')); + expect(fieldWithLabel('合同类型').controller?.text, contains('service')); + expect(fieldWithLabel('核查范围').controller?.text, contains('审查任务')); + expect(fieldWithLabel('风险摘要').controller?.text, contains('high')); + expect(fieldWithLabel('处理建议').controller?.text, contains('催告函')); + expect(fieldWithLabel('原文摘要').controller?.text, contains('逾期付款')); + expect(fieldWithLabel('出具目的').controller?.text, contains('后续法律文书')); } finally { semantics.dispose(); } @@ -105,6 +129,8 @@ void main() { } class _DocumentsPresentationApiClient extends ApiClient { + String? lastTypeQuery; + @override Future listLegalDocumentTypes( String accessToken, { @@ -114,6 +140,7 @@ class _DocumentsPresentationApiClient extends ApiClient { String? query, bool activeOnly = true, }) async { + lastTypeQuery = query; return const LegalDocumentTypeListResponse( items: [_legalOpinionType], total: 1, @@ -190,6 +217,24 @@ const _legalOpinionType = LegalDocumentTypeItem( sortOrder: 10, ); +const _reviewContext = LegalDocumentReviewContext( + reviewId: 'review-1', + reviewTitle: '逾期付款催告', + contractType: 'service', + overallRisk: 'high', + reviewOpinion: '存在逾期付款与履约拖延风险', + recommendation: '优先发送催告函并保留后续追责权利', + riskItems: [ + '逾期付款', + '履约拖延', + ], + suggestions: [ + '先发催告函', + '补充证据并保留追责权利', + ], + sourceTextPreview: '对方已逾期付款并多次拖延交付。', +); + const _schema = LegalDocumentElementSchema( id: 'ldes-1', documentTypeCode: 'legal_opinion', @@ -197,17 +242,71 @@ const _schema = LegalDocumentElementSchema( schemaJson: {}, sections: [ LegalDocumentElementSection( - key: 'scope', - title: 'Scope', + key: 'core', + title: 'Core', description: '', fields: [ + LegalDocumentElementField( + key: 'opinion.title', + label: '标题', + type: 'text', + required: true, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.contract_type', + label: '合同类型', + type: 'text', + required: false, + description: '', + placeholder: '', + options: [], + ), LegalDocumentElementField( key: 'opinion.scope', - label: 'Scope', + label: '核查范围', type: 'textarea', required: true, description: '', - placeholder: 'Describe the review scope.', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.risk', + label: '风险摘要', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.action', + label: '处理建议', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.evidence', + label: '原文摘要', + type: 'textarea', + required: false, + description: '', + placeholder: '', + options: [], + ), + LegalDocumentElementField( + key: 'opinion.purpose', + label: '出具目的', + type: 'textarea', + required: false, + description: '', + placeholder: '', options: [], ), ], diff --git a/mobile/tools/real-device-smoke.ps1 b/mobile/tools/real-device-smoke.ps1 index 6971fc16..cdec179f 100644 --- a/mobile/tools/real-device-smoke.ps1 +++ b/mobile/tools/real-device-smoke.ps1 @@ -6,7 +6,7 @@ [string]$DeviceSerial = "", [string]$LoginAccount = "", [string]$LoginPassword = "", - [ValidateSet("login-only", "shell-only", "share-proof", "contract-taxonomy", "documents-workspace", "business-auxiliary")] + [ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "documents-workspace", "review-legal-document", "business-auxiliary")] [string]$SmokeDepth = "shell-only", [string]$ApiHealthUrl = "http://192.168.3.90:8001/health", [string]$WebUrl = "http://192.168.3.90:3003/", @@ -301,6 +301,24 @@ function Get-BoundsCenter { } } +function Get-SafeTapPoint { + param( + $Bounds, + [int]$AvoidBottomY = 1988 + ) + + $x = [int](($Bounds.Left + $Bounds.Right) / 2) + $y = [int](($Bounds.Top + $Bounds.Bottom) / 2) + if ($Bounds.Bottom -gt $AvoidBottomY -and $Bounds.Top -lt $AvoidBottomY) { + $y = [int]([math]::Min($AvoidBottomY - 8, $Bounds.Top + 14)) + } + + return [pscustomobject]@{ + X = $x + Y = $y + } +} + function Get-SmokeState { param( [string]$Serial, @@ -372,6 +390,43 @@ function Get-SmokeStateWithRetry { return $lastState } +function Invoke-BottomTab { + param( + [string]$Serial, + [string]$ArtifactDir, + [string]$PackageName, + [int]$TabIndex, + [string]$ArtifactLabel, + [string]$Label = "", + [int]$FallbackX, + [int]$FallbackY + ) + + $state = Get-SmokeState -Serial $Serial -Label $ArtifactLabel -ArtifactDir $ArtifactDir -PackageName $PackageName + $tab = Get-ContentDescBounds -UiDump $state.UiDump | + Where-Object { + $_.ContentDesc -match "Tab $TabIndex of \d+" + } | + Select-Object -First 1 + + if (-not $tab -and -not [string]::IsNullOrWhiteSpace($Label)) { + $tab = Get-ContentDescBounds -UiDump $state.UiDump | + Where-Object { + $_.ContentDesc -match "^$([regex]::Escape($Label))" -and + $_.ContentDesc -match "Tab \d+ of \d+" + } | + Select-Object -First 1 + } + + if ($tab) { + $center = Get-BoundsCenter -Bounds $tab + Invoke-AdbTap -Serial $Serial -X $center.X -Y $center.Y + return + } + + Invoke-AdbTap -Serial $Serial -X $FallbackX -Y $FallbackY +} + function Invoke-ShareProof { param( [string]$Serial, @@ -379,31 +434,86 @@ function Invoke-ShareProof { [string]$PackageName ) - Invoke-AdbTap -Serial $Serial -X 756 -Y 2040 + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 4 -Label "记录" -ArtifactLabel "share-proof-record-tab" -FallbackX 756 -FallbackY 2040 Start-Sleep -Seconds 3 $historyState = Get-SmokeState -Serial $Serial -Label "share-proof-review-history" -ArtifactDir $ArtifactDir -PackageName $PackageName - Invoke-AdbTap -Serial $Serial -X 540 -Y 758 + $historyEntryState = $historyState + $recordCard = $null + for ($attempt = 1; $attempt -le 6; $attempt++) { + $recordCard = Get-ContentDescBounds -UiDump $historyEntryState.UiDump | + Where-Object { + $_.ContentDesc -match "创建时间" -and + $_.Top -gt 500 -and + $_.Bottom -lt 1992 + } | + Sort-Object Top | + Select-Object -First 1 + if ($recordCard) { + break + } + + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 760 -DurationMs 300 + Start-Sleep -Seconds 2 + $historyEntryState = Get-SmokeState -Serial $Serial -Label "share-proof-review-history-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + if ($recordCard) { + $recordCenter = Get-BoundsCenter -Bounds $recordCard + Invoke-AdbTap -Serial $Serial -X $recordCenter.X -Y $recordCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 540 -Y 1650 + } Start-Sleep -Seconds 3 $detailTopState = Get-SmokeState -Serial $Serial -Label "share-proof-review-detail-top" -ArtifactDir $ArtifactDir -PackageName $PackageName - Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 700 -DurationMs 300 - Start-Sleep -Milliseconds 900 - Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 700 -DurationMs 300 - Start-Sleep -Milliseconds 900 - Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 700 -DurationMs 300 - Start-Sleep -Milliseconds 900 - Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 1100 -DurationMs 250 - Start-Sleep -Seconds 2 - $reportState = Get-SmokeState -Serial $Serial -Label "share-proof-report-section" -ArtifactDir $ArtifactDir -PackageName $PackageName + $reportState = $detailTopState + $reportSectionReached = $false + $shareButton = $null + for ($attempt = 1; $attempt -le 20; $attempt++) { + $reportSectionReached = $reportSectionReached -or + $reportState.UiDump -match 'review-report-review-.*\.pdf' -or + $reportState.UiDump -match 'report/download' -or + $reportState.UiDump -match '审查报告' -or + $reportState.UiDump -match '下载报告' -or + $reportState.UiDump -match '分享报告' - Invoke-AdbTap -Serial $Serial -X 305 -Y 2054 - Start-Sleep -Seconds 5 - $chooserState = Get-SmokeState -Serial $Serial -Label "share-proof-chooser" -ArtifactDir $ArtifactDir -PackageName $PackageName + $shareButton = Get-ButtonBounds -UiDump $reportState.UiDump | + Where-Object { $_.ContentDesc -eq "分享报告" } | + Select-Object -First 1 + if ($shareButton) { + break + } + + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1870 -EndX 540 -EndY 520 -DurationMs 420 + Start-Sleep -Seconds 1 + $reportState = Get-SmokeState -Serial $Serial -Label "share-proof-report-section-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + if ($shareButton) { + $shareButtonCenter = Get-BoundsCenter -Bounds $shareButton + Invoke-AdbTap -Serial $Serial -X $shareButtonCenter.X -Y $shareButtonCenter.Y + } + Start-Sleep -Seconds 2 + $chooserState = $null + for ($chooserAttempt = 1; $chooserAttempt -le 3; $chooserAttempt++) { + $chooserState = Get-SmokeState -Serial $Serial -Label "share-proof-chooser-attempt-$chooserAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + if ($chooserState.UiDump -match '电池电量少于10%' -or $chooserState.UiDump -match '请插入充电器') { + $confirmButton = Get-TextBounds -UiDump $chooserState.UiDump | + Where-Object { $_.Text -eq '确定' } | + Select-Object -First 1 + if ($confirmButton) { + $confirmCenter = Get-BoundsCenter -Bounds $confirmButton + Invoke-AdbTap -Serial $Serial -X $confirmCenter.X -Y $confirmCenter.Y + Start-Sleep -Seconds 2 + continue + } + } + break + } $chooserOk = $chooserState.TopText -match 'ChooserActivity' - $pdfPreviewOk = $chooserState.UiDump -match 'review-report-review-.*\.pdf' - $reportSectionReached = $reportState.UiDump -match 'review-report-review-.*\.pdf' -or $reportState.UiDump -match 'report/download' + $pdfPreviewOk = $chooserState.UiDump -match 'review-report-review-.*\.pdf' -or $chooserState.UiDump -match '\.pdf' return [pscustomobject]@{ ReviewHistorySurface = $historyState.Surface @@ -414,7 +524,8 @@ function Invoke-ShareProof { ChooserOk = $chooserOk PdfPreviewOk = $pdfPreviewOk ReportSectionReached = $reportSectionReached - SmokeOk = ($chooserOk -and $pdfPreviewOk -and $reportSectionReached) + ShareButtonFound = ($null -ne $shareButton) + SmokeOk = ($chooserOk -and $pdfPreviewOk -and $reportSectionReached -and $null -ne $shareButton) } } @@ -425,11 +536,24 @@ function Invoke-ContractTaxonomyProof { [string]$PackageName ) - Invoke-AdbTap -Serial $Serial -X 540 -Y 2057 + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 3 -Label "AI工具" -ArtifactLabel "contract-taxonomy-ai-tools-tab" -FallbackX 540 -FallbackY 2057 Start-Sleep -Seconds 2 $toolsState = Get-SmokeState -Serial $Serial -Label "contract-taxonomy-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName - Invoke-AdbTap -Serial $Serial -X 571 -Y 1589 + $contractButton = Get-ContentDescBounds -UiDump $toolsState.UiDump | + Where-Object { $_.ContentDesc -eq "open-contracts-workspace" } | + Select-Object -First 1 + if (-not $contractButton) { + $contractButton = Get-ButtonBounds -UiDump $toolsState.UiDump | + Where-Object { $_.ContentDesc -eq "打开合同工作台" } | + Select-Object -First 1 + } + if ($contractButton) { + $contractCenter = Get-SafeTapPoint -Bounds $contractButton + Invoke-AdbTap -Serial $Serial -X $contractCenter.X -Y $contractCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 360 -Y 1865 + } Start-Sleep -Seconds 3 $workspaceState = Get-SmokeState -Serial $Serial -Label "contract-taxonomy-workspace" -ArtifactDir $ArtifactDir -PackageName $PackageName @@ -482,19 +606,24 @@ function Invoke-DocumentsWorkspaceProof { [string]$PackageName ) - Invoke-AdbTap -Serial $Serial -X 540 -Y 2057 + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 3 -Label "AI工具" -ArtifactLabel "documents-workspace-ai-tools-tab" -FallbackX 540 -FallbackY 2057 Start-Sleep -Seconds 2 $toolsState = Get-SmokeState -Serial $Serial -Label "documents-workspace-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName $toolNodes = Get-ContentDescBounds -UiDump $toolsState.UiDump - $documentButton = $toolNodes | + $documentButton = Get-ButtonBounds -UiDump $toolsState.UiDump | + Where-Object { $_.ContentDesc -eq "打开文书工作台" } | + Select-Object -First 1 + if (-not $documentButton) { + $documentButton = $toolNodes | Where-Object { $_.ContentDesc -eq "open-documents-workspace" } | Select-Object -Last 1 + } if ($documentButton) { - $documentButtonCenter = Get-BoundsCenter -Bounds $documentButton + $documentButtonCenter = Get-SafeTapPoint -Bounds $documentButton Invoke-AdbTap -Serial $Serial -X $documentButtonCenter.X -Y $documentButtonCenter.Y } else { - Invoke-AdbTap -Serial $Serial -X 570 -Y 2080 + Invoke-AdbTap -Serial $Serial -X 360 -Y 1980 } Start-Sleep -Seconds 4 @@ -531,7 +660,10 @@ function Invoke-DocumentsWorkspaceProof { $typeListOk = $visibleCodes.Count -gt 0 -and ( $workspaceListState.UiDump -match 'open-legal-document-' -or $workspaceListState.UiDump -match 'legal-document-type' -or - $workspaceListState.UiDump -match 'legal-documents-workspace' + $workspaceListState.UiDump -match 'procurement_goods' -or + $workspaceListState.UiDump -match 'legal_opinion' -or + $workspaceListState.UiDump -match 'demand_letter' -or + $workspaceListState.UiDump -match 'nda_commercial' ) $startButtons = Get-ContentDescBounds -UiDump $workspaceListState.UiDump @@ -543,22 +675,14 @@ function Invoke-DocumentsWorkspaceProof { } | Sort-Object Top | Select-Object -First 1 - if (-not $startButton) { - $startButton = Get-ButtonBounds -UiDump $workspaceListState.UiDump | - Where-Object { $_.Top -gt 1500 -and $_.Bottom -lt 2100 -and $_.ContentDesc -ne "刷新入口" -and $_.ContentDesc -ne "搜索" } | - Sort-Object Top | - Select-Object -First 1 - } if ($startButton) { break } } if ($startButton) { - $startButtonCenter = Get-BoundsCenter -Bounds $startButton + $startButtonCenter = Get-SafeTapPoint -Bounds $startButton Invoke-AdbTap -Serial $Serial -X $startButtonCenter.X -Y $startButtonCenter.Y - } else { - Invoke-AdbTap -Serial $Serial -X 760 -Y 1680 } Start-Sleep -Seconds 4 @@ -578,6 +702,182 @@ function Invoke-DocumentsWorkspaceProof { } } +function Invoke-DashboardReviewPreviewProof { + param( + [string]$Serial, + [string]$ArtifactDir, + [string]$PackageName + ) + + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 1 -Label "工作台" -ArtifactLabel "dashboard-review-preview-tab" -FallbackX 108 -FallbackY 2057 + Start-Sleep -Seconds 3 + + $dashboardState = $null + $previewEntry = $null + for ($attempt = 1; $attempt -le 6; $attempt++) { + if ($attempt -gt 1) { + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 760 -DurationMs 300 + Start-Sleep -Seconds 2 + } + + $dashboardState = Get-SmokeState -Serial $Serial -Label "dashboard-review-preview-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + $previewEntry = Get-ContentDescBounds -UiDump $dashboardState.UiDump | + Where-Object { $_.ContentDesc -like "open-review-preview-*" } | + Sort-Object Top | + Select-Object -First 1 + if ($previewEntry) { + break + } + } + + if ($previewEntry) { + $entryCenter = Get-BoundsCenter -Bounds $previewEntry + Invoke-AdbTap -Serial $Serial -X $entryCenter.X -Y $entryCenter.Y + } else { + throw "Dashboard did not expose a recent review preview entry. See $ArtifactDir" + } + + Start-Sleep -Seconds 4 + $detailState = Get-SmokeState -Serial $Serial -Label "dashboard-review-preview-detail" -ArtifactDir $ArtifactDir -PackageName $PackageName + $detailOk = ($detailState.UiDump -match 'content-desc="Back"') -and ( + $detailState.UiDump -match 'review-[a-z0-9]+' -or + $detailState.UiDump -match '任务编号' + ) + + return [pscustomobject]@{ + DashboardSurface = $dashboardState.Surface + DetailSurface = $detailState.Surface + EntryOk = ($previewEntry -ne $null) + DetailOk = $detailOk + SmokeOk = (($previewEntry -ne $null) -and $detailOk) + } +} + +function Invoke-ReviewLegalDocumentProof { + param( + [string]$Serial, + [string]$ArtifactDir, + [string]$PackageName + ) + + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 4 -Label "记录" -ArtifactLabel "review-legal-document-record-tab" -FallbackX 756 -FallbackY 2040 + Start-Sleep -Seconds 3 + $historyState = Get-SmokeState -Serial $Serial -Label "review-legal-document-history" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $historyEntryState = $historyState + $recordCard = $null + for ($attempt = 1; $attempt -le 6; $attempt++) { + if ($attempt -gt 1) { + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 760 -DurationMs 300 + Start-Sleep -Seconds 2 + $historyEntryState = Get-SmokeState -Serial $Serial -Label "review-legal-document-history-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + $recordCard = Get-ContentDescBounds -UiDump $historyEntryState.UiDump | + Where-Object { + $_.ContentDesc -match '创建时间:' -or $_.ContentDesc -match '文本输入合同' + } | + Sort-Object Top | + Select-Object -First 1 + if ($recordCard) { + break + } + } + if ($recordCard) { + $recordCenter = Get-BoundsCenter -Bounds $recordCard + Invoke-AdbTap -Serial $Serial -X $recordCenter.X -Y $recordCenter.Y + } else { + throw "Review history did not expose a tappable record card. See $ArtifactDir" + } + + Start-Sleep -Seconds 4 + $detailEntryState = Get-SmokeState -Serial $Serial -Label "review-legal-document-detail-entry" -ArtifactDir $ArtifactDir -PackageName $PackageName + $entryButton = $null + for ($attempt = 1; $attempt -le 8; $attempt++) { + if ($attempt -gt 1) { + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1800 -EndX 540 -EndY 760 -DurationMs 300 + Start-Sleep -Seconds 2 + $detailEntryState = Get-SmokeState -Serial $Serial -Label "review-legal-document-detail-entry-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + $entryButton = Get-ContentDescBounds -UiDump $detailEntryState.UiDump | + Where-Object { $_.ContentDesc -eq "draft-legal-document-from-review" } | + Select-Object -First 1 + if ($entryButton) { + break + } + + $entryText = Get-TextBounds -UiDump $detailEntryState.UiDump | + Where-Object { $_.Text -eq "带入审查结果起草文书" } | + Select-Object -First 1 + if ($entryText) { + $entryButton = $entryText + break + } + } + + if ($entryButton) { + $entryCenter = Get-BoundsCenter -Bounds $entryButton + Invoke-AdbTap -Serial $Serial -X $entryCenter.X -Y $entryCenter.Y + } else { + throw "Review detail did not expose the legal-document drafting entry. See $ArtifactDir" + } + + Start-Sleep -Seconds 5 + $workspaceState = Get-SmokeState -Serial $Serial -Label "review-legal-document-workspace" -ArtifactDir $ArtifactDir -PackageName $PackageName + $workspaceOk = $workspaceState.UiDump -match 'legal-documents-workspace' + $reviewContextOk = $workspaceState.UiDump -match 'legal-document-review-context' -or $workspaceState.UiDump -match '从审查结果继续起草' + + $builderButton = $null + $workspaceListState = $workspaceState + for ($attempt = 1; $attempt -le 6; $attempt++) { + if ($attempt -gt 1) { + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1780 -EndX 540 -EndY 880 -DurationMs 300 + Start-Sleep -Seconds 2 + $workspaceListState = Get-SmokeState -Serial $Serial -Label "review-legal-document-list-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + if (-not $reviewContextOk) { + $reviewContextOk = $workspaceListState.UiDump -match 'legal-document-review-context' -or $workspaceListState.UiDump -match '从审查结果继续起草' + } + } + + $builderButton = Get-ContentDescBounds -UiDump $workspaceListState.UiDump | + Where-Object { + $_.ContentDesc -like "open-legal-document-*" -and + $_.Right -gt $_.Left -and + $_.Bottom -gt $_.Top + } | + Sort-Object Top | + Select-Object -First 1 + if ($builderButton) { + break + } + } + + if ($builderButton) { + $builderButtonCenter = Get-BoundsCenter -Bounds $builderButton + Invoke-AdbTap -Serial $Serial -X $builderButtonCenter.X -Y $builderButtonCenter.Y + } else { + throw "Legal document type list did not expose a builder entry. See $ArtifactDir" + } + + Start-Sleep -Seconds 4 + $builderState = Get-SmokeState -Serial $Serial -Label "review-legal-document-builder" -ArtifactDir $ArtifactDir -PackageName $PackageName + $builderOk = $builderState.UiDump -match 'legal-document-builder-' + $builderReviewContextOk = $builderState.UiDump -match 'legal-document-builder-review-context' -or $builderState.UiDump -match '已带入审查结果' + + return [pscustomobject]@{ + HistorySurface = $historyState.Surface + DetailSurface = $detailEntryState.Surface + WorkspaceSurface = $workspaceState.Surface + BuilderSurface = $builderState.Surface + EntryOk = ($entryButton -ne $null) + WorkspaceOk = $workspaceOk + BuilderOk = $builderOk + ReviewContextOk = ($reviewContextOk -and $builderReviewContextOk) + SmokeOk = (($entryButton -ne $null) -and $workspaceOk -and $builderOk -and $reviewContextOk -and $builderReviewContextOk) + } +} + function Invoke-BusinessAuxiliaryProof { param( [string]$Serial, @@ -592,7 +892,7 @@ function Invoke-BusinessAuxiliaryProof { "法律意见边界" ) - Invoke-AdbTap -Serial $Serial -X 540 -Y 2057 + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 3 -Label "AI工具" -ArtifactLabel "business-auxiliary-ai-tools-tab" -FallbackX 540 -FallbackY 2057 Start-Sleep -Seconds 2 $toolsState = Get-SmokeState -Serial $Serial -Label "business-auxiliary-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName @@ -612,8 +912,13 @@ function Invoke-BusinessAuxiliaryProof { } } if (-not $targetPrompt) { - $targetPrompt = Get-TextBounds -UiDump $promptListState.UiDump | - Where-Object { $_.Text -eq "提取履约义务" } | + $targetPrompt = Get-ButtonBounds -UiDump $promptListState.UiDump | + Where-Object { $_.ContentDesc -eq "提取履约义务" } | + Select-Object -First 1 + } + if (-not $targetPrompt) { + $targetPrompt = Get-ContentDescBounds -UiDump $promptListState.UiDump | + Where-Object { $_.ContentDesc -eq "提取履约义务" } | Select-Object -First 1 } if ($visibleLabels.Count -eq $expectedLabels.Count) { @@ -650,7 +955,7 @@ function Invoke-BusinessAuxiliaryProof { PromptsOk = $promptsOk TriggerOk = $triggerOk AssistantResponseOk = $assistantResponseOk - SmokeOk = ($promptsOk -and $triggerOk) + SmokeOk = ($promptsOk -and $triggerOk -and $assistantResponseOk) } } @@ -795,6 +1100,14 @@ try { "DOCUMENTS_TYPE_LIST_OK=", "DOCUMENTS_BUILDER_OK=", "DOCUMENTS_VISIBLE_CODES=", + "DASHBOARD_REVIEW_PREVIEW_PROOF_OK=", + "DASHBOARD_REVIEW_PREVIEW_ENTRY_OK=", + "DASHBOARD_REVIEW_PREVIEW_DETAIL_OK=", + "REVIEW_LEGAL_DOCUMENT_PROOF_OK=", + "REVIEW_LEGAL_DOCUMENT_ENTRY_OK=", + "REVIEW_LEGAL_DOCUMENT_WORKSPACE_OK=", + "REVIEW_LEGAL_DOCUMENT_BUILDER_OK=", + "REVIEW_LEGAL_DOCUMENT_CONTEXT_OK=", "ARTIFACT_DIR=$artifactDir", "SMOKE_OK=False" ) @@ -806,6 +1119,14 @@ try { throw "Environment blocker: wireless ADB is unavailable. Re-enable or re-pair wireless debugging on the phone, then rerun this smoke. See $artifactDir" } +if ($ConfigureDevice) { + & $adb -s $serial shell settings put system screen_off_timeout 1800000 | Out-Null + & $adb -s $serial shell settings put global stay_on_while_plugged_in 3 | Out-Null + & $adb -s $serial shell settings put system accelerometer_rotation 0 | Out-Null + & $adb -s $serial shell settings put system user_rotation 0 | Out-Null + Start-Sleep -Seconds 1 +} + $apiStatus = $null $webStatus = $null if (-not $SkipServerChecks) { @@ -868,6 +1189,14 @@ $documentsWorkspaceReady = "" $documentsTypeListOk = "" $documentsBuilderOk = "" $documentsVisibleCodes = "" +$dashboardReviewPreviewProofOk = "" +$dashboardReviewPreviewEntryOk = "" +$dashboardReviewPreviewDetailOk = "" +$reviewLegalDocumentProofOk = "" +$reviewLegalDocumentEntryOk = "" +$reviewLegalDocumentWorkspaceOk = "" +$reviewLegalDocumentBuilderOk = "" +$reviewLegalDocumentContextOk = "" $businessAuxiliaryProofOk = "" $businessAuxiliaryPromptsOk = "" $businessAuxiliaryTriggerOk = "" @@ -986,6 +1315,62 @@ switch ($SmokeDepth) { $documentsVisibleCodes = $documentsProof.VisibleCodes $smokeOk = $documentsProof.SmokeOk } + "dashboard-review-preview" { + if ($surface -eq "device_locked") { + $smokeBlocker = "device_locked" + break + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $loginState = Try-AutoLogin -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName -Account $LoginAccount -Password $LoginPassword + if ($loginState -ne $null) { + $initialState = $loginState + $surface = $initialState.Surface + $topActivity = $initialState.TopActivity + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $smokeBlocker = "login_required" + break + } + } + if (-not ($initialState.AppVisible -and $surface -eq "signed_in_shell")) { + throw "dashboard-review-preview requires the app to relaunch into the signed-in shell. Current surface: $surface" + } + + $dashboardProof = Invoke-DashboardReviewPreviewProof -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName + $dashboardReviewPreviewProofOk = $dashboardProof.SmokeOk + $dashboardReviewPreviewEntryOk = $dashboardProof.EntryOk + $dashboardReviewPreviewDetailOk = $dashboardProof.DetailOk + $smokeOk = $dashboardProof.SmokeOk + } + "review-legal-document" { + if ($surface -eq "device_locked") { + $smokeBlocker = "device_locked" + break + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $loginState = Try-AutoLogin -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName -Account $LoginAccount -Password $LoginPassword + if ($loginState -ne $null) { + $initialState = $loginState + $surface = $initialState.Surface + $topActivity = $initialState.TopActivity + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $smokeBlocker = "login_required" + break + } + } + if (-not ($initialState.AppVisible -and $surface -eq "signed_in_shell")) { + throw "review-legal-document requires the app to relaunch into the signed-in shell. Current surface: $surface" + } + + $reviewLegalDocumentProof = Invoke-ReviewLegalDocumentProof -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName + $reviewLegalDocumentProofOk = $reviewLegalDocumentProof.SmokeOk + $reviewLegalDocumentEntryOk = $reviewLegalDocumentProof.EntryOk + $reviewLegalDocumentWorkspaceOk = $reviewLegalDocumentProof.WorkspaceOk + $reviewLegalDocumentBuilderOk = $reviewLegalDocumentProof.BuilderOk + $reviewLegalDocumentContextOk = $reviewLegalDocumentProof.ReviewContextOk + $smokeOk = $reviewLegalDocumentProof.SmokeOk + } "business-auxiliary" { if ($surface -eq "device_locked") { $smokeBlocker = "device_locked" @@ -1043,6 +1428,14 @@ $summaryLines = @( "DOCUMENTS_TYPE_LIST_OK=$documentsTypeListOk", "DOCUMENTS_BUILDER_OK=$documentsBuilderOk", "DOCUMENTS_VISIBLE_CODES=$documentsVisibleCodes", + "DASHBOARD_REVIEW_PREVIEW_PROOF_OK=$dashboardReviewPreviewProofOk", + "DASHBOARD_REVIEW_PREVIEW_ENTRY_OK=$dashboardReviewPreviewEntryOk", + "DASHBOARD_REVIEW_PREVIEW_DETAIL_OK=$dashboardReviewPreviewDetailOk", + "REVIEW_LEGAL_DOCUMENT_PROOF_OK=$reviewLegalDocumentProofOk", + "REVIEW_LEGAL_DOCUMENT_ENTRY_OK=$reviewLegalDocumentEntryOk", + "REVIEW_LEGAL_DOCUMENT_WORKSPACE_OK=$reviewLegalDocumentWorkspaceOk", + "REVIEW_LEGAL_DOCUMENT_BUILDER_OK=$reviewLegalDocumentBuilderOk", + "REVIEW_LEGAL_DOCUMENT_CONTEXT_OK=$reviewLegalDocumentContextOk", "BUSINESS_AUXILIARY_PROOF_OK=$businessAuxiliaryProofOk", "BUSINESS_AUXILIARY_PROMPTS_OK=$businessAuxiliaryPromptsOk", "BUSINESS_AUXILIARY_TRIGGER_OK=$businessAuxiliaryTriggerOk", diff --git a/mobile/tools/run-real-device-product-smoke-suite.ps1 b/mobile/tools/run-real-device-product-smoke-suite.ps1 new file mode 100644 index 00000000..c575ba0c --- /dev/null +++ b/mobile/tools/run-real-device-product-smoke-suite.ps1 @@ -0,0 +1,231 @@ +param( + [string]$ExpectedIp = "192.168.3.", + [string]$DeviceSerial = "", + [string]$LoginAccount = "", + [string]$LoginPassword = "", + [ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "documents-workspace", "review-legal-document", "business-auxiliary")] + [string[]]$SmokeDepths = @( + "shell-only", + "share-proof", + "contract-taxonomy", + "documents-workspace", + "review-legal-document", + "dashboard-review-preview", + "business-auxiliary" + ), + [switch]$ConfigureDevice, + [switch]$SkipServerChecks, + [switch]$SkipInitialInstall, + [string]$ArtifactRoot = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = $PSScriptRoot +$mobileRoot = Resolve-Path (Join-Path $scriptDir "..") +$smokeScript = Join-Path $scriptDir "real-device-smoke.ps1" +$reconnectScript = Join-Path $scriptDir "reconnect-wireless-device.ps1" +$apkPath = Join-Path $mobileRoot "build\app\outputs\flutter-apk\app-debug.apk" + +if (-not (Test-Path $smokeScript)) { + throw "Smoke script not found at $smokeScript" +} +if (-not (Test-Path $reconnectScript)) { + throw "Reconnect helper not found at $reconnectScript" +} +if (-not (Test-Path $apkPath)) { + throw "Debug APK not found at $apkPath. Run flutter build apk --debug before the suite." +} + +$suiteBase = if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + Join-Path $mobileRoot "qa-artifacts\real-device-product-smoke-suite" +} else { + $ArtifactRoot +} +$suiteDir = Join-Path $suiteBase (Get-Date -Format "yyyy-MM-ddTHH-mm-ss-fff") +New-Item -ItemType Directory -Force -Path $suiteDir | Out-Null + +function Read-SmokeSummary { + param([string]$SummaryPath) + + $summary = @{} + if (-not [string]::IsNullOrWhiteSpace($SummaryPath) -and (Test-Path $SummaryPath)) { + foreach ($line in Get-Content -Path $SummaryPath) { + if ($line -match '^(?[^=]+)=(?.*)$') { + $summary[$Matches.key] = $Matches.value + } + } + } + return $summary +} + +function Resolve-DeviceSerial { + param( + [string]$RequestedSerial, + [string]$ExpectedIpPrefix, + [bool]$ShouldConfigureDevice + ) + + if (-not [string]::IsNullOrWhiteSpace($RequestedSerial)) { + return $RequestedSerial.Trim() + } + + $args = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $reconnectScript, + "-ExpectedIp", $ExpectedIpPrefix, + "-PassThruSerial" + ) + if ($ShouldConfigureDevice) { + $args += "-ConfigureDevice" + } + + $output = & powershell @args 2>&1 + $output | ForEach-Object { Write-Host $_ } + + $serial = $output | + ForEach-Object { $_.ToString().Trim() } | + Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+:\d+$' } | + Select-Object -Last 1 + + if ([string]::IsNullOrWhiteSpace($serial)) { + throw "Could not resolve a wireless device serial from reconnect helper output." + } + return $serial +} + +function Invoke-SmokeDepth { + param( + [string]$Depth, + [int]$Index, + [string]$Serial, + [bool]$ShouldConfigureDevice, + [bool]$ShouldSkipInstall, + [bool]$ShouldSkipServerChecks + ) + + $stepName = "{0:00}-{1}" -f $Index, $Depth + $stepRoot = Join-Path $suiteDir $stepName + New-Item -ItemType Directory -Force -Path $stepRoot | Out-Null + + $args = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $smokeScript, + "-DeviceSerial", $Serial, + "-SmokeDepth", $Depth, + "-ArtifactRoot", $stepRoot + ) + if ($ShouldConfigureDevice) { + $args += "-ConfigureDevice" + } + if ($ShouldSkipInstall) { + $args += "-SkipInstall" + } + if ($ShouldSkipServerChecks) { + $args += "-SkipServerChecks" + } + if (-not [string]::IsNullOrWhiteSpace($LoginAccount)) { + $args += @("-LoginAccount", $LoginAccount) + } + if (-not [string]::IsNullOrWhiteSpace($LoginPassword)) { + $args += @("-LoginPassword", $LoginPassword) + } + + $startedAt = Get-Date + Write-Host "" + Write-Host "=== MOBILE_SMOKE_STEP $Index/$($SmokeDepths.Count): $Depth ===" + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & powershell @args 2>&1 + $exitCode = $LASTEXITCODE + } catch { + $output = @($_.ToString()) + $exitCode = 1 + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $output | ForEach-Object { Write-Host $_ } + $endedAt = Get-Date + + $artifactDir = ($output | + ForEach-Object { $_.ToString().Trim() } | + Where-Object { $_ -match '^ARTIFACT_DIR=' } | + ForEach-Object { $_ -replace '^ARTIFACT_DIR=', '' } | + Select-Object -Last 1) + $summaryPath = if (-not [string]::IsNullOrWhiteSpace($artifactDir)) { + Join-Path $artifactDir "summary.txt" + } else { + "" + } + $summary = Read-SmokeSummary -SummaryPath $summaryPath + $smokeOk = ($summary.ContainsKey("SMOKE_OK") -and $summary["SMOKE_OK"] -eq "True") + + return [pscustomobject]@{ + Depth = $Depth + ExitCode = $exitCode + SmokeOk = $smokeOk + SmokeBlocker = if ($summary.ContainsKey("SMOKE_BLOCKER")) { $summary["SMOKE_BLOCKER"] } else { "" } + ConnectedSerial = if ($summary.ContainsKey("CONNECTED_SERIAL")) { $summary["CONNECTED_SERIAL"] } else { $Serial } + AppSurface = if ($summary.ContainsKey("APP_SURFACE")) { $summary["APP_SURFACE"] } else { "" } + TopActivity = if ($summary.ContainsKey("TOP_ACTIVITY")) { $summary["TOP_ACTIVITY"] } else { "" } + StartedAt = $startedAt.ToString("o") + EndedAt = $endedAt.ToString("o") + DurationSeconds = [math]::Round(($endedAt - $startedAt).TotalSeconds, 2) + ArtifactDir = $artifactDir + SummaryPath = $summaryPath + } +} + +$serial = Resolve-DeviceSerial -RequestedSerial $DeviceSerial -ExpectedIpPrefix $ExpectedIp -ShouldConfigureDevice:$ConfigureDevice.IsPresent +$results = @() +for ($i = 0; $i -lt $SmokeDepths.Count; $i++) { + $skipInstallForStep = $SkipInitialInstall.IsPresent -or ($i -gt 0) + $results += Invoke-SmokeDepth ` + -Depth $SmokeDepths[$i] ` + -Index ($i + 1) ` + -Serial $serial ` + -ShouldConfigureDevice:$ConfigureDevice.IsPresent ` + -ShouldSkipInstall:$skipInstallForStep ` + -ShouldSkipServerChecks:$SkipServerChecks.IsPresent +} + +$failed = @($results | Where-Object { -not $_.SmokeOk }) +$suiteOk = ($failed.Count -eq 0) +$report = [pscustomobject]@{ + SuiteOk = $suiteOk + DeviceSerial = $serial + StartedAt = $results[0].StartedAt + EndedAt = (Get-Date).ToString("o") + StepCount = $results.Count + PassedCount = ($results.Count - $failed.Count) + FailedDepths = @($failed | ForEach-Object { $_.Depth }) + ArtifactDir = $suiteDir + Results = $results +} + +$jsonPath = Join-Path $suiteDir "suite-summary.json" +$textPath = Join-Path $suiteDir "suite-summary.txt" +$report | ConvertTo-Json -Depth 6 | Set-Content -Path $jsonPath + +$textLines = @( + "SUITE_OK=$suiteOk", + "DEVICE_SERIAL=$serial", + "STEP_COUNT=$($results.Count)", + "PASSED_COUNT=$($results.Count - $failed.Count)", + "FAILED_DEPTHS=$(($failed | ForEach-Object { $_.Depth }) -join ',')", + "ARTIFACT_DIR=$suiteDir", + "SUITE_SUMMARY_JSON=$jsonPath" +) +foreach ($result in $results) { + $textLines += "STEP=$($result.Depth);SMOKE_OK=$($result.SmokeOk);BLOCKER=$($result.SmokeBlocker);SECONDS=$($result.DurationSeconds);ARTIFACT_DIR=$($result.ArtifactDir)" +} +$textLines | Set-Content -Path $textPath +$textLines | ForEach-Object { Write-Output $_ } + +if (-not $suiteOk) { + throw "Mobile real-device product smoke suite failed: $(($failed | ForEach-Object { $_.Depth }) -join ', '). See $suiteDir" +}