feat: close pc and android product smoke flows

This commit is contained in:
2026-05-26 08:37:28 +08:00
parent bafd837020
commit d0ceccf10d
16 changed files with 1572 additions and 85 deletions
+2
View File
@@ -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
+25
View File
@@ -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 (
<main className="landing-page">
<section className="landing-hero-shell">
<Result status="403" title="无权限访问后台管理" subTitle="当前账号没有管理员权限,已返回首页工作台。" />
</section>
</main>
);
}
return (
<main className="landing-page">
<section className="landing-hero-shell">
+9 -2
View File
@@ -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<ReturnType<typeof listContractLifecycleTasks>> | 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);
+12 -8
View File
@@ -297,6 +297,10 @@ export default function ContractsPage() {
const [lifecycleModalOpen, setLifecycleModalOpen] = useState(false);
const [lifecycleRecord, setLifecycleRecord] = useState<GeneratedContractListItem | null>(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() {
<Text type="secondary" style={{ fontSize: 12 }}>
{autoReview.current_stage_label ?? autoReview.next_action ?? "-"}
</Text>
<Button size="small" onClick={() => handleOpenAutoReviewDetail(autoReview)}>
<Button size="small" disabled={!canOpenReviewDetail(autoReview)} onClick={() => handleOpenAutoReviewDetail(autoReview)}>
<span className="ui-nowrap-label">审查详情</span>
</Button>
</Space>
@@ -843,7 +847,7 @@ export default function ContractsPage() {
<Button size="small" onClick={() => handleOpenLifecycleModal(record)}>
<span className="ui-nowrap-label">状态</span>
</Button>
<Button size="small" type="primary" loading={historyDownloadingId === record.id} onClick={() => void handleHistoryDownload(record)}>
<Button size="small" type="primary" disabled={!canDownloadContracts} loading={historyDownloadingId === record.id} onClick={() => void handleHistoryDownload(record)}>
<span className="ui-nowrap-label">下载</span>
</Button>
</Space>
@@ -1103,13 +1107,13 @@ export default function ContractsPage() {
{generatedContract.auto_review.review_opinion ? <Text>审查意见: {generatedContract.auto_review.review_opinion}</Text> : null}
</Space>
<Space wrap style={{ marginTop: 12 }}>
<Button onClick={() => handleOpenAutoReviewDetail(generatedContract.auto_review)}>
<Button disabled={!canOpenReviewDetail(generatedContract.auto_review)} onClick={() => handleOpenAutoReviewDetail(generatedContract.auto_review)}>
<span className="ui-nowrap-label">查看审查详情</span>
</Button>
<Button type="primary" onClick={() => handleOpenAutoReviewChat(generatedContract.auto_review)}>
<Button type="primary" disabled={!canOpenReviewChat(generatedContract.auto_review)} onClick={() => handleOpenAutoReviewChat(generatedContract.auto_review)}>
<span className="ui-nowrap-label">继续审查问答</span>
</Button>
<Button onClick={() => void handleDownloadAutoReviewReport(generatedContract.auto_review)}>
<Button disabled={!canDownloadReview(generatedContract.auto_review)} onClick={() => void handleDownloadAutoReviewReport(generatedContract.auto_review)}>
<span className="ui-nowrap-label">下载审查报告</span>
</Button>
</Space>
@@ -1138,7 +1142,7 @@ export default function ContractsPage() {
{currentUser?.role === "admin" && generatedContract.file_id ? (
<Button onClick={() => router.push(`/files?highlight=${generatedContract.file_id}`)}><span className="ui-nowrap-label">查看文件资产</span></Button>
) : null}
<Button type="primary" loading={downloadingCurrent} onClick={handleDownload}>
<Button type="primary" disabled={!canDownloadContracts} loading={downloadingCurrent} onClick={handleDownload}>
<span className="ui-nowrap-label">下载合同文件</span>
</Button>
</Space>
+65
View File
@@ -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/`
+13
View File
@@ -324,6 +324,19 @@ class _AiContractAppShellState extends State<AiContractAppShell>
onOpenLegalChat: _openTools,
onOpenContracts: _openContractWorkspace,
onOpenDocuments: _openDocumentWorkspace,
onOpenReviewDetail: (item) {
_selectTab(AppTab.history);
unawaited(
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ReviewDetailScreen(
reviewId: item.id,
initialTitle: item.title,
),
),
),
);
},
);
case AppTab.review:
return const ReviewEntryScreen(key: ValueKey(AppTab.review));
@@ -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<ReviewHistoryItem>? onOpenReviewDetail;
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
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<DashboardScreen> {
_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<ReviewHistoryItem> items;
final VoidCallback onOpenHistory;
final ValueChanged<ReviewHistoryItem>? onOpenReviewDetail;
final Future<void> 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,
),
],
),
),
],
),
),
],
),
);
}
}
@@ -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<String> riskItems;
final List<String> 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<String> get suggestedDocumentTypeCodes {
final text = _combinedText.toLowerCase();
final codes = <String>[];
if (_hasAny(text, const <String>['解除', '终止', 'rescission', 'terminate'])) {
codes.add('rescission_notice');
}
if (_hasAny(text, const <String>['违约', '催告', '逾期', '付款', '交付', '履约'])) {
codes.add('demand_letter');
}
if (_hasAny(text, const <String>['授权', '委托', '代理', 'power', 'attorney'])) {
codes.add('power_of_attorney');
}
if (_hasAny(
text, const <String>['法律意见', '人工复核', '重大风险', 'legal opinion'])) {
codes.add('legal_opinion');
}
if (overallRisk == 'high' && !codes.contains('legal_opinion')) {
codes.add('legal_opinion');
}
if (codes.isEmpty) {
codes.addAll(const <String>['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<String, dynamic> buildInitialElementValues(
LegalDocumentElementSchema schema,
) {
final values = <String, dynamic>{};
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(<String>[
'审查任务:$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(<String>[
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(<String>[
if (reviewOpinion.trim().isNotEmpty) '审查意见:${reviewOpinion.trim()}',
if (riskSummary.isNotEmpty) riskSummary,
'移动端已从审查结果带入初始信息,正式出具前仍需人工复核事实、证据和法源。',
]);
}
return '';
}
String get _combinedText => _compactLines(<String>[
reviewTitle,
contractType,
overallRisk,
reviewOpinion,
recommendation,
...riskItems,
...suggestions,
if (sourceTextPreview != null) sourceTextPreview!,
]);
String get _riskSummary => _compactLines(<String>[
if (overallRisk.trim().isNotEmpty) '总体风险:$overallRisk',
if (riskItems.isNotEmpty) '重点风险:${riskItems.join(';')}',
if (reviewOpinion.trim().isNotEmpty) '审查意见:${reviewOpinion.trim()}',
]);
String get _actionSummary => _compactLines(<String>[
if (recommendation.trim().isNotEmpty) '建议动作:${recommendation.trim()}',
if (suggestions.isNotEmpty) '处理建议:${suggestions.join(';')}',
if (reviewOpinion.trim().isNotEmpty) '审查意见:${reviewOpinion.trim()}',
]);
}
bool _hasAny(String text, List<String> 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<String> 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;
}
}
@@ -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 ?? <String, dynamic>{};
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
@@ -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<void> _openBuilder(LegalDocumentTypeItem item) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
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,
@@ -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<ReviewDetailScreen> {
);
}
Future<void> _openLegalDocumentDrafting({
required ReviewDetail detail,
required ReviewResultSummary result,
}) async {
final reviewContext = LegalDocumentReviewContext.fromReview(
detail: detail,
result: result,
);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LegalDocumentTypeListScreen(
reviewContext: reviewContext,
),
),
);
}
void _handleNextAction({
required ReviewDetailNextAction action,
required ReviewDetail detail,
@@ -535,6 +554,39 @@ class _ReviewDetailScreenState extends State<ReviewDetailScreen> {
],
),
),
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(
@@ -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 <ReviewHistoryItem>[_recentReview];
}
@override
Future<void> load() async {
items.value = const <ReviewHistoryItem>[_recentReview];
}
}
@@ -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(<String>['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: <String>[
'逾期付款',
'履约拖延',
],
suggestionHighlights: <String>[
'先发催告函',
'补充证据并保留追责权利',
],
);
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: <String, dynamic>{},
sections: <LegalDocumentElementSection>[
LegalDocumentElementSection(
key: 'core',
title: '核心信息',
description: '',
fields: <LegalDocumentElementField>[
LegalDocumentElementField(
key: 'opinion.title',
label: '标题',
type: 'text',
required: true,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.contract_type',
label: '合同类型',
type: 'text',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.scope',
label: '核查范围',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.risk',
label: '风险摘要',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.action',
label: '处理建议',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.evidence',
label: '原文摘要',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.purpose',
label: '出具目的',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
],
),
],
status: 'published',
publishedAt: '',
publishedBy: '',
rollbackFromSchemaId: '',
createdBy: '',
updatedBy: '',
createdAt: '',
updatedAt: '',
);
@@ -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<TextField>(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<LegalDocumentTypeListResponse> listLegalDocumentTypes(
String accessToken, {
@@ -114,6 +140,7 @@ class _DocumentsPresentationApiClient extends ApiClient {
String? query,
bool activeOnly = true,
}) async {
lastTypeQuery = query;
return const LegalDocumentTypeListResponse(
items: <LegalDocumentTypeItem>[_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: <String>[
'逾期付款',
'履约拖延',
],
suggestions: <String>[
'先发催告函',
'补充证据并保留追责权利',
],
sourceTextPreview: '对方已逾期付款并多次拖延交付。',
);
const _schema = LegalDocumentElementSchema(
id: 'ldes-1',
documentTypeCode: 'legal_opinion',
@@ -197,17 +242,71 @@ const _schema = LegalDocumentElementSchema(
schemaJson: <String, dynamic>{},
sections: <LegalDocumentElementSection>[
LegalDocumentElementSection(
key: 'scope',
title: 'Scope',
key: 'core',
title: 'Core',
description: '',
fields: <LegalDocumentElementField>[
LegalDocumentElementField(
key: 'opinion.title',
label: '标题',
type: 'text',
required: true,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.contract_type',
label: '合同类型',
type: 'text',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.scope',
label: 'Scope',
label: '核查范围',
type: 'textarea',
required: true,
description: '',
placeholder: 'Describe the review scope.',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.risk',
label: '风险摘要',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.action',
label: '处理建议',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.evidence',
label: '原文摘要',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
LegalDocumentElementField(
key: 'opinion.purpose',
label: '出具目的',
type: 'textarea',
required: false,
description: '',
placeholder: '',
options: <LegalDocumentFieldOption>[],
),
],
+431 -38
View File
@@ -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",
@@ -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 '^(?<key>[^=]+)=(?<value>.*)$') {
$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"
}