Add contract version chain mobile workflow

This commit is contained in:
2026-05-26 16:41:31 +08:00
parent d0ceccf10d
commit fcd9e9dbc2
15 changed files with 3247 additions and 494 deletions
@@ -1026,6 +1026,10 @@ class ContractGenerationService:
"sourceDocument": source_document,
}
detail_url = f"/reviews/{review_id}" if review_detail else None
report_download_url = review_report.get("download_url") if review_report else None
report_file_name = review_report.get("file_name") if review_report else None
chat_url = f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}" if review_detail else None
return {
"review_id": review_id,
"review_title": generated_contract.template_name,
@@ -1045,10 +1049,10 @@ class ContractGenerationService:
"source_file_name": generated_contract.file_name,
"source_text_length": review_detail.get("source_text_length"),
"processing_time_ms": review_detail.get("processing_time_ms"),
"detail_url": f"/reviews/{review_id}",
"report_download_url": review_report.get("download_url") if review_report else f"/api/v1/reviews/{review_id}/report/download",
"report_file_name": review_report.get("file_name") if review_report else f"review-report-{review_id}.pdf",
"chat_url": f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}",
"detail_url": detail_url,
"report_download_url": report_download_url,
"report_file_name": report_file_name,
"chat_url": chat_url,
"prompt_runtime": prompt_runtime,
}
@@ -1551,7 +1555,9 @@ class ContractGenerationService:
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
current_user_id = str(current_user.get("id") or "")
generated_by = str(generated_contract.generated_by or "")
if current_user.get("role") != "admin" and generated_by != current_user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
return generated_contract
+3 -1
View File
@@ -346,7 +346,9 @@ class FileService:
)
if current_user.get("role") == "admin":
return file_record
if allow_owner_access and file_record.uploaded_by == current_user.get("id"):
current_user_id = str(current_user.get("id") or "")
uploaded_by = str(file_record.uploaded_by or "")
if allow_owner_access and uploaded_by == current_user_id:
return file_record
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
+28
View File
@@ -80,6 +80,18 @@ The summary prints:
- `TAXONOMY_TARGET_CARD_OK`
- `TAXONOMY_FILTER_ACTIVE_OK`
- `TAXONOMY_VISIBLE_KEYS`
- `CONTRACT_RESULT_PROOF_OK`
- `CONTRACT_RESULT_DOWNLOAD_OK`
- `CONTRACT_RESULT_SHARE_OK`
- `CONTRACT_RESULT_CONTINUE_OK`
- `CONTRACT_VERSION_CHAIN_PROOF_OK`
- `CONTRACT_VERSION_CHAIN_ENTRY_OK`
- `CONTRACT_VERSION_CHAIN_DOWNLOAD_OK`
- `CONTRACT_VERSION_CHAIN_DIFF_VISIBLE`
- `CONTRACT_VERSION_CHAIN_SAVE_OK`
- `CONTRACT_VERSION_CHAIN_CREATED_VISIBLE`
- `CONTRACT_VERSION_CHAIN_SAVE_MARKER`
- `CONTRACT_VERSION_CHAIN_VISIBLE_LABELS`
- `DOCUMENTS_PROOF_OK`
- `DOCUMENTS_WORKSPACE_READY`
- `DOCUMENTS_TYPE_LIST_OK`
@@ -121,6 +133,14 @@ Smoke depth options:
- starts from the signed-in shell
- opens `AI tools -> contract workbench`, applies the compliance/general-enterprise template filters, and verifies the filtered template keys
- intended for deeper proof that template-category and industry-entry filtering still works on the proven X60PRO device profile
- `contract-result-actions`
- starts from the signed-in shell
- opens `AI tools -> contract workbench -> generated result`, then verifies download, share, and continue-editing actions
- intended for proof that generated contract delivery actions remain reachable after result-page changes
- `contract-version-chain`
- starts from the signed-in shell
- opens a generated contract result, enters the lightweight editor, appends a smoke marker, saves a new version, and verifies the version-chain entry, diff entry, download entry, and created version visibility
- intended for proof that generated contracts can complete a real mobile version-management save loop on device
- `documents-workspace`
- starts from the signed-in shell
- opens `AI tools -> legal document workspace`, verifies the document type list, and continues toward the builder surface
@@ -142,6 +162,12 @@ powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\re
# Deeper proof that contract template taxonomy filters work on device
powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -ConfigureDevice -SkipInstall -SmokeDepth contract-taxonomy
# Deeper proof that generated-contract result actions remain reachable
powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -ConfigureDevice -SkipInstall -SmokeDepth contract-result-actions
# Deeper proof that generated-contract version-chain can create and surface a saved version
powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -ConfigureDevice -SkipInstall -SmokeDepth contract-version-chain
# Deeper proof that legal-document drafting remains reachable on device
powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -SkipInstall -SmokeDepth documents-workspace
@@ -161,6 +187,8 @@ powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\re
- Report download is validated
- Contract download is validated
- Contract template taxonomy filtering is validated through `contract-taxonomy` smoke depth
- Generated-contract result download/share/continue-editing is validated through `contract-result-actions`
- Generated-contract version-chain entry, editor save, diff affordance, version download affordance, and created-version visibility are validated through `contract-version-chain`
- Legal-document types, schema loading, and draft persistence are now wired into the mobile app; the `documents-workspace` smoke depth now proves the workspace, type list, and builder entry path on the signed-in X60PRO session
- The `AI tools` surface now exposes a first-class business-auxiliary launch strip for obligation extraction, filing-materials checklists, negotiation strategy, and legal-opinion boundary fallback, and the embedded legal chat keeps the same four quick prompts available for continuity
- External open/share behavior is OEM-dependent and may route into vendor apps such as the default browser or WeChat
+66
View File
@@ -673,3 +673,69 @@ Created on 2026-04-18.
- `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/`
## Generated contract result actions upgrade (2026-05-26)
- The mobile contract-generation result flow now has stable automation anchors for the production-facing actions:
- contract workspace template cards and generated-contract history actions
- contract builder preview / generate actions
- generated-contract result download, open, share, and continue-editing actions
- `mobile/tools/real-device-smoke.ps1` now supports a dedicated `contract-result-actions` depth.
- `mobile/tools/run-real-device-product-smoke-suite.ps1` now includes `contract-result-actions` in the default suite between `contract-taxonomy` and `documents-workspace`.
- Validation:
- PowerShell parser checks passed for both smoke scripts.
- `flutter analyze --no-pub`
- `flutter test --no-pub test/features/contracts/presentation/contract_screens_test.dart test/features/contracts/application/contract_template_controller_test.dart test/features/contracts/application/generated_contract_history_controller_test.dart`
- `flutter test --no-pub` passed with 61 tests.
- `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`
- Real-device smoke status:
- the current wireless-debug serial `192.168.3.93:45347` is connected and usable for real-device smoke.
- `mobile/tools/real-device-smoke.ps1 -DeviceSerial 192.168.3.93:45347 -ConfigureDevice -SkipInstall -SmokeDepth contract-result-actions -LoginAccount cyuqei@gmail.com -LoginPassword <member-password>` returned `SMOKE_OK=True`.
- the full product smoke suite now passes on the same device:
- `mobile/tools/run-real-device-product-smoke-suite.ps1 -DeviceSerial 192.168.3.93:45347 -ConfigureDevice -SkipInitialInstall -LoginAccount cyuqei@gmail.com -LoginPassword <member-password>` returned `SUITE_OK=True`
- passed steps: `shell-only`, `share-proof`, `contract-taxonomy`, `contract-result-actions`, `documents-workspace`, `review-legal-document`, `dashboard-review-preview`, `business-auxiliary`
- latest artifact root: `mobile/qa-artifacts/real-device-product-smoke-suite/2026-05-26T10-30-31-688/`
## Generated contract version-chain mobile closure (2026-05-26)
- The generated-contract result page now exposes the backend version-chain capability on Android:
- version list with base/generated version and saved editor versions
- mobile diff preview between generated-contract versions
- version download entry
- lightweight editor entry that saves edited contract content as a new backend version
- Mobile API/client coverage now includes:
- `GET /contracts/{contract_id}/versions`
- `GET /contracts/{contract_id}/versions/diff`
- `POST /contracts/{contract_id}/versions`
- `GET /contracts/{contract_id}/versions/{version_id}/download`
- `mobile/tools/real-device-smoke.ps1` now supports `contract-version-chain`.
- `mobile/tools/run-real-device-product-smoke-suite.ps1` now includes `contract-version-chain` in the default suite between `contract-result-actions` and `documents-workspace`.
- Validation:
- PowerShell parser checks passed for both smoke scripts.
- `flutter analyze --no-pub`
- `flutter test --no-pub test/features/contracts`
- `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:45347 -ConfigureDevice -SmokeDepth contract-version-chain -LoginAccount cyuqei@gmail.com -LoginPassword <member-password>` returned `SMOKE_OK=True`
- `mobile/tools/run-real-device-product-smoke-suite.ps1 -DeviceSerial 192.168.3.93:45347 -ConfigureDevice -SkipInitialInstall -LoginAccount cyuqei@gmail.com -LoginPassword <member-password>` returned `SUITE_OK=True`
- passed suite steps: `shell-only`, `share-proof`, `contract-taxonomy`, `contract-result-actions`, `contract-version-chain`, `documents-workspace`, `review-legal-document`, `dashboard-review-preview`, `business-auxiliary`
- latest artifact root: `mobile/qa-artifacts/real-device-product-smoke-suite/2026-05-26T11-23-24-023/`
## Generated contract version save-loop hardening (2026-05-26)
- The Android version editor now exposes stable automation anchors for:
- change-summary input
- contract-content input
- bottom save action
- The `contract-version-chain` smoke depth is no longer display-only:
- it enters the editor from a generated contract result
- appends a timestamped smoke marker to the summary/content
- saves a new backend version
- verifies the app returned to the result page and the created version marker is visible in the version list
- Additional result fields now exist in `summary.txt` for the save loop:
- `CONTRACT_VERSION_CHAIN_SAVE_OK`
- `CONTRACT_VERSION_CHAIN_CREATED_VISIBLE`
- `CONTRACT_VERSION_CHAIN_SAVE_MARKER`
- Local validation for this hardening slice:
- `flutter analyze --no-pub`
- `flutter test --no-pub test/features/contracts`
- PowerShell parser check for `mobile/tools/real-device-smoke.ps1`
@@ -0,0 +1,241 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../../../shared/network/api_client.dart';
import '../../../shared/storage/session_store.dart';
import '../domain/contract_models.dart';
import '../domain/contract_version_models.dart';
class GeneratedContractVersionController {
GeneratedContractVersionController({ApiClient? apiClient})
: _apiClient = apiClient ?? const ApiClient();
final ApiClient _apiClient;
final ValueNotifier<bool> isLoading = ValueNotifier<bool>(false);
final ValueNotifier<bool> isSaving = ValueNotifier<bool>(false);
final ValueNotifier<bool> isDownloading = ValueNotifier<bool>(false);
final ValueNotifier<String?> errorMessage = ValueNotifier<String?>(null);
final ValueNotifier<GeneratedContractVersionListResponse?> versions =
ValueNotifier<GeneratedContractVersionListResponse?>(null);
final ValueNotifier<GeneratedContractVersionDiffResult?> diff =
ValueNotifier<GeneratedContractVersionDiffResult?>(null);
final ValueNotifier<String?> localFilePath = ValueNotifier<String?>(null);
Future<void> loadVersions(String contractId) async {
final session = SessionStore.current;
if (session == null || !session.isAuthenticated) {
errorMessage.value = '请先登录后查看版本链';
versions.value = null;
return;
}
isLoading.value = true;
errorMessage.value = null;
try {
versions.value = await _apiClient.listGeneratedContractVersions(
session.accessToken,
contractId,
);
} on ApiException catch (error) {
if (error.isUnauthorized) {
await SessionStore.clear();
errorMessage.value = '登录已过期,请重新登录';
} else {
errorMessage.value = error.message;
}
versions.value = null;
} catch (_) {
errorMessage.value = '获取合同版本链失败,请稍后重试';
versions.value = null;
} finally {
isLoading.value = false;
}
}
Future<void> loadDiff(
String contractId, {
String? fromVersionId,
String? toVersionId,
}) async {
final session = SessionStore.current;
if (session == null || !session.isAuthenticated) {
errorMessage.value = '请先登录后查看版本差异';
diff.value = null;
return;
}
isLoading.value = true;
errorMessage.value = null;
try {
diff.value = await _apiClient.diffGeneratedContractVersions(
session.accessToken,
contractId,
fromVersionId: fromVersionId,
toVersionId: toVersionId,
);
} on ApiException catch (error) {
if (error.isUnauthorized) {
await SessionStore.clear();
errorMessage.value = '登录已过期,请重新登录';
} else {
errorMessage.value = error.message;
}
diff.value = null;
} catch (_) {
errorMessage.value = '获取合同版本差异失败,请稍后重试';
diff.value = null;
} finally {
isLoading.value = false;
}
}
Future<GeneratedContractVersionItem> saveVersion({
required String contractId,
required String content,
String changeType = 'editor_save',
String? changeSummary,
String? sourceVersionId,
String? originalText,
String? replacementText,
String locateStatus = 'applied',
}) async {
final session = SessionStore.current;
if (session == null || !session.isAuthenticated) {
throw const GeneratedContractVersionException('请先登录后保存版本');
}
isSaving.value = true;
errorMessage.value = null;
try {
final result = await _apiClient.createGeneratedContractVersion(
session.accessToken,
contractId,
content: content,
changeType: changeType,
changeSummary: changeSummary,
sourceVersionId: sourceVersionId,
originalText: originalText,
replacementText: replacementText,
locateStatus: locateStatus,
);
return result;
} on ApiException catch (error) {
if (error.isUnauthorized) {
await SessionStore.clear();
errorMessage.value = '登录已过期,请重新登录';
throw const GeneratedContractVersionException('登录已过期,请重新登录');
}
errorMessage.value = error.message;
throw GeneratedContractVersionException(error.message);
} catch (_) {
errorMessage.value = '保存合同版本失败,请稍后重试';
throw const GeneratedContractVersionException('保存合同版本失败,请稍后重试');
} finally {
isSaving.value = false;
}
}
Future<String> downloadVersion(
GeneratedContractItem contract,
GeneratedContractVersionItem version,
) async {
if (isDownloading.value) {
throw const GeneratedContractVersionException('版本文件正在处理,请稍候');
}
isDownloading.value = true;
try {
final bytes = await _resolveBytes(contract.id, version);
final directory = await _resolveVersionDirectory();
final fileName = _buildFileName(contract.fileName, contract.id, version);
final file = File('${directory.path}${Platform.pathSeparator}$fileName');
await file.writeAsBytes(bytes, flush: true);
localFilePath.value = file.path;
return '版本 ${version.versionLabel} 已下载到本地';
} finally {
isDownloading.value = false;
}
}
Future<List<int>> _resolveBytes(
String contractId,
GeneratedContractVersionItem version,
) async {
final session = SessionStore.current;
if (session == null || !session.isAuthenticated) {
throw const GeneratedContractVersionException('登录已过期,请重新登录');
}
try {
return await _apiClient.downloadGeneratedContractVersionFile(
session.accessToken,
contractId,
version.id,
);
} on ApiException catch (error) {
if (error.isUnauthorized) {
await SessionStore.clear();
throw const GeneratedContractVersionException('登录已过期,请重新登录');
}
throw GeneratedContractVersionException(error.message);
} catch (_) {
throw const GeneratedContractVersionException('版本文件下载失败,请稍后重试');
}
}
Future<Directory> _resolveVersionDirectory() async {
final baseDirectory = await getApplicationDocumentsDirectory();
final directory = Directory(
'${baseDirectory.path}${Platform.pathSeparator}generated_contract_versions',
);
if (!directory.existsSync()) {
await directory.create(recursive: true);
}
return directory;
}
String _buildFileName(
String rawFileName,
String contractId,
GeneratedContractVersionItem version,
) {
final trimmed = rawFileName.trim();
final versionSuffix = '_${version.versionLabel}';
if (trimmed.isEmpty) {
return 'generated_contract_$contractId$versionSuffix.docx';
}
final dotIndex = trimmed.lastIndexOf('.');
if (dotIndex > 0) {
final base = trimmed.substring(0, dotIndex);
final ext = trimmed.substring(dotIndex);
return '${_sanitizeFileName(base)}$versionSuffix$ext';
}
return '${_sanitizeFileName(trimmed)}$versionSuffix.docx';
}
String _sanitizeFileName(String rawFileName) {
return rawFileName.trim().replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
}
void dispose() {
isLoading.dispose();
isSaving.dispose();
isDownloading.dispose();
errorMessage.dispose();
versions.dispose();
diff.dispose();
localFilePath.dispose();
}
}
class GeneratedContractVersionException implements Exception {
const GeneratedContractVersionException(this.message);
final String message;
@override
String toString() => message;
}
@@ -0,0 +1,254 @@
class GeneratedContractVersionItem {
const GeneratedContractVersionItem({
required this.id,
required this.contractId,
required this.versionNumber,
required this.versionLabel,
required this.sourceVersionId,
required this.changeType,
required this.changeSummary,
required this.originalText,
required this.replacementText,
required this.content,
required this.locateStatus,
required this.createdBy,
required this.createdAt,
required this.detailUrl,
});
final String id;
final String contractId;
final int versionNumber;
final String versionLabel;
final String? sourceVersionId;
final String changeType;
final String changeSummary;
final String originalText;
final String replacementText;
final String content;
final String locateStatus;
final String createdBy;
final String createdAt;
final String detailUrl;
bool get isBase => id == 'base' || versionNumber == 1;
factory GeneratedContractVersionItem.fromJson(
Map<String, dynamic> json, {
String? contractId,
}) {
final versionNumber = (json['version_number'] as num?)?.toInt() ?? 1;
final versionLabel = json['version_label']?.toString() ?? 'v$versionNumber';
return GeneratedContractVersionItem(
id: json['id']?.toString() ??
(versionNumber == 1 || versionLabel == 'v1' ? 'base' : ''),
contractId: json['contract_id']?.toString() ?? contractId ?? '',
versionNumber: versionNumber,
versionLabel: versionLabel,
sourceVersionId: json['source_version_id']?.toString(),
changeType: json['change_type']?.toString() ??
(versionNumber == 1 ? 'generated' : 'editor_save'),
changeSummary: json['change_summary']?.toString() ??
(versionNumber == 1 ? '原始生成版本' : ''),
originalText: json['original_text']?.toString() ?? '',
replacementText: json['replacement_text']?.toString() ?? '',
content: json['content']?.toString() ?? '',
locateStatus: json['locate_status']?.toString() ?? '',
createdBy: json['created_by']?.toString() ?? '',
createdAt: json['created_at']?.toString() ?? '',
detailUrl: json['detail_url']?.toString() ?? '',
);
}
}
class GeneratedContractVersionListResponse {
const GeneratedContractVersionListResponse({
required this.contractId,
required this.baseVersion,
required this.items,
required this.total,
});
final String contractId;
final GeneratedContractVersionItem baseVersion;
final List<GeneratedContractVersionItem> items;
final int total;
List<GeneratedContractVersionItem> get displayItems {
return <GeneratedContractVersionItem>[...items, baseVersion];
}
GeneratedContractVersionItem get latestVersion {
if (items.isEmpty) {
return baseVersion;
}
return items.reduce(
(current, next) =>
next.versionNumber > current.versionNumber ? next : current,
);
}
factory GeneratedContractVersionListResponse.fromJson(
Map<String, dynamic> json) {
final contractId = json['contract_id']?.toString() ?? '';
final rawItems = json['items'];
final baseMap = json['base_version'] is Map<String, dynamic>
? json['base_version'] as Map<String, dynamic>
: <String, dynamic>{'version_number': 1, 'version_label': 'v1'};
return GeneratedContractVersionListResponse(
contractId: contractId,
baseVersion: GeneratedContractVersionItem.fromJson(
baseMap,
contractId: contractId,
),
items: rawItems is List
? rawItems
.whereType<Map<String, dynamic>>()
.map((item) => GeneratedContractVersionItem.fromJson(item,
contractId: contractId))
.toList(growable: false)
: const <GeneratedContractVersionItem>[],
total: (json['total'] as num?)?.toInt() ?? 0,
);
}
}
class GeneratedContractVersionSnapshot {
const GeneratedContractVersionSnapshot({
required this.id,
required this.versionNumber,
required this.versionLabel,
required this.sourceVersionId,
required this.changeType,
required this.changeSummary,
required this.createdAt,
});
final String id;
final int versionNumber;
final String versionLabel;
final String? sourceVersionId;
final String changeType;
final String changeSummary;
final String createdAt;
factory GeneratedContractVersionSnapshot.fromJson(Map<String, dynamic> json) {
final versionNumber = (json['version_number'] as num?)?.toInt() ?? 1;
return GeneratedContractVersionSnapshot(
id: json['id']?.toString() ??
(versionNumber == 1
? 'base'
: json['version_label']?.toString() ?? ''),
versionNumber: versionNumber,
versionLabel: json['version_label']?.toString() ?? 'v$versionNumber',
sourceVersionId: json['source_version_id']?.toString(),
changeType: json['change_type']?.toString() ?? '',
changeSummary: json['change_summary']?.toString() ?? '',
createdAt: json['created_at']?.toString() ?? '',
);
}
}
class GeneratedContractVersionDiffSummary {
const GeneratedContractVersionDiffSummary({
required this.added,
required this.removed,
required this.modified,
required this.unchanged,
required this.changeCount,
required this.fromParagraphCount,
required this.toParagraphCount,
});
final int added;
final int removed;
final int modified;
final int unchanged;
final int changeCount;
final int fromParagraphCount;
final int toParagraphCount;
factory GeneratedContractVersionDiffSummary.fromJson(
Map<String, dynamic> json) {
return GeneratedContractVersionDiffSummary(
added: (json['added'] as num?)?.toInt() ?? 0,
removed: (json['removed'] as num?)?.toInt() ?? 0,
modified: (json['modified'] as num?)?.toInt() ?? 0,
unchanged: (json['unchanged'] as num?)?.toInt() ?? 0,
changeCount: (json['change_count'] as num?)?.toInt() ?? 0,
fromParagraphCount: (json['from_paragraph_count'] as num?)?.toInt() ?? 0,
toParagraphCount: (json['to_paragraph_count'] as num?)?.toInt() ?? 0,
);
}
}
class GeneratedContractVersionDiffItem {
const GeneratedContractVersionDiffItem({
required this.changeType,
required this.oldParagraphIndex,
required this.newParagraphIndex,
required this.oldText,
required this.newText,
});
final String changeType;
final int? oldParagraphIndex;
final int? newParagraphIndex;
final String oldText;
final String newText;
factory GeneratedContractVersionDiffItem.fromJson(Map<String, dynamic> json) {
return GeneratedContractVersionDiffItem(
changeType: json['change_type']?.toString() ?? '',
oldParagraphIndex: (json['old_paragraph_index'] as num?)?.toInt(),
newParagraphIndex: (json['new_paragraph_index'] as num?)?.toInt(),
oldText: json['old_text']?.toString() ?? '',
newText: json['new_text']?.toString() ?? '',
);
}
}
class GeneratedContractVersionDiffResult {
const GeneratedContractVersionDiffResult({
required this.contractId,
required this.fromVersion,
required this.toVersion,
required this.summary,
required this.items,
});
final String contractId;
final GeneratedContractVersionSnapshot fromVersion;
final GeneratedContractVersionSnapshot toVersion;
final GeneratedContractVersionDiffSummary summary;
final List<GeneratedContractVersionDiffItem> items;
factory GeneratedContractVersionDiffResult.fromJson(
Map<String, dynamic> json) {
final rawItems = json['items'];
return GeneratedContractVersionDiffResult(
contractId: json['contract_id']?.toString() ?? '',
fromVersion: GeneratedContractVersionSnapshot.fromJson(
json['from_version'] is Map<String, dynamic>
? json['from_version'] as Map<String, dynamic>
: <String, dynamic>{},
),
toVersion: GeneratedContractVersionSnapshot.fromJson(
json['to_version'] is Map<String, dynamic>
? json['to_version'] as Map<String, dynamic>
: <String, dynamic>{},
),
summary: GeneratedContractVersionDiffSummary.fromJson(
json['summary'] is Map<String, dynamic>
? json['summary'] as Map<String, dynamic>
: <String, dynamic>{},
),
items: rawItems is List
? rawItems
.whereType<Map<String, dynamic>>()
.map(GeneratedContractVersionDiffItem.fromJson)
.toList(growable: false)
: const <GeneratedContractVersionDiffItem>[],
);
}
}
@@ -151,231 +151,254 @@ class _ContractBuilderScreenState extends State<ContractBuilderScreen> {
appBar: AppBar(
title: Text(template.name),
),
body: ListView(
padding: EdgeInsets.zero,
children: [
AppScaffoldPage(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSectionCard(
title: '模板信息',
subtitle: '先确认模板说明,再填写变量并预览生成结果。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoLine(label: '合同类型', value: template.contractType),
const SizedBox(height: 12),
_InfoLine(
label: '模板分类',
value:
formatTemplateCategory(template.templateCategory),
),
const SizedBox(height: 12),
_InfoLine(
label: '行业入口',
value:
formatIndustryCategory(template.industryCategory),
),
if (template.description.isNotEmpty) ...[
const SizedBox(height: 12),
_InfoLine(label: '模板说明', value: template.description),
],
if (template.previewExcerpt.isNotEmpty) ...[
const SizedBox(height: 12),
_InfoLine(
label: '预览摘要', value: template.previewExcerpt),
],
],
),
),
if (widget.reviewContext != null) ...[
const SizedBox(height: 16),
AppSectionCard(
title: '审查上下文已带入',
subtitle: '系统已尝试把审查意见、风险项和建议动作预填到匹配变量中,你可以继续编辑后再生成。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoLine(
label: '审查任务',
value: widget.reviewContext!.reviewTitle ?? '-'),
const SizedBox(height: 12),
_InfoLine(
label: '合同类型',
value: widget.reviewContext!.contractType ?? '-'),
const SizedBox(height: 12),
_InfoLine(
label: '总体风险',
value: widget.reviewContext!.overallRisk ?? '-'),
if (widget.reviewContext!.reviewOpinion != null &&
widget
.reviewContext!.reviewOpinion!.isNotEmpty) ...[
body: Semantics(
container: true,
label: 'contract-builder-${template.id}',
child: ListView(
padding: EdgeInsets.zero,
children: [
SizedBox(
height: MediaQuery.sizeOf(context).height,
child: AppScaffoldPage(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSectionCard(
title: '模板信息',
subtitle: '先确认模板说明,再填写变量并预览生成结果。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoLine(
label: '合同类型', value: template.contractType),
const SizedBox(height: 12),
_InfoLine(
label: '审查意见',
value: widget.reviewContext!.reviewOpinion!),
],
],
),
),
],
const SizedBox(height: 16),
AppSectionCard(
title: '变量填写',
subtitle: '优先填写必填变量,确认导出格式后再预览和生成。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final variable in template.variables) ...[
TextField(
controller: _fieldControllers[variable.name],
minLines: variable.description.length > 24 ? 2 : 1,
maxLines: variable.description.length > 24 ? 3 : 1,
decoration: InputDecoration(
labelText: variable.required
? '${variable.label} *'
: variable.label,
hintText: variable.description.isNotEmpty
? variable.description
: null,
label: '模板分类',
value: formatTemplateCategory(
template.templateCategory),
),
),
if (variable != template.variables.last)
const SizedBox(height: 12),
],
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _exportFormat,
decoration: const InputDecoration(
labelText: '导出格式',
),
items: const <DropdownMenuItem<String>>[
DropdownMenuItem<String>(
value: 'docx', child: Text('Word (.docx)')),
DropdownMenuItem<String>(
value: 'pdf', child: Text('PDF (.pdf)')),
DropdownMenuItem<String>(
value: 'html', child: Text('HTML (.html)')),
DropdownMenuItem<String>(
value: 'txt', child: Text('文本 (.txt)')),
_InfoLine(
label: '行业入口',
value: formatIndustryCategory(
template.industryCategory),
),
if (template.description.isNotEmpty) ...[
const SizedBox(height: 12),
_InfoLine(
label: '模板说明', value: template.description),
],
if (template.previewExcerpt.isNotEmpty) ...[
const SizedBox(height: 12),
_InfoLine(
label: '预览摘要', value: template.previewExcerpt),
],
],
onChanged: (value) {
if (value == null) {
return;
}
setState(() {
_exportFormat = value;
});
},
),
),
if (widget.reviewContext != null) ...[
const SizedBox(height: 16),
AppSectionCard(
title: '审查上下文已带入',
subtitle: '系统已尝试把审查意见、风险项和建议动作预填到匹配变量中,你可以继续编辑后再生成。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoLine(
label: '审查任务',
value:
widget.reviewContext!.reviewTitle ?? '-'),
const SizedBox(height: 12),
_InfoLine(
label: '合同类型',
value:
widget.reviewContext!.contractType ?? '-'),
const SizedBox(height: 12),
_InfoLine(
label: '总体风险',
value:
widget.reviewContext!.overallRisk ?? '-'),
if (widget.reviewContext!.reviewOpinion != null &&
widget.reviewContext!.reviewOpinion!
.isNotEmpty) ...[
const SizedBox(height: 12),
_InfoLine(
label: '审查意见',
value: widget.reviewContext!.reviewOpinion!),
],
],
),
),
],
),
),
const SizedBox(height: 16),
ValueListenableBuilder<ContractPreviewItem?>(
valueListenable: _controller.preview,
builder: (context, preview, _) {
if (preview == null) {
return const SizedBox.shrink();
}
return Column(
children: [
AppSectionCard(
title: '合同预览',
subtitle: '这里只是生成前预览,不会写入正式合同记录。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (preview.variableSummary.isNotEmpty) ...[
for (final item in preview.variableSummary) ...[
_InfoLine(
label: item.label,
value: item.valuePreview.isEmpty
? '(空)'
: item.valuePreview,
),
if (item != preview.variableSummary.last)
const SizedBox(height: 12),
],
const SizedBox(height: 16),
],
Text(preview.previewContent),
const SizedBox(height: 16),
_InfoLine(
label: '当前导出格式',
value: _exportFormat.toUpperCase()),
const SizedBox(height: 16),
AppSectionCard(
title: '变量填写',
subtitle: '优先填写必填变量,确认导出格式后再预览和生成。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final variable in template.variables) ...[
TextField(
controller: _fieldControllers[variable.name],
minLines:
variable.description.length > 24 ? 2 : 1,
maxLines:
variable.description.length > 24 ? 3 : 1,
decoration: InputDecoration(
labelText: variable.required
? '${variable.label} *'
: variable.label,
hintText: variable.description.isNotEmpty
? variable.description
: null,
),
),
if (variable != template.variables.last)
const SizedBox(height: 12),
],
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _exportFormat,
decoration: const InputDecoration(
labelText: '导出格式',
),
items: const <DropdownMenuItem<String>>[
DropdownMenuItem<String>(
value: 'docx', child: Text('Word (.docx)')),
DropdownMenuItem<String>(
value: 'pdf', child: Text('PDF (.pdf)')),
DropdownMenuItem<String>(
value: 'html', child: Text('HTML (.html)')),
DropdownMenuItem<String>(
value: 'txt', child: Text('文本 (.txt)')),
],
onChanged: (value) {
if (value == null) {
return;
}
setState(() {
_exportFormat = value;
});
},
),
),
const SizedBox(height: 16),
],
);
},
),
ValueListenableBuilder<String?>(
valueListenable: _controller.errorMessage,
builder: (context, message, _) {
if (message == null || message.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
message,
style: TextStyle(
color: Theme.of(context).colorScheme.error),
),
);
},
),
Row(
children: [
Expanded(
child: ValueListenableBuilder<bool>(
valueListenable: _controller.isPreviewing,
builder: (context, isPreviewing, _) {
return OutlinedButton.icon(
onPressed: isPreviewing ? null : _preview,
icon: isPreviewing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.visibility_outlined),
label: Text(isPreviewing ? '预览中...' : '预览合同'),
);
},
],
),
),
const SizedBox(width: 12),
Expanded(
child: ValueListenableBuilder<bool>(
valueListenable: _controller.isGenerating,
builder: (context, isGenerating, _) {
return FilledButton.icon(
onPressed: isGenerating ? null : _generate,
icon: isGenerating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(isGenerating ? '生成中...' : '生成合同'),
);
},
),
const SizedBox(height: 16),
ValueListenableBuilder<ContractPreviewItem?>(
valueListenable: _controller.preview,
builder: (context, preview, _) {
if (preview == null) {
return const SizedBox.shrink();
}
return Column(
children: [
AppSectionCard(
title: '合同预览',
subtitle: '这里只是生成前预览,不会写入正式合同记录。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (preview.variableSummary.isNotEmpty) ...[
for (final item
in preview.variableSummary) ...[
_InfoLine(
label: item.label,
value: item.valuePreview.isEmpty
? '(空)'
: item.valuePreview,
),
if (item != preview.variableSummary.last)
const SizedBox(height: 12),
],
const SizedBox(height: 16),
],
Text(preview.previewContent),
const SizedBox(height: 16),
_InfoLine(
label: '当前导出格式',
value: _exportFormat.toUpperCase()),
],
),
),
const SizedBox(height: 16),
],
);
},
),
ValueListenableBuilder<String?>(
valueListenable: _controller.errorMessage,
builder: (context, message, _) {
if (message == null || message.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
message,
style: TextStyle(
color: Theme.of(context).colorScheme.error),
),
);
},
),
Row(
children: [
Expanded(
child: ValueListenableBuilder<bool>(
valueListenable: _controller.isPreviewing,
builder: (context, isPreviewing, _) {
return Semantics(
label: 'preview-contract',
button: true,
child: OutlinedButton.icon(
onPressed: isPreviewing ? null : _preview,
icon: isPreviewing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.visibility_outlined),
label: Text(isPreviewing ? '预览中...' : '预览合同'),
),
);
},
),
),
const SizedBox(width: 12),
Expanded(
child: ValueListenableBuilder<bool>(
valueListenable: _controller.isGenerating,
builder: (context, isGenerating, _) {
return Semantics(
label: 'generate-contract',
button: true,
child: FilledButton.icon(
onPressed: isGenerating ? null : _generate,
icon: isGenerating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(isGenerating ? '生成中...' : '生成合同'),
),
);
},
),
),
],
),
],
),
],
),
),
),
],
],
),
),
);
}
@@ -18,10 +18,14 @@ class ContractTemplateListScreen extends StatefulWidget {
super.key,
this.reviewContext,
this.embedded = false,
this.controller,
this.historyController,
});
final ContractReviewContext? reviewContext;
final bool embedded;
final ContractTemplateController? controller;
final GeneratedContractHistoryController? historyController;
@override
State<ContractTemplateListScreen> createState() =>
@@ -30,9 +34,10 @@ class ContractTemplateListScreen extends StatefulWidget {
class _ContractTemplateListScreenState extends State<ContractTemplateListScreen>
with SingleTickerProviderStateMixin {
final ContractTemplateController _controller = ContractTemplateController();
final GeneratedContractHistoryController _historyController =
GeneratedContractHistoryController();
late final ContractTemplateController _controller;
late final GeneratedContractHistoryController _historyController;
late final bool _ownsController;
late final bool _ownsHistoryController;
final TextEditingController _keywordController = TextEditingController();
final TextEditingController _templateKeywordController =
TextEditingController();
@@ -46,6 +51,11 @@ class _ContractTemplateListScreenState extends State<ContractTemplateListScreen>
@override
void initState() {
super.initState();
_controller = widget.controller ?? ContractTemplateController();
_historyController =
widget.historyController ?? GeneratedContractHistoryController();
_ownsController = widget.controller == null;
_ownsHistoryController = widget.historyController == null;
_tabController = TabController(length: 2, vsync: this);
_controller.load();
_historyController.load();
@@ -54,8 +64,12 @@ class _ContractTemplateListScreenState extends State<ContractTemplateListScreen>
@override
void dispose() {
_tabController.dispose();
_controller.dispose();
_historyController.dispose();
if (_ownsController) {
_controller.dispose();
}
if (_ownsHistoryController) {
_historyController.dispose();
}
_keywordController.dispose();
_templateKeywordController.dispose();
super.dispose();
@@ -461,14 +475,22 @@ class _ContractTemplateListScreenState extends State<ContractTemplateListScreen>
);
if (widget.embedded) {
return body;
return Semantics(
container: true,
label: 'contract-workspace',
child: body,
);
}
return Scaffold(
appBar: AppBar(
title: const Text('合同生成'),
return Semantics(
container: true,
label: 'contract-workspace',
child: Scaffold(
appBar: AppBar(
title: const Text('合同生成'),
),
body: body,
),
body: body,
);
}
@@ -795,9 +817,21 @@ class _WorkspaceTopBar extends StatelessWidget {
unselectedLabelColor: BrandAssets.muted,
indicatorColor: BrandAssets.accentLime,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: '模板'),
Tab(text: '历史结果'),
tabs: [
Tab(
child: Semantics(
label: 'contract-template-tab',
button: true,
child: Text('模板'),
),
),
Tab(
child: Semantics(
label: 'contract-history-tab',
button: true,
child: Text('历史结果'),
),
),
],
),
],
@@ -947,81 +981,89 @@ class _TemplateListTile extends StatelessWidget {
? template.description
: template.previewExcerpt;
return Container(
decoration: BoxDecoration(
color: BrandAssets.surface,
borderRadius: BorderRadius.circular(BrandAssets.radius),
border: Border.all(color: BrandAssets.line),
boxShadow: const <BoxShadow>[BrandAssets.cardShadow],
),
child: ListTile(
onTap: onTap,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: BrandAssets.surfaceTinted,
borderRadius: BorderRadius.circular(BrandAssets.radius),
return Semantics(
label: 'open-contract-template-${template.id}',
button: true,
child: Container(
decoration: BoxDecoration(
color: BrandAssets.surface,
borderRadius: BorderRadius.circular(BrandAssets.radius),
border: Border.all(color: BrandAssets.line),
boxShadow: const <BoxShadow>[BrandAssets.cardShadow],
),
child: ListTile(
onTap: onTap,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: BrandAssets.surfaceTinted,
borderRadius: BorderRadius.circular(BrandAssets.radius),
),
child: const Icon(Icons.description_outlined,
color: BrandAssets.brand),
),
child:
const Icon(Icons.description_outlined, color: BrandAssets.brand),
),
title: Row(
children: [
Expanded(
child: Text(
template.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
title: Row(
children: [
Expanded(
child: Text(
template.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: onTap,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
minimumSize: const Size(0, 34),
),
child: const Text('使用'),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MetaChip(
label: template.contractType,
color: BrandAssets.accentCyan),
_MetaChip(
label: formatTemplateCategory(template.templateCategory),
color: BrandAssets.accentAmber),
_MetaChip(
label: formatIndustryCategory(template.industryCategory),
color: BrandAssets.primaryDark),
_MetaChip(
label: '${template.variables.length} 个变量',
color: BrandAssets.brand),
],
),
if (summary.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
summary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
const SizedBox(width: 8),
Semantics(
label: 'use-contract-template-${template.id}',
button: true,
child: FilledButton.tonal(
onPressed: onTap,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
minimumSize: const Size(0, 34),
),
child: const Text('使用'),
),
),
],
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MetaChip(
label: template.contractType,
color: BrandAssets.accentCyan),
_MetaChip(
label: formatTemplateCategory(template.templateCategory),
color: BrandAssets.accentAmber),
_MetaChip(
label: formatIndustryCategory(template.industryCategory),
color: BrandAssets.primaryDark),
_MetaChip(
label: '${template.variables.length} 个变量',
color: BrandAssets.brand),
],
),
if (summary.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
summary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
);
@@ -1126,26 +1168,34 @@ class _GeneratedContractResultTile extends StatelessWidget {
style: Theme.of(context).textTheme.bodySmall,
),
),
TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
minimumSize: const Size(0, 32),
Semantics(
label: 'open-generated-contract-${item.id}',
button: true,
child: TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 6),
minimumSize: const Size(0, 32),
),
child: const Text('结果'),
),
child: const Text('结果'),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: onEdit,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
minimumSize: const Size(0, 34),
Semantics(
label: 'continue-generated-contract-${item.id}',
button: true,
child: FilledButton.tonal(
onPressed: onEdit,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
minimumSize: const Size(0, 34),
),
child: const Text('继续编辑'),
),
child: const Text('继续编辑'),
),
],
),
File diff suppressed because it is too large Load Diff
+115
View File
@@ -8,6 +8,7 @@ import 'package:http_parser/http_parser.dart';
import '../../app/config/app_config.dart';
import '../../features/chat/domain/chat_models.dart';
import '../../features/contracts/domain/contract_models.dart';
import '../../features/contracts/domain/contract_version_models.dart';
import '../../features/auth/domain/auth_session.dart';
import '../../features/documents/domain/legal_document_models.dart';
import '../../features/profile/domain/admin_snapshot.dart';
@@ -745,6 +746,120 @@ class ApiClient {
return response.bodyBytes;
}
Future<GeneratedContractVersionListResponse> listGeneratedContractVersions(
String accessToken,
String contractId,
) async {
final response = await http.get(
Uri.parse('$contractsUrl/$contractId/versions'),
headers: {'Authorization': 'Bearer $accessToken'},
);
final body = _decodeBody(response.body);
if (response.statusCode != 200) {
throw ApiException(_extractMessage(body, fallback: '获取合同版本失败'),
statusCode: response.statusCode);
}
final data = _extractMap(body['data'], fallback: '合同版本响应缺少 data');
return GeneratedContractVersionListResponse.fromJson(data);
}
Future<GeneratedContractVersionItem> createGeneratedContractVersion(
String accessToken,
String contractId, {
required String content,
String changeType = 'editor_save',
String? changeSummary,
String? sourceVersionId,
String? originalText,
String? replacementText,
String locateStatus = 'applied',
}) async {
final payload = <String, dynamic>{
'content': content,
'change_type': changeType,
'locate_status': locateStatus,
if (changeSummary != null && changeSummary.trim().isNotEmpty)
'change_summary': changeSummary.trim(),
if (sourceVersionId != null && sourceVersionId.trim().isNotEmpty)
'source_version_id': sourceVersionId.trim(),
if (originalText != null && originalText.trim().isNotEmpty)
'original_text': originalText.trim(),
if (replacementText != null && replacementText.trim().isNotEmpty)
'replacement_text': replacementText.trim(),
};
final response = await http.post(
Uri.parse('$contractsUrl/$contractId/versions'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: jsonEncode(payload),
);
final body = _decodeBody(response.body);
if (response.statusCode != 200) {
throw ApiException(_extractMessage(body, fallback: '保存合同版本失败'),
statusCode: response.statusCode);
}
final data = _extractMap(body['data'], fallback: '保存合同版本响应缺少 data');
return GeneratedContractVersionItem.fromJson(data, contractId: contractId);
}
Future<GeneratedContractVersionDiffResult> diffGeneratedContractVersions(
String accessToken,
String contractId, {
String? fromVersionId,
String? toVersionId,
}) async {
final queryParameters = <String, String>{};
if (fromVersionId != null && fromVersionId.trim().isNotEmpty) {
queryParameters['from_version_id'] = fromVersionId.trim();
}
if (toVersionId != null && toVersionId.trim().isNotEmpty) {
queryParameters['to_version_id'] = toVersionId.trim();
}
final uri = Uri.parse('$contractsUrl/$contractId/versions/diff').replace(
queryParameters: queryParameters.isEmpty ? null : queryParameters);
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $accessToken'},
);
final body = _decodeBody(response.body);
if (response.statusCode != 200) {
throw ApiException(_extractMessage(body, fallback: '获取合同版本差异失败'),
statusCode: response.statusCode);
}
final data = _extractMap(body['data'], fallback: '合同版本差异响应缺少 data');
return GeneratedContractVersionDiffResult.fromJson(data);
}
Future<Uint8List> downloadGeneratedContractVersionFile(
String accessToken,
String contractId,
String versionId, {
String exportFormat = 'docx',
}) async {
final response = await http.get(
Uri.parse('$contractsUrl/$contractId/versions/$versionId/download')
.replace(queryParameters: {'export_format': exportFormat}),
headers: {'Authorization': 'Bearer $accessToken'},
);
if (response.statusCode != 200) {
throw ApiException(
_extractDownloadErrorMessage(response),
statusCode: response.statusCode,
);
}
if (response.bodyBytes.isEmpty) {
throw const ApiException('合同版本文件为空,请稍后重试');
}
return response.bodyBytes;
}
Future<Map<String, dynamic>> submitReview({
required String accessToken,
required ReviewDraft draft,
@@ -0,0 +1,196 @@
import 'dart:typed_data';
import 'package:ai_contract_mobile/features/auth/domain/auth_session.dart';
import 'package:ai_contract_mobile/features/contracts/application/generated_contract_version_controller.dart';
import 'package:ai_contract_mobile/features/contracts/domain/contract_version_models.dart';
import 'package:ai_contract_mobile/shared/network/api_client.dart';
import 'package:ai_contract_mobile/shared/storage/session_store.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(() async {
SessionStore.useStorageDriverForTesting(InMemorySessionStorageDriver());
await SessionStore.save(_session);
});
tearDown(() async {
await SessionStore.clear();
SessionStore.resetStorageDriverForTesting();
});
test('loads generated contract versions and diff', () async {
final apiClient = _VersionApiClient();
final controller = GeneratedContractVersionController(apiClient: apiClient);
addTearDown(controller.dispose);
await controller.loadVersions('contract-1');
await controller.loadDiff('contract-1', toVersionId: 'gcv-2');
expect(controller.errorMessage.value, isNull);
expect(controller.versions.value?.latestVersion.versionLabel, 'v2');
expect(controller.diff.value?.summary.added, 1);
expect(apiClient.loadedContractId, 'contract-1');
expect(apiClient.diffToVersionId, 'gcv-2');
});
test('saves edited contract content as a new version', () async {
final apiClient = _VersionApiClient();
final controller = GeneratedContractVersionController(apiClient: apiClient);
addTearDown(controller.dispose);
final result = await controller.saveVersion(
contractId: 'contract-1',
content: 'Generated NDA content.\nAcceptance clause added.',
changeSummary: '补充验收条款',
sourceVersionId: 'base',
);
expect(result.versionLabel, 'v2');
expect(apiClient.savedContent, contains('Acceptance clause added'));
expect(apiClient.savedSummary, '补充验收条款');
expect(controller.errorMessage.value, isNull);
});
}
class _VersionApiClient extends ApiClient {
String? loadedContractId;
String? diffToVersionId;
String? savedContent;
String? savedSummary;
@override
Future<GeneratedContractVersionListResponse> listGeneratedContractVersions(
String accessToken,
String contractId,
) async {
loadedContractId = contractId;
return _versionList;
}
@override
Future<GeneratedContractVersionDiffResult> diffGeneratedContractVersions(
String accessToken,
String contractId, {
String? fromVersionId,
String? toVersionId,
}) async {
diffToVersionId = toVersionId;
return _versionDiff;
}
@override
Future<GeneratedContractVersionItem> createGeneratedContractVersion(
String accessToken,
String contractId, {
required String content,
String changeType = 'editor_save',
String? changeSummary,
String? sourceVersionId,
String? originalText,
String? replacementText,
String locateStatus = 'applied',
}) async {
savedContent = content;
savedSummary = changeSummary;
return _versionList.items.first;
}
@override
Future<Uint8List> downloadGeneratedContractVersionFile(
String accessToken,
String contractId,
String versionId, {
String exportFormat = 'docx',
}) async {
return Uint8List.fromList(<int>[1, 2, 3]);
}
}
const _session = AuthSession(
accessToken: 'token',
userId: 'user-1',
tenantId: 'tenant-1',
username: 'member@example.com',
userDisplayName: 'Member',
role: 'member',
status: 'active',
);
final _versionList = GeneratedContractVersionListResponse(
contractId: 'contract-1',
baseVersion: GeneratedContractVersionItem(
id: 'base',
contractId: 'contract-1',
versionNumber: 1,
versionLabel: 'v1',
sourceVersionId: null,
changeType: 'generated',
changeSummary: '原始生成版本',
originalText: '',
replacementText: '',
content: 'Generated NDA content.',
locateStatus: 'applied',
createdBy: 'user-1',
createdAt: '2026-05-26T01:00:00Z',
detailUrl: '',
),
items: <GeneratedContractVersionItem>[
GeneratedContractVersionItem(
id: 'gcv-2',
contractId: 'contract-1',
versionNumber: 2,
versionLabel: 'v2',
sourceVersionId: 'base',
changeType: 'editor_save',
changeSummary: '补充验收条款',
originalText: '',
replacementText: 'Acceptance clause added.',
content: 'Generated NDA content.\nAcceptance clause added.',
locateStatus: 'applied',
createdBy: 'user-1',
createdAt: '2026-05-26T02:00:00Z',
detailUrl: '/contracts/contract-1?version=gcv-2',
),
],
total: 1,
);
final _versionDiff = GeneratedContractVersionDiffResult(
contractId: 'contract-1',
fromVersion: GeneratedContractVersionSnapshot(
id: 'base',
versionNumber: 1,
versionLabel: 'v1',
sourceVersionId: null,
changeType: 'generated',
changeSummary: '原始生成版本',
createdAt: '2026-05-26T01:00:00Z',
),
toVersion: GeneratedContractVersionSnapshot(
id: 'gcv-2',
versionNumber: 2,
versionLabel: 'v2',
sourceVersionId: 'base',
changeType: 'editor_save',
changeSummary: '补充验收条款',
createdAt: '2026-05-26T02:00:00Z',
),
summary: GeneratedContractVersionDiffSummary(
added: 1,
removed: 0,
modified: 0,
unchanged: 1,
changeCount: 1,
fromParagraphCount: 1,
toParagraphCount: 2,
),
items: <GeneratedContractVersionDiffItem>[
GeneratedContractVersionDiffItem(
changeType: 'added',
oldParagraphIndex: null,
newParagraphIndex: 2,
oldText: '',
newText: 'Acceptance clause added.',
),
],
);
@@ -0,0 +1,86 @@
import 'package:ai_contract_mobile/features/contracts/domain/contract_version_models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('parses generated contract version list and diff payloads', () {
final list =
GeneratedContractVersionListResponse.fromJson(<String, dynamic>{
'contract_id': 'contract-1',
'base_version': <String, dynamic>{
'version_number': 1,
'version_label': 'v1',
'content': 'Base content',
'created_at': '2026-05-26T01:00:00Z',
},
'items': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gcv-2',
'contract_id': 'contract-1',
'version_number': 2,
'version_label': 'v2',
'source_version_id': 'base',
'change_type': 'editor_save',
'change_summary': '补充验收条款',
'content': 'Edited content',
'locate_status': 'applied',
'created_by': 'user-1',
'created_at': '2026-05-26T02:00:00Z',
'detail_url': '/contracts/contract-1?version=gcv-2',
},
],
'total': 1,
});
expect(list.contractId, 'contract-1');
expect(list.baseVersion.versionLabel, 'v1');
expect(list.items, hasLength(1));
expect(list.items.first.id, 'gcv-2');
expect(list.items.first.changeType, 'editor_save');
final diff = GeneratedContractVersionDiffResult.fromJson(<String, dynamic>{
'contract_id': 'contract-1',
'from_version': <String, dynamic>{
'id': 'base',
'version_number': 1,
'version_label': 'v1',
'change_type': 'generated',
'change_summary': '原始生成版本',
'created_at': '2026-05-26T01:00:00Z',
},
'to_version': <String, dynamic>{
'id': 'gcv-2',
'version_number': 2,
'version_label': 'v2',
'source_version_id': 'base',
'change_type': 'editor_save',
'change_summary': '补充验收条款',
'created_at': '2026-05-26T02:00:00Z',
},
'summary': <String, dynamic>{
'added': 1,
'removed': 0,
'modified': 1,
'unchanged': 2,
'change_count': 2,
'from_paragraph_count': 3,
'to_paragraph_count': 4,
},
'items': <Map<String, dynamic>>[
<String, dynamic>{
'change_type': 'added',
'old_paragraph_index': null,
'new_paragraph_index': 4,
'old_text': '',
'new_text': '新补充条款',
},
],
});
expect(diff.contractId, 'contract-1');
expect(diff.fromVersion.versionLabel, 'v1');
expect(diff.toVersion.versionLabel, 'v2');
expect(diff.summary.changeCount, 2);
expect(diff.items, hasLength(1));
expect(diff.items.first.changeType, 'added');
});
}
@@ -0,0 +1,480 @@
import 'dart:typed_data';
import 'package:ai_contract_mobile/features/auth/domain/auth_session.dart';
import 'package:ai_contract_mobile/features/contracts/application/contract_template_controller.dart';
import 'package:ai_contract_mobile/features/contracts/application/generated_contract_history_controller.dart';
import 'package:ai_contract_mobile/features/contracts/application/generated_contract_version_controller.dart';
import 'package:ai_contract_mobile/features/contracts/domain/contract_models.dart';
import 'package:ai_contract_mobile/features/contracts/domain/contract_version_models.dart';
import 'package:ai_contract_mobile/features/contracts/presentation/contract_builder_screen.dart';
import 'package:ai_contract_mobile/features/contracts/presentation/contract_template_list_screen.dart';
import 'package:ai_contract_mobile/features/contracts/presentation/generated_contract_result_screen.dart';
import 'package:ai_contract_mobile/shared/network/api_client.dart';
import 'package:ai_contract_mobile/shared/storage/session_store.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SessionStore.useStorageDriverForTesting(InMemorySessionStorageDriver());
await SessionStore.save(_session);
});
tearDown(() async {
await SessionStore.clear();
SessionStore.resetStorageDriverForTesting();
});
testWidgets('contract workspace exposes stable automation labels',
(tester) async {
final semantics = tester.ensureSemantics();
final apiClient = _ContractsPresentationApiClient();
final templateController = ContractTemplateController(apiClient: apiClient);
final historyController =
GeneratedContractHistoryController(apiClient: apiClient);
addTearDown(templateController.dispose);
addTearDown(historyController.dispose);
try {
await tester.pumpWidget(
MaterialApp(
home: ContractTemplateListScreen(
controller: templateController,
historyController: historyController,
),
),
);
await tester.pumpAndSettle();
expect(find.text('模板'), findsOneWidget);
expect(find.text('历史结果'), findsOneWidget);
expect(find.bySemanticsLabel('open-contract-template-tpl-nda'),
findsOneWidget);
expect(find.bySemanticsLabel('use-contract-template-tpl-nda'),
findsOneWidget);
await tester.tap(find.text('历史结果'));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('open-generated-contract-contract-1'),
findsOneWidget);
expect(find.bySemanticsLabel('continue-generated-contract-contract-1'),
findsOneWidget);
} finally {
semantics.dispose();
}
});
testWidgets('contract builder exposes stable automation labels',
(tester) async {
final semantics = tester.ensureSemantics();
try {
await tester.pumpWidget(
const MaterialApp(
home: ContractBuilderScreen(template: _template),
),
);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('contract-builder-tpl-nda'), findsOneWidget);
expect(find.bySemanticsLabel('preview-contract'), findsOneWidget);
expect(find.bySemanticsLabel('generate-contract'), findsOneWidget);
} finally {
semantics.dispose();
}
});
testWidgets('generated contract result exposes stable file action labels',
(tester) async {
final semantics = tester.ensureSemantics();
final apiClient = _ContractsPresentationApiClient();
try {
await tester.pumpWidget(
MaterialApp(
home: GeneratedContractResultScreen(
contract: _generatedContract,
versionController: GeneratedContractVersionController(
apiClient: apiClient,
),
),
),
);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('generated-contract-result-contract-1'),
findsOneWidget);
expect(find.bySemanticsLabel('continue-generated-contract-editing'),
findsOneWidget);
expect(
find.bySemanticsLabel('download-generated-contract'), findsOneWidget);
expect(find.bySemanticsLabel('open-generated-contract-file'),
findsOneWidget);
expect(find.bySemanticsLabel('share-generated-contract'), findsOneWidget);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -720),
);
await tester.pumpAndSettle();
expect(find.text('版本链'), findsOneWidget);
expect(find.bySemanticsLabel('edit-generated-contract-version'),
findsOneWidget);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -360),
);
await tester.pumpAndSettle();
expect(
find.bySemanticsLabel('diff-contract-version-gcv-2'), findsOneWidget);
expect(find.bySemanticsLabel('download-contract-version-gcv-2'),
findsOneWidget);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -320),
);
await tester.pumpAndSettle();
expect(find.text('v1 -> v2'), findsOneWidget);
} finally {
semantics.dispose();
}
});
testWidgets('generated contract version editor saves through stable labels',
(tester) async {
final semantics = tester.ensureSemantics();
final apiClient = _ContractsPresentationApiClient();
final versionController = GeneratedContractVersionController(
apiClient: apiClient,
);
addTearDown(versionController.dispose);
try {
await tester.pumpWidget(
MaterialApp(
home: GeneratedContractResultScreen(
contract: _generatedContract,
versionController: versionController,
),
),
);
await tester.pumpAndSettle();
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -720),
);
await tester.pumpAndSettle();
await tester
.tap(find.bySemanticsLabel('edit-generated-contract-version'));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('contract-version-editor'), findsOneWidget);
expect(find.bySemanticsLabel('contract-version-change-summary-input'),
findsOneWidget);
expect(find.bySemanticsLabel('contract-version-content-input'),
findsOneWidget);
final summaryField = find.descendant(
of: find.bySemanticsLabel('contract-version-change-summary-input'),
matching: find.byType(TextField),
);
await tester.enterText(summaryField, 'Mobile version smoke summary');
final contentField = find.descendant(
of: find.bySemanticsLabel('contract-version-content-input'),
matching: find.byType(TextField),
);
await tester.ensureVisible(contentField);
await tester.enterText(
contentField,
'Generated NDA content.\nMobile version smoke clause.',
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
final saveButton = find.descendant(
of: find.bySemanticsLabel('save-contract-version'),
matching: find.byType(FilledButton),
);
await tester.scrollUntilVisible(
saveButton,
200,
scrollable: find.byType(Scrollable).last,
);
await tester.tapAt(tester.getCenter(saveButton));
await tester.pumpAndSettle();
expect(apiClient.savedContent, contains('Mobile version smoke clause'));
expect(apiClient.savedSummary, 'Mobile version smoke summary');
expect(find.bySemanticsLabel('generated-contract-result-contract-1'),
findsOneWidget);
} finally {
semantics.dispose();
}
});
}
class _ContractsPresentationApiClient extends ApiClient {
String? savedContent;
String? savedSummary;
@override
Future<List<ContractTemplateItem>> listContractTemplates(
String accessToken, {
String? contractType,
String? documentTypeCode,
String? templateCategory,
String? industryCategory,
}) async {
return const <ContractTemplateItem>[_template];
}
@override
Future<GeneratedContractListResponse> listGeneratedContracts(
String accessToken, {
int page = 1,
int pageSize = 10,
String? contractType,
String? keyword,
}) async {
return const GeneratedContractListResponse(
items: <GeneratedContractListItem>[_generatedListItem],
total: 1,
page: 1,
pageSize: 10,
);
}
@override
Future<GeneratedContractItem> getGeneratedContract(
String accessToken,
String contractId,
) async {
return _generatedContract;
}
@override
Future<GeneratedContractVersionListResponse> listGeneratedContractVersions(
String accessToken,
String contractId,
) async {
return savedContent == null
? _generatedVersionList
: _generatedVersionListAfterSave;
}
@override
Future<GeneratedContractVersionDiffResult> diffGeneratedContractVersions(
String accessToken,
String contractId, {
String? fromVersionId,
String? toVersionId,
}) async {
return _generatedVersionDiff;
}
@override
Future<GeneratedContractVersionItem> createGeneratedContractVersion(
String accessToken,
String contractId, {
required String content,
String changeType = 'editor_save',
String? changeSummary,
String? sourceVersionId,
String? originalText,
String? replacementText,
String locateStatus = 'applied',
}) async {
savedContent = content;
savedSummary = changeSummary;
return _generatedSavedVersion;
}
@override
Future<Uint8List> downloadGeneratedContractVersionFile(
String accessToken,
String contractId,
String versionId, {
String exportFormat = 'docx',
}) async {
return Uint8List.fromList(<int>[1, 2, 3]);
}
}
const _session = AuthSession(
accessToken: 'token',
userId: 'user-1',
tenantId: 'tenant-1',
username: 'member@example.com',
userDisplayName: 'Member',
role: 'member',
status: 'active',
);
const _template = ContractTemplateItem(
id: 'tpl-nda',
name: 'NDA Template',
contractType: 'nda',
description: 'Confidentiality template.',
variables: <ContractTemplateVariable>[
ContractTemplateVariable(
name: 'party_a',
label: '甲方',
required: true,
defaultValue: '甲方公司',
description: '请输入甲方名称',
),
],
previewExcerpt: 'NDA preview',
templateCategory: 'commercial-compliance',
industryCategory: 'general-enterprise',
);
const _generatedListItem = GeneratedContractListItem(
id: 'contract-1',
templateName: 'NDA Template',
contractType: 'nda',
fileId: 'file-1',
generatedAt: '2026-05-26T01:00:00Z',
exportFormat: 'docx',
fileName: 'nda.docx',
fileSize: 2048,
generatedByName: 'Member',
templateCategory: 'commercial-compliance',
industryCategory: 'general-enterprise',
);
const _generatedContract = GeneratedContractItem(
id: 'contract-1',
templateId: 'tpl-nda',
templateName: 'NDA Template',
contractType: 'nda',
documentTypeCode: '',
generatedContent: 'Generated NDA content.',
variableValues: <String, String>{'party_a': '甲方公司'},
missingFields: <GeneratedContractMissingField>[],
aiHints: <GeneratedContractAiHint>[],
relatedLegalArticles: <GeneratedContractLegalArticle>[],
generatedAt: '2026-05-26T01:00:00Z',
exportFormat: 'docx',
fileName: 'nda.docx',
fileSize: 2048,
fileContentBase64: 'ZHVtbXk=',
previewUrl: '',
downloadUrl: '',
templateCategory: 'commercial-compliance',
industryCategory: 'general-enterprise',
);
final _generatedVersionList = GeneratedContractVersionListResponse(
contractId: 'contract-1',
baseVersion: GeneratedContractVersionItem(
id: 'base',
contractId: 'contract-1',
versionNumber: 1,
versionLabel: 'v1',
sourceVersionId: null,
changeType: 'generated',
changeSummary: '原始生成版本',
originalText: '',
replacementText: '',
content: 'Generated NDA content.',
locateStatus: 'applied',
createdBy: 'user-1',
createdAt: '2026-05-26T01:00:00Z',
detailUrl: '',
),
items: <GeneratedContractVersionItem>[
GeneratedContractVersionItem(
id: 'gcv-2',
contractId: 'contract-1',
versionNumber: 2,
versionLabel: 'v2',
sourceVersionId: 'base',
changeType: 'editor_save',
changeSummary: '补充验收条款',
originalText: 'Acceptance clause',
replacementText: 'Acceptance clause added.',
content: 'Generated NDA content.\nAcceptance clause added.',
locateStatus: 'applied',
createdBy: 'user-1',
createdAt: '2026-05-26T02:00:00Z',
detailUrl: '/contracts/contract-1?version=gcv-2',
),
],
total: 1,
);
final _generatedSavedVersion = GeneratedContractVersionItem(
id: 'gcv-3',
contractId: 'contract-1',
versionNumber: 3,
versionLabel: 'v3',
sourceVersionId: 'gcv-2',
changeType: 'editor_save',
changeSummary: 'Mobile version smoke summary',
originalText: '',
replacementText: 'Mobile version smoke clause.',
content: 'Generated NDA content.\nMobile version smoke clause.',
locateStatus: 'applied',
createdBy: 'user-1',
createdAt: '2026-05-26T03:00:00Z',
detailUrl: '/contracts/contract-1?version=gcv-3',
);
final _generatedVersionListAfterSave = GeneratedContractVersionListResponse(
contractId: 'contract-1',
baseVersion: _generatedVersionList.baseVersion,
items: <GeneratedContractVersionItem>[
_generatedSavedVersion,
..._generatedVersionList.items,
],
total: 2,
);
final _generatedVersionDiff = GeneratedContractVersionDiffResult(
contractId: 'contract-1',
fromVersion: GeneratedContractVersionSnapshot(
id: 'base',
versionNumber: 1,
versionLabel: 'v1',
sourceVersionId: null,
changeType: 'generated',
changeSummary: '原始生成版本',
createdAt: '2026-05-26T01:00:00Z',
),
toVersion: GeneratedContractVersionSnapshot(
id: 'gcv-2',
versionNumber: 2,
versionLabel: 'v2',
sourceVersionId: 'base',
changeType: 'editor_save',
changeSummary: '补充验收条款',
createdAt: '2026-05-26T02:00:00Z',
),
summary: GeneratedContractVersionDiffSummary(
added: 1,
removed: 0,
modified: 0,
unchanged: 1,
changeCount: 1,
fromParagraphCount: 1,
toParagraphCount: 2,
),
items: <GeneratedContractVersionDiffItem>[
GeneratedContractVersionDiffItem(
changeType: 'added',
oldParagraphIndex: null,
newParagraphIndex: 2,
oldText: '',
newText: 'Acceptance clause added.',
),
],
);
+536 -3
View File
@@ -6,7 +6,7 @@
[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")]
[ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "contract-result-actions", "contract-version-chain", "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/",
@@ -599,6 +599,439 @@ function Invoke-ContractTaxonomyProof {
}
}
function Invoke-ContractResultActionsProof {
param(
[string]$Serial,
[string]$ArtifactDir,
[string]$PackageName
)
Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 3 -Label "AI工具" -ArtifactLabel "contract-result-ai-tools-tab" -FallbackX 540 -FallbackY 2057
Start-Sleep -Seconds 2
$toolsState = Get-SmokeState -Serial $Serial -Label "contract-result-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName
$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-result-workspace" -ArtifactDir $ArtifactDir -PackageName $PackageName
$historyTab = Get-ContentDescBounds -UiDump $workspaceState.UiDump |
Where-Object { $_.ContentDesc -match '^contract-history-tab' } |
Select-Object -First 1
if (-not $historyTab) {
$historyTab = Get-TextBounds -UiDump $workspaceState.UiDump |
Where-Object { $_.Text -eq "历史结果" } |
Select-Object -First 1
}
if ($historyTab) {
$historyCenter = Get-SafeTapPoint -Bounds $historyTab
Invoke-AdbTap -Serial $Serial -X $historyCenter.X -Y $historyCenter.Y
} else {
Invoke-AdbTap -Serial $Serial -X 688 -Y 520
}
Start-Sleep -Seconds 2
$historyState = Get-SmokeState -Serial $Serial -Label "contract-result-history" -ArtifactDir $ArtifactDir -PackageName $PackageName
$generatedCard = $null
$generatedResultButton = $null
for ($attempt = 1; $attempt -le 6; $attempt++) {
$generatedResultButton = Get-ContentDescBounds -UiDump $historyState.UiDump |
Where-Object { $_.ContentDesc -match '^open-generated-contract-' } |
Select-Object -First 1
if ($generatedResultButton) {
break
}
$generatedTextButton = Get-TextBounds -UiDump $historyState.UiDump |
Where-Object { $_.Text -eq '结果' -and $_.Top -gt 500 } |
Select-Object -First 1
if ($generatedTextButton) {
$generatedCard = $generatedTextButton
break
}
Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1760 -EndX 540 -EndY 760 -DurationMs 320
Start-Sleep -Seconds 2
$historyState = Get-SmokeState -Serial $Serial -Label "contract-result-history-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
}
if ($generatedResultButton) {
$generatedCard = $generatedResultButton
}
if ($generatedCard) {
$generatedCenter = Get-SafeTapPoint -Bounds $generatedCard
Invoke-AdbTap -Serial $Serial -X $generatedCenter.X -Y $generatedCenter.Y
} else {
Invoke-AdbTap -Serial $Serial -X 978 -Y 1598
}
Start-Sleep -Seconds 3
$resultState = Get-SmokeState -Serial $Serial -Label "contract-result-detail" -ArtifactDir $ArtifactDir -PackageName $PackageName
$buttonState = $resultState
$downloadButton = $null
$shareButton = $null
$continueButton = $null
for ($attempt = 1; $attempt -le 8; $attempt++) {
$downloadButton = Get-ContentDescBounds -UiDump $buttonState.UiDump |
Where-Object { $_.ContentDesc -match '^download-generated-contract' } |
Select-Object -First 1
if (-not $downloadButton) {
$downloadButton = Get-TextBounds -UiDump $buttonState.UiDump |
Where-Object { $_.Text -eq "下载合同" } |
Select-Object -First 1
}
$shareButton = Get-ContentDescBounds -UiDump $buttonState.UiDump |
Where-Object { $_.ContentDesc -match '^share-generated-contract' } |
Select-Object -First 1
if (-not $shareButton) {
$shareButton = Get-TextBounds -UiDump $buttonState.UiDump |
Where-Object { $_.Text -eq "分享合同" } |
Select-Object -First 1
}
$continueButton = Get-ContentDescBounds -UiDump $buttonState.UiDump |
Where-Object { $_.ContentDesc -match '^continue-generated-contract-editing' } |
Select-Object -First 1
if (-not $continueButton) {
$continueButton = Get-TextBounds -UiDump $buttonState.UiDump |
Where-Object { $_.Text -eq "继续编辑" } |
Select-Object -First 1
}
if ($downloadButton -or $shareButton -or $continueButton) {
if ($downloadButton -and $shareButton -and $continueButton) {
break
}
}
Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1830 -EndX 540 -EndY 520 -DurationMs 420
Start-Sleep -Seconds 1
$buttonState = Get-SmokeState -Serial $Serial -Label "contract-result-detail-scroll-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
}
$resultState = $buttonState
$downloadOk = $false
$shareOk = $false
$continueOk = $null -ne $continueButton
if ($downloadButton) {
$downloadCenter = Get-SafeTapPoint -Bounds $downloadButton
Invoke-AdbTap -Serial $Serial -X $downloadCenter.X -Y $downloadCenter.Y
Start-Sleep -Seconds 2
$downloadState = Get-SmokeState -Serial $Serial -Label "contract-result-download" -ArtifactDir $ArtifactDir -PackageName $PackageName
$downloadOk = $downloadState.UiDump -match 'generated-contract-local-file-ready' -or $downloadState.UiDump -match '本地文件'
}
if ($shareButton) {
$shareCenter = Get-SafeTapPoint -Bounds $shareButton
Invoke-AdbTap -Serial $Serial -X $shareCenter.X -Y $shareCenter.Y
Start-Sleep -Seconds 2
$chooserState = $null
for ($chooserAttempt = 1; $chooserAttempt -le 3; $chooserAttempt++) {
$chooserState = Get-SmokeState -Serial $Serial -Label "contract-result-share-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
}
$shareOk = $chooserState.TopText -match 'ChooserActivity' -or
$chooserState.UiDump -match 'android\.intent\.action\.CHOOSER' -or
$chooserState.UiDump -match '分享'
}
return [pscustomobject]@{
ToolsSurface = $toolsState.Surface
WorkspaceSurface = $workspaceState.Surface
HistorySurface = $historyState.Surface
DetailSurface = $resultState.Surface
DownloadButtonFound = ($null -ne $downloadButton)
ShareButtonFound = ($null -ne $shareButton)
ContinueButtonFound = $continueOk
DownloadOk = $downloadOk
ShareOk = $shareOk
SmokeOk = ($downloadOk -and $shareOk -and $continueOk)
}
}
function Invoke-ContractVersionChainProof {
param(
[string]$Serial,
[string]$ArtifactDir,
[string]$PackageName
)
Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 3 -Label "AI工具" -ArtifactLabel "contract-version-chain-ai-tools-tab" -FallbackX 540 -FallbackY 2057
Start-Sleep -Seconds 2
$toolsState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName
$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-version-chain-workspace" -ArtifactDir $ArtifactDir -PackageName $PackageName
$historyTab = Get-ContentDescBounds -UiDump $workspaceState.UiDump |
Where-Object { $_.ContentDesc -match '^contract-history-tab' } |
Select-Object -First 1
if (-not $historyTab) {
$historyTab = Get-TextBounds -UiDump $workspaceState.UiDump |
Where-Object { $_.Text -eq "历史结果" } |
Select-Object -First 1
}
if ($historyTab) {
$historyCenter = Get-SafeTapPoint -Bounds $historyTab
Invoke-AdbTap -Serial $Serial -X $historyCenter.X -Y $historyCenter.Y
} else {
Invoke-AdbTap -Serial $Serial -X 688 -Y 520
}
Start-Sleep -Seconds 2
$historyState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-history" -ArtifactDir $ArtifactDir -PackageName $PackageName
$generatedCard = $null
$generatedResultButton = $null
for ($attempt = 1; $attempt -le 6; $attempt++) {
$generatedResultButton = Get-ContentDescBounds -UiDump $historyState.UiDump |
Where-Object { $_.ContentDesc -match '^open-generated-contract-' } |
Select-Object -First 1
if ($generatedResultButton) {
break
}
$generatedTextButton = Get-TextBounds -UiDump $historyState.UiDump |
Where-Object { $_.Text -eq '结果' -and $_.Top -gt 500 } |
Select-Object -First 1
if ($generatedTextButton) {
$generatedCard = $generatedTextButton
break
}
Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1760 -EndX 540 -EndY 760 -DurationMs 320
Start-Sleep -Seconds 2
$historyState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-history-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
}
if ($generatedResultButton) {
$generatedCard = $generatedResultButton
}
if ($generatedCard) {
$generatedCenter = Get-SafeTapPoint -Bounds $generatedCard
Invoke-AdbTap -Serial $Serial -X $generatedCenter.X -Y $generatedCenter.Y
} else {
Invoke-AdbTap -Serial $Serial -X 978 -Y 1598
}
Start-Sleep -Seconds 3
$detailState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-detail" -ArtifactDir $ArtifactDir -PackageName $PackageName
$versionState = $detailState
$versionTitleOk = $false
$versionEditorButton = $null
$versionDownloadButton = $null
$versionDiffButton = $null
$visibleLabels = @()
$editorOpened = $false
$saveOk = $false
$createdVersionVisible = $false
$saveMarker = "MobileVersionSmoke_{0}" -f (Get-Date -Format "yyyyMMddHHmmss")
for ($attempt = 1; $attempt -le 8; $attempt++) {
$versionTitleOk = $versionState.UiDump -match 'contract-version-chain' -or $versionState.UiDump -match '版本链'
$versionEditorButton = Get-ContentDescBounds -UiDump $versionState.UiDump |
Where-Object { $_.ContentDesc -match '^edit-generated-contract-version' } |
Select-Object -First 1
if (-not $versionEditorButton) {
$versionEditorButton = Get-TextBounds -UiDump $versionState.UiDump |
Where-Object { $_.Text -eq "编辑并保存新版本" } |
Select-Object -First 1
}
$versionDownloadButton = Get-ContentDescBounds -UiDump $versionState.UiDump |
Where-Object { $_.ContentDesc -match '^download-contract-version-' } |
Select-Object -First 1
if (-not $versionDownloadButton) {
$versionDownloadButton = Get-TextBounds -UiDump $versionState.UiDump |
Where-Object { $_.Text -eq "下载版本" } |
Select-Object -First 1
}
$versionDiffButton = Get-ContentDescBounds -UiDump $versionState.UiDump |
Where-Object { $_.ContentDesc -match '^diff-contract-version-' } |
Select-Object -First 1
if (-not $versionDiffButton) {
$versionDiffButton = Get-TextBounds -UiDump $versionState.UiDump |
Where-Object { $_.Text -eq "查看差异" } |
Select-Object -First 1
}
if ($versionTitleOk) {
$visibleLabels += "version_chain"
}
if ($versionEditorButton) {
$visibleLabels += "editor_entry"
}
if ($versionDownloadButton) {
$visibleLabels += "version_download"
}
if ($versionDiffButton) {
$visibleLabels += "version_diff"
}
if ($versionTitleOk -and $versionEditorButton -and $versionDownloadButton) {
break
}
Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1830 -EndX 540 -EndY 520 -DurationMs 420
Start-Sleep -Seconds 1
$versionState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-scroll-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
}
$visibleLabels = $visibleLabels | Select-Object -Unique
$entryOk = $versionTitleOk -and ($null -ne $versionEditorButton)
$downloadOk = $null -ne $versionDownloadButton
$diffVisible = $null -ne $versionDiffButton
if ($versionEditorButton) {
$editorCenter = Get-SafeTapPoint -Bounds $versionEditorButton
Invoke-AdbTap -Serial $Serial -X $editorCenter.X -Y $editorCenter.Y
Start-Sleep -Seconds 3
$editorState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-editor" -ArtifactDir $ArtifactDir -PackageName $PackageName
$editorOpened = $editorState.UiDump -match 'contract-version-editor'
$summaryInput = Get-ContentDescBounds -UiDump $editorState.UiDump |
Where-Object { $_.ContentDesc -eq "contract-version-change-summary-input" } |
Select-Object -First 1
$contentInput = Get-ContentDescBounds -UiDump $editorState.UiDump |
Where-Object { $_.ContentDesc -eq "contract-version-content-input" } |
Select-Object -First 1
$editInputs = @(Get-EditTextBounds -UiDump $editorState.UiDump)
if (-not $summaryInput -and $editInputs.Count -ge 1) {
$summaryInput = $editInputs[0]
}
if (-not $contentInput -and $editInputs.Count -ge 2) {
$contentInput = $editInputs[$editInputs.Count - 1]
}
if ($contentInput) {
$contentCenter = Get-SafeTapPoint -Bounds $contentInput
Invoke-AdbTap -Serial $Serial -X $contentCenter.X -Y $contentCenter.Y
Start-Sleep -Milliseconds 400
& $script:adb -s $Serial shell input keyevent KEYCODE_MOVE_END | Out-Null
Invoke-AdbTextEntry -Serial $Serial -Value " $saveMarker clause"
& $script:adb -s $Serial shell input keyevent KEYCODE_BACK | Out-Null
Start-Sleep -Seconds 1
}
if ($summaryInput) {
$summaryCenter = Get-SafeTapPoint -Bounds $summaryInput
Invoke-AdbTap -Serial $Serial -X $summaryCenter.X -Y $summaryCenter.Y
Start-Sleep -Milliseconds 400
& $script:adb -s $Serial shell input keyevent KEYCODE_MOVE_END | Out-Null
Invoke-AdbTextEntry -Serial $Serial -Value " $saveMarker"
& $script:adb -s $Serial shell input keyevent KEYCODE_ENTER | Out-Null
Start-Sleep -Seconds 6
$afterEnterSaveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-after-enter-save" -ArtifactDir $ArtifactDir -PackageName $PackageName
$saveOk = ($afterEnterSaveState.UiDump -match 'contract-version-chain') -and
-not ($afterEnterSaveState.UiDump -match 'contract-version-editor')
$createdVersionVisible = $afterEnterSaveState.UiDump -match [regex]::Escape($saveMarker)
}
if (-not $saveOk) {
$saveButton = $null
$saveState = $editorState
for ($saveAttempt = 1; $saveAttempt -le 6; $saveAttempt++) {
$saveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-save-button-$saveAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
$saveButton = Get-ButtonBounds -UiDump $saveState.UiDump |
Where-Object { $_.ContentDesc -eq "保存新版本" } |
Select-Object -First 1
if (-not $saveButton) {
$saveButton = Get-ContentDescBounds -UiDump $saveState.UiDump |
Where-Object { $_.ContentDesc -eq "save-contract-version" } |
Select-Object -First 1
}
if (-not $saveButton) {
$saveButton = Get-TextBounds -UiDump $saveState.UiDump |
Where-Object { $_.Text -eq "保存新版本" } |
Select-Object -First 1
}
if ($saveButton) {
break
}
Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1830 -EndX 540 -EndY 760 -DurationMs 360
Start-Sleep -Seconds 1
}
if ($saveButton -and $editorOpened) {
$saveCenter = Get-SafeTapPoint -Bounds $saveButton
Invoke-AdbTap -Serial $Serial -X $saveCenter.X -Y $saveCenter.Y
Start-Sleep -Seconds 6
$afterSaveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-after-save" -ArtifactDir $ArtifactDir -PackageName $PackageName
$saveOk = ($afterSaveState.UiDump -match 'contract-version-chain') -and
-not ($afterSaveState.UiDump -match 'contract-version-editor')
$createdVersionVisible = $afterSaveState.UiDump -match [regex]::Escape($saveMarker)
if (-not $createdVersionVisible) {
for ($createdAttempt = 1; $createdAttempt -le 4; $createdAttempt++) {
Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1760 -EndX 540 -EndY 760 -DurationMs 320
Start-Sleep -Seconds 1
$createdState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-created-version-$createdAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
if ($createdState.UiDump -match [regex]::Escape($saveMarker)) {
$createdVersionVisible = $true
break
}
}
}
}
}
}
return [pscustomobject]@{
ToolsSurface = $toolsState.Surface
WorkspaceSurface = $workspaceState.Surface
HistorySurface = $historyState.Surface
DetailSurface = $detailState.Surface
EntryOk = $entryOk
VersionDownloadOk = $downloadOk
VersionDiffVisible = $diffVisible
EditorOpened = $editorOpened
VersionSaveOk = $saveOk
CreatedVersionVisible = $createdVersionVisible
SaveMarker = $saveMarker
VisibleLabels = ($visibleLabels -join ",")
SmokeOk = ($entryOk -and $downloadOk -and $saveOk -and $createdVersionVisible)
}
}
function Invoke-DocumentsWorkspaceProof {
param(
[string]$Serial,
@@ -1063,8 +1496,16 @@ try {
} catch {
$connectionError = ($_.Exception.Message -replace '\s+', ' ').Trim()
$connectionError | Set-Content -Path (Join-Path $artifactDir "reconnect-error.txt")
& $adb mdns services 2>&1 | Set-Content -Path (Join-Path $artifactDir "mdns-services.txt")
& $adb devices -l 2>&1 | Set-Content -Path (Join-Path $artifactDir "adb-devices.txt")
try {
(& $adb mdns services 2>&1) | Set-Content -Path (Join-Path $artifactDir "mdns-services.txt")
} catch {
($_.Exception.Message -replace '\s+', ' ').Trim() | Set-Content -Path (Join-Path $artifactDir "mdns-services.txt")
}
try {
(& $adb devices -l 2>&1) | Set-Content -Path (Join-Path $artifactDir "adb-devices.txt")
} catch {
($_.Exception.Message -replace '\s+', ' ').Trim() | Set-Content -Path (Join-Path $artifactDir "adb-devices.txt")
}
$blockedApiStatus = $null
$blockedWebStatus = $null
@@ -1095,6 +1536,14 @@ try {
"TAXONOMY_TARGET_CARD_OK=",
"TAXONOMY_FILTER_ACTIVE_OK=",
"TAXONOMY_VISIBLE_KEYS=",
"CONTRACT_VERSION_CHAIN_PROOF_OK=",
"CONTRACT_VERSION_CHAIN_ENTRY_OK=",
"CONTRACT_VERSION_CHAIN_DOWNLOAD_OK=",
"CONTRACT_VERSION_CHAIN_DIFF_VISIBLE=",
"CONTRACT_VERSION_CHAIN_SAVE_OK=",
"CONTRACT_VERSION_CHAIN_CREATED_VISIBLE=",
"CONTRACT_VERSION_CHAIN_SAVE_MARKER=",
"CONTRACT_VERSION_CHAIN_VISIBLE_LABELS=",
"DOCUMENTS_PROOF_OK=",
"DOCUMENTS_WORKSPACE_READY=",
"DOCUMENTS_TYPE_LIST_OK=",
@@ -1184,6 +1633,18 @@ $taxonomyProofOk = ""
$taxonomyTargetCardOk = ""
$taxonomyFilterActiveOk = ""
$taxonomyVisibleKeys = ""
$contractResultProofOk = ""
$contractResultDownloadOk = ""
$contractResultShareOk = ""
$contractResultContinueOk = ""
$contractVersionChainProofOk = ""
$contractVersionChainEntryOk = ""
$contractVersionChainDownloadOk = ""
$contractVersionChainDiffVisible = ""
$contractVersionChainSaveOk = ""
$contractVersionChainCreatedVisible = ""
$contractVersionChainSaveMarker = ""
$contractVersionChainVisibleLabels = ""
$documentsProofOk = ""
$documentsWorkspaceReady = ""
$documentsTypeListOk = ""
@@ -1286,6 +1747,66 @@ switch ($SmokeDepth) {
$taxonomyVisibleKeys = $taxonomyProof.VisibleKeys
$smokeOk = $taxonomyProof.SmokeOk
}
"contract-result-actions" {
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 "contract-result-actions requires the app to relaunch into the signed-in shell. Current surface: $surface"
}
$contractResultProof = Invoke-ContractResultActionsProof -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName
$contractResultProofOk = $contractResultProof.SmokeOk
$contractResultDownloadOk = $contractResultProof.DownloadOk
$contractResultShareOk = $contractResultProof.ShareOk
$contractResultContinueOk = $contractResultProof.ContinueButtonFound
$smokeOk = $contractResultProof.SmokeOk
}
"contract-version-chain" {
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 "contract-version-chain requires the app to relaunch into the signed-in shell. Current surface: $surface"
}
$contractVersionProof = Invoke-ContractVersionChainProof -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName
$contractVersionChainProofOk = $contractVersionProof.SmokeOk
$contractVersionChainEntryOk = $contractVersionProof.EntryOk
$contractVersionChainDownloadOk = $contractVersionProof.VersionDownloadOk
$contractVersionChainDiffVisible = $contractVersionProof.VersionDiffVisible
$contractVersionChainSaveOk = $contractVersionProof.VersionSaveOk
$contractVersionChainCreatedVisible = $contractVersionProof.CreatedVersionVisible
$contractVersionChainSaveMarker = $contractVersionProof.SaveMarker
$contractVersionChainVisibleLabels = $contractVersionProof.VisibleLabels
$smokeOk = $contractVersionProof.SmokeOk
}
"documents-workspace" {
if ($surface -eq "device_locked") {
$smokeBlocker = "device_locked"
@@ -1423,6 +1944,18 @@ $summaryLines = @(
"TAXONOMY_TARGET_CARD_OK=$taxonomyTargetCardOk",
"TAXONOMY_FILTER_ACTIVE_OK=$taxonomyFilterActiveOk",
"TAXONOMY_VISIBLE_KEYS=$taxonomyVisibleKeys",
"CONTRACT_RESULT_PROOF_OK=$contractResultProofOk",
"CONTRACT_RESULT_DOWNLOAD_OK=$contractResultDownloadOk",
"CONTRACT_RESULT_SHARE_OK=$contractResultShareOk",
"CONTRACT_RESULT_CONTINUE_OK=$contractResultContinueOk",
"CONTRACT_VERSION_CHAIN_PROOF_OK=$contractVersionChainProofOk",
"CONTRACT_VERSION_CHAIN_ENTRY_OK=$contractVersionChainEntryOk",
"CONTRACT_VERSION_CHAIN_DOWNLOAD_OK=$contractVersionChainDownloadOk",
"CONTRACT_VERSION_CHAIN_DIFF_VISIBLE=$contractVersionChainDiffVisible",
"CONTRACT_VERSION_CHAIN_SAVE_OK=$contractVersionChainSaveOk",
"CONTRACT_VERSION_CHAIN_CREATED_VISIBLE=$contractVersionChainCreatedVisible",
"CONTRACT_VERSION_CHAIN_SAVE_MARKER=$contractVersionChainSaveMarker",
"CONTRACT_VERSION_CHAIN_VISIBLE_LABELS=$contractVersionChainVisibleLabels",
"DOCUMENTS_PROOF_OK=$documentsProofOk",
"DOCUMENTS_WORKSPACE_READY=$documentsWorkspaceReady",
"DOCUMENTS_TYPE_LIST_OK=$documentsTypeListOk",
@@ -3,11 +3,13 @@ param(
[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")]
[ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "contract-result-actions", "contract-version-chain", "documents-workspace", "review-legal-document", "business-auxiliary")]
[string[]]$SmokeDepths = @(
"shell-only",
"share-proof",
"contract-taxonomy",
"contract-result-actions",
"contract-version-chain",
"documents-workspace",
"review-legal-document",
"dashboard-review-preview",