feat: close pc and android product smoke flows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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>[],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user