@harmonyos-arkts/d2h 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.claude-plugin/marketplace.json +15 -13
  2. package/.claude-plugin/plugin.json +13 -7
  3. package/AGENTS.md +5 -5
  4. package/README.md +212 -212
  5. package/agents/android-to-hmos-00-orchestrator.md +318 -318
  6. package/agents/d2h.md +157 -157
  7. package/dist/bin/install-opencode.js +30 -30
  8. package/docs/environment-check.md +86 -86
  9. package/install-opencode.sh +160 -160
  10. package/opencode/agents.json +16 -16
  11. package/opencode/vscode-d2h-package.json +7 -7
  12. package/opencode/vscode-d2h-plugin.mjs +29 -29
  13. package/package.json +54 -54
  14. package/schemas/android-source-manifest.schema.json +25 -25
  15. package/schemas/checkpoint-provenance.schema.json +67 -67
  16. package/schemas/final-acceptance.schema.json +29 -29
  17. package/schemas/managed-evidence-index.schema.json +15 -15
  18. package/schemas/managed-evidence.schema.json +114 -114
  19. package/schemas/migration-config.schema.json +51 -51
  20. package/schemas/migration-report-index.schema.json +39 -39
  21. package/schemas/migration-report-item.schema.json +51 -51
  22. package/schemas/migration-report-summary.schema.json +25 -25
  23. package/schemas/migration-status.schema.json +202 -202
  24. package/schemas/preflight.schema.json +60 -60
  25. package/schemas/source-order-audit.schema.json +16 -16
  26. package/schemas/source-provenance-event.schema.json +19 -19
  27. package/schemas/spec-app-shard.schema.json +45 -45
  28. package/schemas/spec-fact-corrections-shard.schema.json +13 -13
  29. package/schemas/spec-features-shard.schema.json +43 -43
  30. package/schemas/spec-index.schema.json +26 -26
  31. package/schemas/spec-interactions-shard.schema.json +40 -40
  32. package/schemas/spec-page.schema.json +98 -98
  33. package/schemas/spec-pages-index.schema.json +1 -1
  34. package/schemas/spec-unresolved-shard.schema.json +1 -1
  35. package/schemas/task-envelope.schema.json +109 -109
  36. package/schemas/task-plan.schema.json +341 -341
  37. package/scripts/check-environment.mjs +320 -320
  38. package/skills/android2hmos_resources_convert/SKILL.md +180 -180
  39. package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
  40. package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
  41. package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
  42. package/skills/arkts-app-identity/SKILL.md +238 -238
  43. package/skills/arkts-i18n/SKILL.md +496 -496
  44. package/skills/arkts-i18n/evals/evals.json +84 -84
  45. package/skills/arkts-i18n/references/code-examples.md +302 -302
  46. package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
  47. package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
  48. package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
  49. package/skills/arkts-i18n/references/language-codes.md +104 -104
  50. package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
  51. package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
  52. package/skills/arkts-i18n/references/v1-compat.md +244 -244
  53. package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
  54. package/skills/arkts-icon-sizing/SKILL.md +211 -211
  55. package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
  56. package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
  57. package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
  58. package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
  59. package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
  60. package/skills/d2h-hypium-forge/SKILL.md +309 -309
  61. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
  62. package/skills/d2h-hypium-forge/references/official-api.md +135 -135
  63. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
  64. package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
  65. package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
  66. package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
  67. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
  68. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
  69. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
  70. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
  71. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
  72. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
  73. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
  74. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
  75. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
  76. package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
  77. package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
  78. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
  79. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
  80. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
  81. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
  82. package/skills/d2h-page-develop/SKILL.md +112 -112
  83. package/skills/d2h-page-develop/agents/openai.yaml +7 -7
  84. package/skills/d2h-page-spec-generate/SKILL.md +206 -206
  85. package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
  86. package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
  87. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
  88. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
  89. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
  90. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
  91. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
  92. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
  93. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
  94. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
  95. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
  96. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
  97. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
  98. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
  99. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
  100. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
  101. package/skills/d2h-utg-static-generate/SKILL.md +73 -73
  102. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
  103. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
  104. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
  105. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
  106. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
  107. package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
  108. package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
  109. package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
  110. package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
  111. package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
  112. package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
  113. package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
  114. package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
  115. package/skills/maintaining-migration-report/SKILL.md +144 -144
  116. package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
  117. package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
  118. package/skills/managing-d2h-toolchain/SKILL.md +24 -24
  119. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
  120. package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
  121. package/skills/managing-d2h-toolchain/toolchain.json +16 -16
  122. package/skills/native-library-substitution/SKILL.md +384 -384
  123. package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
  124. package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
  125. package/skills/preparing-migration-workspace/SKILL.md +131 -131
  126. package/skills/reviewing-migration-process/SKILL.md +62 -62
@@ -1,335 +1,335 @@
1
- import copy
2
- import importlib.util
3
- import json
4
- import re
5
- import subprocess
6
- import sys
7
- import tempfile
8
- import unittest
9
- from pathlib import Path
10
-
11
- SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'generate.py'
12
- spec = importlib.util.spec_from_file_location('static_generate', SCRIPT)
13
- module = importlib.util.module_from_spec(spec)
14
- spec.loader.exec_module(module)
15
-
16
-
17
- class StaticTests(unittest.TestCase):
18
- def setUp(self):
19
- self.temp = tempfile.TemporaryDirectory()
20
- self.addCleanup(self.temp.cleanup)
21
- self.root = Path(self.temp.name) / 'source'
22
- self.root.mkdir()
23
- (self.root / 'Screen.kt').write_text('fun home() {}\nfun detail() {}\nfun dialog() {}\n', encoding='utf-8')
24
- self.ref = {'file': 'Screen.kt', 'line': 1}
25
- self.graph = {'schemaVersion': 1, 'platform': 'android', 'app': {'name': 'Demo', 'packageName': 'demo'},
26
- 'nodes': [{'id': identity, 'kind': 'Screen', 'name': 'SameName', 'platform': 'android',
27
- 'subtype': kind, 'platformRef': self.ref}
28
- for identity, kind in [('home', 'activity'), ('detail', 'compose'), ('dialog', 'dialog'), ('fragment', 'fragment')]],
29
- 'edges': [{'id': identity, 'kind': 'navigates_to', 'from': source, 'to': target,
30
- 'attrs': {'evidence': [self.ref]}, 'confidence': 1}
31
- for identity, source, target in [('nav1', 'home', 'detail'), ('nav2', 'home', 'detail'),
32
- ('back', 'detail', 'home'), ('external', 'home', 'outside')]],
33
- 'entryPoints': [{'id': 'launch', 'kind': 'app-launch', 'sourceRef': 'home', 'resolution': 'resolved'},
34
- {'id': 'click', 'kind': 'ui-action', 'sourceRef': 'button', 'ownerRef': 'home', 'resolution': 'resolved'}],
35
- 'uiComponents': {'nodes': [{'id': 'button', 'kind': 'control', 'name': 'Button', 'evidence': [self.ref]}],
36
- 'edges': [{'id': 'owns', 'kind': 'contains', 'from': 'home', 'to': 'button'}]},
37
- 'callTraces': [{'id': 'trace', 'entryPointId': 'click', 'effects': [
38
- {'id': 'effect', 'kind': 'navigation', 'targetRefs': ['dialog'], 'origin': self.ref}]}]}
39
- self.base = module.project(self.graph, self.root)
40
- self.fingerprint = module.digest({'appgraph': self.graph, 'source': module.source_digest(self.root)})
41
-
42
- def discoveries(self, operations=None):
43
- return {'schema_version': 'd2h-utg-discoveries/v1', 'input_fingerprint': self.fingerprint,
44
- 'operations': operations or [], 'unresolved': []}
45
-
46
- def operation(self, kind, op, identity, value=None):
47
- return {'kind': kind, 'op': op, 'id': identity, 'reason': 'source verified',
48
- 'evidence': 'Screen.kt:1', 'value': value}
49
-
50
- def test_projection(self):
51
- self.assertEqual(len(self.base['nodes']), 4)
52
- self.assertEqual(len(self.base['edges']), 4)
53
- self.assertEqual(self.base['graph']['root'], 'home')
54
- home = next(n for n in self.base['nodes'] if n['id'] == 'home')
55
- self.assertEqual(home['widgets'][0]['id'], 'button')
56
- self.assertTrue(any(x['subject'] == 'external' for x in self.base['unresolved']))
57
-
58
- def test_source_page_coalesces_manifest_and_synthetic_compose_screen(self):
59
- graph = {'schemaVersion': 1, 'platform': 'android', 'app': {'name': 'Demo'},
60
- 'nodes': [
61
- {'id': 'manifest', 'kind': 'Screen', 'name': 'MainActivity', 'subtype': 'activity',
62
- 'platformRef': {'file': 'missing.xml', 'symbol': '.MainActivity'}},
63
- {'id': 'entry', 'kind': 'AppEntry', 'subtype': 'launcher-activity',
64
- 'attrs': {'screen': 'manifest'}},
65
- {'id': 'route', 'kind': 'Screen', 'name': 'SplashRoute', 'subtype': 'compose',
66
- 'matchKey': 'screen:splashroute', 'platformRef': {'file': 'Screen.kt'}},
67
- {'id': 'synthetic', 'kind': 'Screen', 'name': 'SplashRoute', 'subtype': 'compose',
68
- 'matchKey': 'screen:splashroute:compose', 'platformRef': self.ref,
69
- 'attrs': {'classificationRule': 'compose.destination.register'}}],
70
- 'uiComponents': {'nodes': [
71
- {'id': 'activity-page', 'kind': 'page', 'name': 'MainActivity', 'subtype': 'activity',
72
- 'evidence': [self.ref], 'attrs': {'screenId': 'manifest'}},
73
- {'id': 'route-page', 'kind': 'page', 'name': 'SplashRoute', 'subtype': 'compose',
74
- 'evidence': [self.ref], 'attrs': {'screenId': 'route',
75
- 'screenMatchKey': 'screen:splashroute', 'classificationRule': 'COMPOSE-SCREEN-AST'}}],
76
- 'edges': []}, 'edges': [{'id': 'nav', 'kind': 'navigates_to',
77
- 'from': 'manifest', 'to': 'synthetic', 'attrs': {'evidence': [self.ref]}}],
78
- 'entryPoints': [], 'callTraces': []}
79
- base = module.project(graph, self.root)
80
- self.assertEqual({n['id'] for n in base['nodes']}, {'activity-page', 'route-page'})
81
- self.assertEqual(base['graph']['root'], 'activity-page')
82
- self.assertEqual([(e['source'], e['target']) for e in base['edges']],
83
- [('activity-page', 'route-page')])
84
-
85
- def test_interaction_projection_preserves_stable_ids_and_alignment(self):
86
- self.graph['interactionGraph'] = {'schemaVersion': 1,
87
- 'surfaces': [{'id': identity, 'name': identity, 'kind': kind, 'sourceRefs': [identity, 'ui:' + identity],
88
- 'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'}
89
- for identity, kind in [('home', 'activity'), ('detail', 'compose')]],
90
- 'controlBindings': [{'id': 'binding:button:home', 'controlRef': 'button', 'surfaceId': 'home',
91
- 'evidence': [self.ref], 'selectorCandidates': []}],
92
- 'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved'}],
93
- 'transitions': [{'id': 'transition:stable', 'sourceSurfaceId': 'home',
94
- 'target': {'kind': 'internal', 'surfaceId': 'detail'}, 'operation': 'navigate',
95
- 'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['edge', 'trace', 'effect'],
96
- 'actionRef': 'click', 'conditions': []}], 'unresolved': []}
97
- base = module.project(self.graph, self.root)
98
- self.assertEqual(len(base['edges']), 1) # Legacy edges must not be appended a second time.
99
- self.assertEqual(base['graph']['root'], 'home')
100
- self.assertEqual(base['alignment']['edges'][0]['source_refs'], ['edge', 'trace', 'effect'])
101
- self.assertEqual(base['nodes'][1]['extra']['source_refs'], ['home', 'ui:home'])
102
- utg, _, _ = module.normalize_facts(base, self.root)
103
- self.assertEqual(utg['links'][0]['id'], 'transition:stable')
104
- self.assertEqual(utg['links'][0]['status'], 'static-only')
105
- self.graph['interactionGraph']['transitions'][0]['conditions'] = [
106
- {'expression': 'loggedIn', 'branch': 'else', 'group': 'login'}]
107
- guarded = module.project(self.graph, self.root)
108
- self.assertEqual(guarded['edges'][0]['guard']['text'], '!(loggedIn)')
109
- self.assertEqual(guarded['edges'][0]['guard']['conditions'][0]['branch'], 'else')
110
- self.graph['interactionGraph']['transitions'][0]['conditions'] = []
111
- from build_page_json import build_page_plan
112
- plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
113
- self.assertFalse(any(p['capture'] for p in plan['pages']))
114
- # Multiple declared launch pages use a non-capturable logical boundary.
115
- self.graph['interactionGraph']['entries'].append({'id': 'second-launch', 'kind': 'app-launch',
116
- 'surfaceIds': ['detail'], 'resolution': 'resolved', 'launch': {'action': 'start-activity', 'component': 'demo/demo.DetailActivity'}})
117
- base = module.project(self.graph, self.root)
118
- self.assertEqual(base['graph']['root'], 'utg:entry-root')
119
- detail = next(n for n in base['nodes'] if n['id'] == 'detail')
120
- detail['expected'] = {'text_any': ['Detail']}
121
- utg, _, _ = module.normalize_facts(base, self.root)
122
- paths = module.build_click_paths(utg)
123
- plan = build_page_plan(utg, paths, utg_path='utg.json', click_paths_path='click_paths.json')
124
- self.assertFalse(next(p for p in plan['pages'] if p['node_id'] == 'utg:entry-root')['capture'])
125
- page = next(p for p in plan['pages'] if p['node_id'] == 'detail')
126
- self.assertTrue(page['capture'])
127
- self.assertEqual(page['steps'][-1]['component'], 'demo/demo.DetailActivity')
128
- self.graph['interactionGraph']['entries'][-1].pop('launch')
129
- utg, _, _ = module.normalize_facts(module.project(self.graph, self.root), self.root)
130
- plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
131
- self.assertFalse(next(p for p in plan['pages'] if p['node_id'] == 'detail')['capture'])
132
- self.graph['interactionGraph']['entries'][-1].update(kind='deep-link', launch={'action': 'deep-link', 'uri': 'demo://app/detail'})
133
- base = module.project(self.graph, self.root)
134
- next(n for n in base['nodes'] if n['id'] == 'detail')['expected'] = {'text_any': ['Detail']}
135
- # Retain every source-proven edge: the executable entry must win over
136
- # the ordinary navigation whose control lacks a replay selector.
137
- self.assertTrue(any(e['id'] == 'transition:stable' for e in base['edges']))
138
- utg, _, _ = module.normalize_facts(base, self.root)
139
- plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
140
- page = next(p for p in plan['pages'] if p['node_id'] == 'detail')
141
- self.assertTrue(page['capture'])
142
- self.assertEqual(page['steps'][-1]['action'], 'deep-link')
143
- self.assertEqual(page['steps'][-1]['label'], 'demo://app/detail')
144
-
145
- def test_source_selector_candidates_do_not_guess_test_tag_resource_ids(self):
146
- self.graph['interactionGraph'] = {'schemaVersion': 1,
147
- 'surfaces': [{'id': identity, 'name': identity, 'kind': kind, 'sourceRefs': [identity],
148
- 'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'}
149
- for identity, kind in [('home', 'activity'), ('detail', 'compose')]],
150
- 'controlBindings': [{'id': 'binding', 'controlRef': 'button', 'surfaceId': 'home',
151
- 'evidence': [self.ref], 'selectorCandidates': []}],
152
- 'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved'},
153
- {'id': 'click', 'kind': 'ui-action', 'sourceRef': 'button', 'surfaceIds': ['home'],
154
- 'event': 'onClick', 'resolution': 'resolved'}],
155
- 'transitions': [{'id': 'navigate', 'sourceSurfaceId': 'home', 'actionRef': 'click',
156
- 'target': {'kind': 'internal', 'surfaceId': 'detail'}, 'operation': 'navigate',
157
- 'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['click']}],
158
- 'unresolved': []}
159
- binding = self.graph['interactionGraph']['controlBindings'][0]
160
- from build_page_json import build_page_plan
161
- for kind, ready in [('resource-id', True), ('test-tag', False)]:
162
- binding['selectorCandidates'] = [{'kind': kind, 'value': 'demo:id/open', 'evidence': [self.ref]}]
163
- base = module.project(self.graph, self.root)
164
- next(node for node in base['nodes'] if node['id'] == 'detail')['expected'] = {'text_any': ['Detail']}
165
- utg, _, _ = module.normalize_facts(base, self.root)
166
- page = next(page for page in build_page_plan(utg, module.build_click_paths(utg),
167
- utg_path='utg.json', click_paths_path='click_paths.json')['pages'] if page['node_id'] == 'detail')
168
- self.assertEqual(page['capture'], ready)
169
- if ready:
170
- self.assertEqual(page['steps'][-1]['selector'], {'resource_id': 'demo:id/open'})
171
- else:
172
- self.assertNotIn('selector', page['steps'][-1])
173
-
174
- def test_sheet_projection_and_external_entry_operand_boundary(self):
175
- self.graph['interactionGraph'] = {'schemaVersion': 1,
176
- 'surfaces': [{'id': 'home', 'name': 'Home', 'kind': 'activity', 'sourceRefs': ['home'],
177
- 'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'},
178
- {'id': 'sheet', 'name': 'Sheet', 'kind': 'bottom-sheet', 'sourceRefs': ['sheet'],
179
- 'hostSurfaceIds': ['home'], 'evidence': [self.ref], 'presentation': 'overlay'}],
180
- 'controlBindings': [],
181
- 'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved',
182
- 'launch': {'action': 'start-activity', 'component': 'demo/demo.Home'}},
183
- {'id': 'share', 'kind': 'external-entry', 'surfaceIds': ['sheet'],
184
- 'resolution': 'resolved', 'evidence': [self.ref]}],
185
- 'transitions': [{'id': 'show-sheet', 'sourceSurfaceId': 'home',
186
- 'target': {'kind': 'internal', 'surfaceId': 'sheet'}, 'operation': 'show',
187
- 'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['trigger']}],
188
- 'unresolved': []}
189
- base = module.project(self.graph, self.root)
190
- sheet = next(node for node in base['nodes'] if node['id'] == 'sheet')
191
- self.assertEqual(sheet['kind'], 'sheet')
192
- self.assertEqual(sheet['extra']['host_surface_ids'], ['home'])
193
- self.assertTrue(any(issue['subject'] == 'share' and 'operands' in issue['reason'] for issue in base['unresolved']))
194
- sheet['expected'] = {'text_any': ['Sheet']}
195
- utg, _, _ = module.normalize_facts(base, self.root)
196
- from build_page_json import build_page_plan
197
- plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
198
- self.assertFalse(next(page for page in plan['pages'] if page['node_id'] == 'sheet')['capture'])
199
- self.assertTrue(any(edge['id'] == 'show-sheet' for edge in utg['links']))
200
-
201
- def test_manifest_exact_declaration_and_entry(self):
202
- (self.root / 'AndroidManifest.xml').write_text(
203
- '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
204
- '<application><activity android:name=".MainActivity"/></application>\n</manifest>', encoding='utf-8')
205
- self.graph['nodes'][0]['platformRef'] = {'file': 'AndroidManifest.xml', 'symbol': '.MainActivity'}
206
- self.graph['nodes'].append({'id': 'manifest-entry', 'kind': 'AppEntry',
207
- 'subtype': 'launcher-activity', 'attrs': {'screen': 'home'}})
208
- self.graph['entryPoints'] = []
209
- self.graph['edges'][0]['attrs']['evidence'] = self.ref # Actual producer also emits a single ref.
210
- base = module.project(self.graph, self.root)
211
- self.assertEqual(base['graph']['root'], 'home')
212
- self.assertEqual(next(n for n in base['nodes'] if n['id'] == 'home')['declared_at'], 'AndroidManifest.xml:2')
213
-
214
- def test_ai_edge_guard_and_capture_readiness(self):
215
- node = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'fragment'))
216
- node['expected'] = {'text_any': ['Fragment']}
217
- edge = {'id': 'ai:fragment', 'source': 'home', 'target': 'fragment',
218
- 'mechanism': 'fragment-transaction', 'trigger': 'Screen.kt:1', 'confidence': 0.9,
219
- 'guard': {'kind': 'login', 'text': 'requires login'},
220
- 'args': {'action': 'tap', 'selector': {'text': 'Fragment'}}}
221
- merged = module.apply_discoveries(self.base, self.discoveries([
222
- self.operation('node', 'replace', 'fragment', node),
223
- self.operation('edge', 'add', 'ai:fragment', edge)]), self.root, self.fingerprint)
224
- utg, _, _ = module.normalize_facts(merged, self.root)
225
- paths = module.build_click_paths(utg)
226
- from build_page_json import build_page_plan
227
- plan = build_page_plan(utg, paths, utg_path='utg.json', click_paths_path='click_paths.json')
228
- page = next(n for n in plan['pages'] if n['node_id'] == 'fragment')
229
- self.assertTrue(page['capture'])
230
- self.assertEqual(page['steps'][-1]['guard']['kind'], 'login')
231
- next(e for e in merged['edges'] if e['id'] == 'ai:fragment')['args'] = {'nl_hint': 'click Fragment'}
232
- utg, _, _ = module.normalize_facts(merged, self.root)
233
- plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
234
- self.assertFalse(next(n for n in plan['pages'] if n['node_id'] == 'fragment')['capture'])
235
-
236
- def test_add_replace_exclude_audit(self):
237
- node = copy.deepcopy(self.base['nodes'][0])
238
- node.update(id='ai:new', label='New')
239
- detail = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'detail'))
240
- detail['expected'] = {'text_any': ['Details']}
241
- operations = [self.operation('node', 'add', 'ai:new', node),
242
- self.operation('node', 'replace', 'detail', detail),
243
- self.operation('edge', 'exclude', 'nav2')]
244
- merged = module.apply_discoveries(self.base, self.discoveries(operations), self.root, self.fingerprint)
245
- self.assertEqual(len(merged['graph']['discovery_audit']), 3)
246
- self.assertEqual(len(self.base['edges']), 4)
247
- self.assertEqual(next(n for n in merged['nodes'] if n['id'] == 'detail')['provenance'], 'ai-source-review')
248
-
249
- def test_source_review_cannot_claim_runtime_verification(self):
250
- node = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'detail'))
251
- node.update(status='runtime-verified', reached=True, screenshot='fake.png')
252
- edge = copy.deepcopy(next(e for e in self.base['edges'] if e['id'] == 'nav2'))
253
- edge.update(status='runtime-verified', reached=True)
254
- merged = module.apply_discoveries(self.base, self.discoveries([
255
- self.operation('node', 'replace', 'detail', node),
256
- self.operation('edge', 'replace', 'nav2', edge)]), self.root, self.fingerprint)
257
- utg, _, _ = module.normalize_facts(merged, self.root)
258
- reviewed = [next(n for n in utg['nodes'] if n['id'] == 'detail'),
259
- next(e for e in utg['links'] if e['id'] == 'nav2')]
260
- for item in reviewed:
261
- self.assertEqual(item['status'], 'static-only')
262
- self.assertEqual(item['provenance'], 'ai-source-review')
263
- self.assertNotIn('reached', item)
264
- self.assertNotIn('screenshot', item)
265
- self.assertEqual(next(n for n in self.base['nodes'] if n['id'] == 'detail')['status'], 'static-only')
266
-
267
- def test_resolved_findings_preserve_audit(self):
268
- discoveries = self.discoveries()
269
- discoveries['resolved_findings'] = [{'id': self.base['unresolved'][0]['id'],
270
- 'reason': 'verified boundary', 'evidence': 'Screen.kt:1'}]
271
- merged = module.apply_discoveries(self.base, discoveries, self.root, self.fingerprint)
272
- self.assertEqual(len(merged['unresolved']), len(self.base['unresolved']) - 1)
273
- self.assertEqual(merged['graph']['resolved_findings'], discoveries['resolved_findings'])
274
- discoveries['resolved_findings'][0]['id'] = 'unknown'
275
- with self.assertRaises(module.UtgError):
276
- module.apply_discoveries(self.base, discoveries, self.root, self.fingerprint)
277
-
278
- def test_reject_invalid_stale_duplicate_and_dangling(self):
279
- bad = self.discoveries()
280
- bad['input_fingerprint'] = 'old'
281
- with self.assertRaises(module.UtgError):
282
- module.apply_discoveries(self.base, bad, self.root, self.fingerprint)
283
- for operations in [[self.operation('node', 'exclude', 'home')],
284
- [self.operation('edge', 'exclude', 'nav1')] * 2,
285
- [self.operation('node', 'add', 'home', self.base['nodes'][0])]]:
286
- with self.assertRaises(module.UtgError):
287
- module.apply_discoveries(self.base, self.discoveries(operations), self.root, self.fingerprint)
288
- op = self.operation('edge', 'exclude', 'nav1')
289
- op['evidence'] = 'Screen.kt:99'
290
- with self.assertRaises(module.UtgError):
291
- module.apply_discoveries(self.base, self.discoveries([op]), self.root, self.fingerprint)
292
- old = module.source_digest(self.root)
293
- (self.root / 'Screen.kt').write_text('changed\n')
294
- self.assertNotEqual(old, module.source_digest(self.root))
295
-
296
- def test_ambiguous_root_not_guessed(self):
297
- self.graph['entryPoints'].append({'id': 'launch2', 'kind': 'app-launch', 'sourceRef': 'detail', 'resolution': 'resolved'})
298
- self.assertEqual(module.project(self.graph, self.root)['graph']['root'], '')
299
-
300
- def test_end_to_end_determinism_offline_and_readonly(self):
301
- graph_path = Path(self.temp.name) / 'graph.json'
302
- graph_path.write_text(json.dumps(self.graph), encoding='utf-8')
303
- original = graph_path.read_bytes()
304
- ws = Path(self.temp.name) / 'utg'
305
- command = [sys.executable, str(SCRIPT), '--project-root', str(self.root), '--appgraph', str(graph_path), '--workspace', str(ws)]
306
- subprocess.run(command, check=True, capture_output=True)
307
- review = json.loads((ws / 'review-worklist.json').read_text(encoding='utf-8'))
308
- self.assertEqual(review['navigation_review']['status'], 'pending')
309
- self.assertEqual(review['navigation_review']['total_findings'], 1)
310
- self.assertEqual(len(review['navigation_candidates']), 4)
311
- outputs = ['utg.json', 'click_paths.json', 'page.json', 'utg.html']
312
- before = {n: (ws / n).read_bytes() for n in outputs}
313
- subprocess.run(command + ['--force'], check=True, capture_output=True)
314
- self.assertEqual(before, {n: (ws / n).read_bytes() for n in outputs})
315
- self.assertEqual(graph_path.read_bytes(), original)
316
- plan = json.loads((ws / 'page.json').read_text())
317
- self.assertFalse(any(n['capture'] for n in plan['pages']))
318
- html = (ws / 'utg.html').read_text(encoding='utf-8')
319
- self.assertNotIn('<script src=', html)
320
- self.assertIn('source-filter', html)
321
- for i, script in enumerate(re.findall(r'<script>(.*?)</script>', html, re.S)):
322
- js = Path(self.temp.name) / f'html-{i}.js'
323
- js.write_text(script, encoding='utf-8')
324
- result = subprocess.run(['node', '--check', str(js)], capture_output=True, text=True)
325
- self.assertEqual(result.returncode, 0, result.stderr)
326
- # The existing TS consumer validates this static plan without ADB.
327
- result = subprocess.run(['node', str(module.SHARED / 'capture_from_page_json.ts'),
328
- '--page-json', str(ws / 'page.json'), '--output-workspace', str(ws / 'captures'), '--dry-run'],
329
- capture_output=True, text=True)
330
- self.assertEqual(result.returncode, 0, result.stderr)
331
- self.assertFalse((ws / 'captures').exists())
332
-
333
-
334
- if __name__ == '__main__':
335
- unittest.main()
1
+ import copy
2
+ import importlib.util
3
+ import json
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'generate.py'
12
+ spec = importlib.util.spec_from_file_location('static_generate', SCRIPT)
13
+ module = importlib.util.module_from_spec(spec)
14
+ spec.loader.exec_module(module)
15
+
16
+
17
+ class StaticTests(unittest.TestCase):
18
+ def setUp(self):
19
+ self.temp = tempfile.TemporaryDirectory()
20
+ self.addCleanup(self.temp.cleanup)
21
+ self.root = Path(self.temp.name) / 'source'
22
+ self.root.mkdir()
23
+ (self.root / 'Screen.kt').write_text('fun home() {}\nfun detail() {}\nfun dialog() {}\n', encoding='utf-8')
24
+ self.ref = {'file': 'Screen.kt', 'line': 1}
25
+ self.graph = {'schemaVersion': 1, 'platform': 'android', 'app': {'name': 'Demo', 'packageName': 'demo'},
26
+ 'nodes': [{'id': identity, 'kind': 'Screen', 'name': 'SameName', 'platform': 'android',
27
+ 'subtype': kind, 'platformRef': self.ref}
28
+ for identity, kind in [('home', 'activity'), ('detail', 'compose'), ('dialog', 'dialog'), ('fragment', 'fragment')]],
29
+ 'edges': [{'id': identity, 'kind': 'navigates_to', 'from': source, 'to': target,
30
+ 'attrs': {'evidence': [self.ref]}, 'confidence': 1}
31
+ for identity, source, target in [('nav1', 'home', 'detail'), ('nav2', 'home', 'detail'),
32
+ ('back', 'detail', 'home'), ('external', 'home', 'outside')]],
33
+ 'entryPoints': [{'id': 'launch', 'kind': 'app-launch', 'sourceRef': 'home', 'resolution': 'resolved'},
34
+ {'id': 'click', 'kind': 'ui-action', 'sourceRef': 'button', 'ownerRef': 'home', 'resolution': 'resolved'}],
35
+ 'uiComponents': {'nodes': [{'id': 'button', 'kind': 'control', 'name': 'Button', 'evidence': [self.ref]}],
36
+ 'edges': [{'id': 'owns', 'kind': 'contains', 'from': 'home', 'to': 'button'}]},
37
+ 'callTraces': [{'id': 'trace', 'entryPointId': 'click', 'effects': [
38
+ {'id': 'effect', 'kind': 'navigation', 'targetRefs': ['dialog'], 'origin': self.ref}]}]}
39
+ self.base = module.project(self.graph, self.root)
40
+ self.fingerprint = module.digest({'appgraph': self.graph, 'source': module.source_digest(self.root)})
41
+
42
+ def discoveries(self, operations=None):
43
+ return {'schema_version': 'd2h-utg-discoveries/v1', 'input_fingerprint': self.fingerprint,
44
+ 'operations': operations or [], 'unresolved': []}
45
+
46
+ def operation(self, kind, op, identity, value=None):
47
+ return {'kind': kind, 'op': op, 'id': identity, 'reason': 'source verified',
48
+ 'evidence': 'Screen.kt:1', 'value': value}
49
+
50
+ def test_projection(self):
51
+ self.assertEqual(len(self.base['nodes']), 4)
52
+ self.assertEqual(len(self.base['edges']), 4)
53
+ self.assertEqual(self.base['graph']['root'], 'home')
54
+ home = next(n for n in self.base['nodes'] if n['id'] == 'home')
55
+ self.assertEqual(home['widgets'][0]['id'], 'button')
56
+ self.assertTrue(any(x['subject'] == 'external' for x in self.base['unresolved']))
57
+
58
+ def test_source_page_coalesces_manifest_and_synthetic_compose_screen(self):
59
+ graph = {'schemaVersion': 1, 'platform': 'android', 'app': {'name': 'Demo'},
60
+ 'nodes': [
61
+ {'id': 'manifest', 'kind': 'Screen', 'name': 'MainActivity', 'subtype': 'activity',
62
+ 'platformRef': {'file': 'missing.xml', 'symbol': '.MainActivity'}},
63
+ {'id': 'entry', 'kind': 'AppEntry', 'subtype': 'launcher-activity',
64
+ 'attrs': {'screen': 'manifest'}},
65
+ {'id': 'route', 'kind': 'Screen', 'name': 'SplashRoute', 'subtype': 'compose',
66
+ 'matchKey': 'screen:splashroute', 'platformRef': {'file': 'Screen.kt'}},
67
+ {'id': 'synthetic', 'kind': 'Screen', 'name': 'SplashRoute', 'subtype': 'compose',
68
+ 'matchKey': 'screen:splashroute:compose', 'platformRef': self.ref,
69
+ 'attrs': {'classificationRule': 'compose.destination.register'}}],
70
+ 'uiComponents': {'nodes': [
71
+ {'id': 'activity-page', 'kind': 'page', 'name': 'MainActivity', 'subtype': 'activity',
72
+ 'evidence': [self.ref], 'attrs': {'screenId': 'manifest'}},
73
+ {'id': 'route-page', 'kind': 'page', 'name': 'SplashRoute', 'subtype': 'compose',
74
+ 'evidence': [self.ref], 'attrs': {'screenId': 'route',
75
+ 'screenMatchKey': 'screen:splashroute', 'classificationRule': 'COMPOSE-SCREEN-AST'}}],
76
+ 'edges': []}, 'edges': [{'id': 'nav', 'kind': 'navigates_to',
77
+ 'from': 'manifest', 'to': 'synthetic', 'attrs': {'evidence': [self.ref]}}],
78
+ 'entryPoints': [], 'callTraces': []}
79
+ base = module.project(graph, self.root)
80
+ self.assertEqual({n['id'] for n in base['nodes']}, {'activity-page', 'route-page'})
81
+ self.assertEqual(base['graph']['root'], 'activity-page')
82
+ self.assertEqual([(e['source'], e['target']) for e in base['edges']],
83
+ [('activity-page', 'route-page')])
84
+
85
+ def test_interaction_projection_preserves_stable_ids_and_alignment(self):
86
+ self.graph['interactionGraph'] = {'schemaVersion': 1,
87
+ 'surfaces': [{'id': identity, 'name': identity, 'kind': kind, 'sourceRefs': [identity, 'ui:' + identity],
88
+ 'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'}
89
+ for identity, kind in [('home', 'activity'), ('detail', 'compose')]],
90
+ 'controlBindings': [{'id': 'binding:button:home', 'controlRef': 'button', 'surfaceId': 'home',
91
+ 'evidence': [self.ref], 'selectorCandidates': []}],
92
+ 'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved'}],
93
+ 'transitions': [{'id': 'transition:stable', 'sourceSurfaceId': 'home',
94
+ 'target': {'kind': 'internal', 'surfaceId': 'detail'}, 'operation': 'navigate',
95
+ 'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['edge', 'trace', 'effect'],
96
+ 'actionRef': 'click', 'conditions': []}], 'unresolved': []}
97
+ base = module.project(self.graph, self.root)
98
+ self.assertEqual(len(base['edges']), 1) # Legacy edges must not be appended a second time.
99
+ self.assertEqual(base['graph']['root'], 'home')
100
+ self.assertEqual(base['alignment']['edges'][0]['source_refs'], ['edge', 'trace', 'effect'])
101
+ self.assertEqual(base['nodes'][1]['extra']['source_refs'], ['home', 'ui:home'])
102
+ utg, _, _ = module.normalize_facts(base, self.root)
103
+ self.assertEqual(utg['links'][0]['id'], 'transition:stable')
104
+ self.assertEqual(utg['links'][0]['status'], 'static-only')
105
+ self.graph['interactionGraph']['transitions'][0]['conditions'] = [
106
+ {'expression': 'loggedIn', 'branch': 'else', 'group': 'login'}]
107
+ guarded = module.project(self.graph, self.root)
108
+ self.assertEqual(guarded['edges'][0]['guard']['text'], '!(loggedIn)')
109
+ self.assertEqual(guarded['edges'][0]['guard']['conditions'][0]['branch'], 'else')
110
+ self.graph['interactionGraph']['transitions'][0]['conditions'] = []
111
+ from build_page_json import build_page_plan
112
+ plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
113
+ self.assertFalse(any(p['capture'] for p in plan['pages']))
114
+ # Multiple declared launch pages use a non-capturable logical boundary.
115
+ self.graph['interactionGraph']['entries'].append({'id': 'second-launch', 'kind': 'app-launch',
116
+ 'surfaceIds': ['detail'], 'resolution': 'resolved', 'launch': {'action': 'start-activity', 'component': 'demo/demo.DetailActivity'}})
117
+ base = module.project(self.graph, self.root)
118
+ self.assertEqual(base['graph']['root'], 'utg:entry-root')
119
+ detail = next(n for n in base['nodes'] if n['id'] == 'detail')
120
+ detail['expected'] = {'text_any': ['Detail']}
121
+ utg, _, _ = module.normalize_facts(base, self.root)
122
+ paths = module.build_click_paths(utg)
123
+ plan = build_page_plan(utg, paths, utg_path='utg.json', click_paths_path='click_paths.json')
124
+ self.assertFalse(next(p for p in plan['pages'] if p['node_id'] == 'utg:entry-root')['capture'])
125
+ page = next(p for p in plan['pages'] if p['node_id'] == 'detail')
126
+ self.assertTrue(page['capture'])
127
+ self.assertEqual(page['steps'][-1]['component'], 'demo/demo.DetailActivity')
128
+ self.graph['interactionGraph']['entries'][-1].pop('launch')
129
+ utg, _, _ = module.normalize_facts(module.project(self.graph, self.root), self.root)
130
+ plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
131
+ self.assertFalse(next(p for p in plan['pages'] if p['node_id'] == 'detail')['capture'])
132
+ self.graph['interactionGraph']['entries'][-1].update(kind='deep-link', launch={'action': 'deep-link', 'uri': 'demo://app/detail'})
133
+ base = module.project(self.graph, self.root)
134
+ next(n for n in base['nodes'] if n['id'] == 'detail')['expected'] = {'text_any': ['Detail']}
135
+ # Retain every source-proven edge: the executable entry must win over
136
+ # the ordinary navigation whose control lacks a replay selector.
137
+ self.assertTrue(any(e['id'] == 'transition:stable' for e in base['edges']))
138
+ utg, _, _ = module.normalize_facts(base, self.root)
139
+ plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
140
+ page = next(p for p in plan['pages'] if p['node_id'] == 'detail')
141
+ self.assertTrue(page['capture'])
142
+ self.assertEqual(page['steps'][-1]['action'], 'deep-link')
143
+ self.assertEqual(page['steps'][-1]['label'], 'demo://app/detail')
144
+
145
+ def test_source_selector_candidates_do_not_guess_test_tag_resource_ids(self):
146
+ self.graph['interactionGraph'] = {'schemaVersion': 1,
147
+ 'surfaces': [{'id': identity, 'name': identity, 'kind': kind, 'sourceRefs': [identity],
148
+ 'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'}
149
+ for identity, kind in [('home', 'activity'), ('detail', 'compose')]],
150
+ 'controlBindings': [{'id': 'binding', 'controlRef': 'button', 'surfaceId': 'home',
151
+ 'evidence': [self.ref], 'selectorCandidates': []}],
152
+ 'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved'},
153
+ {'id': 'click', 'kind': 'ui-action', 'sourceRef': 'button', 'surfaceIds': ['home'],
154
+ 'event': 'onClick', 'resolution': 'resolved'}],
155
+ 'transitions': [{'id': 'navigate', 'sourceSurfaceId': 'home', 'actionRef': 'click',
156
+ 'target': {'kind': 'internal', 'surfaceId': 'detail'}, 'operation': 'navigate',
157
+ 'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['click']}],
158
+ 'unresolved': []}
159
+ binding = self.graph['interactionGraph']['controlBindings'][0]
160
+ from build_page_json import build_page_plan
161
+ for kind, ready in [('resource-id', True), ('test-tag', False)]:
162
+ binding['selectorCandidates'] = [{'kind': kind, 'value': 'demo:id/open', 'evidence': [self.ref]}]
163
+ base = module.project(self.graph, self.root)
164
+ next(node for node in base['nodes'] if node['id'] == 'detail')['expected'] = {'text_any': ['Detail']}
165
+ utg, _, _ = module.normalize_facts(base, self.root)
166
+ page = next(page for page in build_page_plan(utg, module.build_click_paths(utg),
167
+ utg_path='utg.json', click_paths_path='click_paths.json')['pages'] if page['node_id'] == 'detail')
168
+ self.assertEqual(page['capture'], ready)
169
+ if ready:
170
+ self.assertEqual(page['steps'][-1]['selector'], {'resource_id': 'demo:id/open'})
171
+ else:
172
+ self.assertNotIn('selector', page['steps'][-1])
173
+
174
+ def test_sheet_projection_and_external_entry_operand_boundary(self):
175
+ self.graph['interactionGraph'] = {'schemaVersion': 1,
176
+ 'surfaces': [{'id': 'home', 'name': 'Home', 'kind': 'activity', 'sourceRefs': ['home'],
177
+ 'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'},
178
+ {'id': 'sheet', 'name': 'Sheet', 'kind': 'bottom-sheet', 'sourceRefs': ['sheet'],
179
+ 'hostSurfaceIds': ['home'], 'evidence': [self.ref], 'presentation': 'overlay'}],
180
+ 'controlBindings': [],
181
+ 'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved',
182
+ 'launch': {'action': 'start-activity', 'component': 'demo/demo.Home'}},
183
+ {'id': 'share', 'kind': 'external-entry', 'surfaceIds': ['sheet'],
184
+ 'resolution': 'resolved', 'evidence': [self.ref]}],
185
+ 'transitions': [{'id': 'show-sheet', 'sourceSurfaceId': 'home',
186
+ 'target': {'kind': 'internal', 'surfaceId': 'sheet'}, 'operation': 'show',
187
+ 'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['trigger']}],
188
+ 'unresolved': []}
189
+ base = module.project(self.graph, self.root)
190
+ sheet = next(node for node in base['nodes'] if node['id'] == 'sheet')
191
+ self.assertEqual(sheet['kind'], 'sheet')
192
+ self.assertEqual(sheet['extra']['host_surface_ids'], ['home'])
193
+ self.assertTrue(any(issue['subject'] == 'share' and 'operands' in issue['reason'] for issue in base['unresolved']))
194
+ sheet['expected'] = {'text_any': ['Sheet']}
195
+ utg, _, _ = module.normalize_facts(base, self.root)
196
+ from build_page_json import build_page_plan
197
+ plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
198
+ self.assertFalse(next(page for page in plan['pages'] if page['node_id'] == 'sheet')['capture'])
199
+ self.assertTrue(any(edge['id'] == 'show-sheet' for edge in utg['links']))
200
+
201
+ def test_manifest_exact_declaration_and_entry(self):
202
+ (self.root / 'AndroidManifest.xml').write_text(
203
+ '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
204
+ '<application><activity android:name=".MainActivity"/></application>\n</manifest>', encoding='utf-8')
205
+ self.graph['nodes'][0]['platformRef'] = {'file': 'AndroidManifest.xml', 'symbol': '.MainActivity'}
206
+ self.graph['nodes'].append({'id': 'manifest-entry', 'kind': 'AppEntry',
207
+ 'subtype': 'launcher-activity', 'attrs': {'screen': 'home'}})
208
+ self.graph['entryPoints'] = []
209
+ self.graph['edges'][0]['attrs']['evidence'] = self.ref # Actual producer also emits a single ref.
210
+ base = module.project(self.graph, self.root)
211
+ self.assertEqual(base['graph']['root'], 'home')
212
+ self.assertEqual(next(n for n in base['nodes'] if n['id'] == 'home')['declared_at'], 'AndroidManifest.xml:2')
213
+
214
+ def test_ai_edge_guard_and_capture_readiness(self):
215
+ node = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'fragment'))
216
+ node['expected'] = {'text_any': ['Fragment']}
217
+ edge = {'id': 'ai:fragment', 'source': 'home', 'target': 'fragment',
218
+ 'mechanism': 'fragment-transaction', 'trigger': 'Screen.kt:1', 'confidence': 0.9,
219
+ 'guard': {'kind': 'login', 'text': 'requires login'},
220
+ 'args': {'action': 'tap', 'selector': {'text': 'Fragment'}}}
221
+ merged = module.apply_discoveries(self.base, self.discoveries([
222
+ self.operation('node', 'replace', 'fragment', node),
223
+ self.operation('edge', 'add', 'ai:fragment', edge)]), self.root, self.fingerprint)
224
+ utg, _, _ = module.normalize_facts(merged, self.root)
225
+ paths = module.build_click_paths(utg)
226
+ from build_page_json import build_page_plan
227
+ plan = build_page_plan(utg, paths, utg_path='utg.json', click_paths_path='click_paths.json')
228
+ page = next(n for n in plan['pages'] if n['node_id'] == 'fragment')
229
+ self.assertTrue(page['capture'])
230
+ self.assertEqual(page['steps'][-1]['guard']['kind'], 'login')
231
+ next(e for e in merged['edges'] if e['id'] == 'ai:fragment')['args'] = {'nl_hint': 'click Fragment'}
232
+ utg, _, _ = module.normalize_facts(merged, self.root)
233
+ plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
234
+ self.assertFalse(next(n for n in plan['pages'] if n['node_id'] == 'fragment')['capture'])
235
+
236
+ def test_add_replace_exclude_audit(self):
237
+ node = copy.deepcopy(self.base['nodes'][0])
238
+ node.update(id='ai:new', label='New')
239
+ detail = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'detail'))
240
+ detail['expected'] = {'text_any': ['Details']}
241
+ operations = [self.operation('node', 'add', 'ai:new', node),
242
+ self.operation('node', 'replace', 'detail', detail),
243
+ self.operation('edge', 'exclude', 'nav2')]
244
+ merged = module.apply_discoveries(self.base, self.discoveries(operations), self.root, self.fingerprint)
245
+ self.assertEqual(len(merged['graph']['discovery_audit']), 3)
246
+ self.assertEqual(len(self.base['edges']), 4)
247
+ self.assertEqual(next(n for n in merged['nodes'] if n['id'] == 'detail')['provenance'], 'ai-source-review')
248
+
249
+ def test_source_review_cannot_claim_runtime_verification(self):
250
+ node = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'detail'))
251
+ node.update(status='runtime-verified', reached=True, screenshot='fake.png')
252
+ edge = copy.deepcopy(next(e for e in self.base['edges'] if e['id'] == 'nav2'))
253
+ edge.update(status='runtime-verified', reached=True)
254
+ merged = module.apply_discoveries(self.base, self.discoveries([
255
+ self.operation('node', 'replace', 'detail', node),
256
+ self.operation('edge', 'replace', 'nav2', edge)]), self.root, self.fingerprint)
257
+ utg, _, _ = module.normalize_facts(merged, self.root)
258
+ reviewed = [next(n for n in utg['nodes'] if n['id'] == 'detail'),
259
+ next(e for e in utg['links'] if e['id'] == 'nav2')]
260
+ for item in reviewed:
261
+ self.assertEqual(item['status'], 'static-only')
262
+ self.assertEqual(item['provenance'], 'ai-source-review')
263
+ self.assertNotIn('reached', item)
264
+ self.assertNotIn('screenshot', item)
265
+ self.assertEqual(next(n for n in self.base['nodes'] if n['id'] == 'detail')['status'], 'static-only')
266
+
267
+ def test_resolved_findings_preserve_audit(self):
268
+ discoveries = self.discoveries()
269
+ discoveries['resolved_findings'] = [{'id': self.base['unresolved'][0]['id'],
270
+ 'reason': 'verified boundary', 'evidence': 'Screen.kt:1'}]
271
+ merged = module.apply_discoveries(self.base, discoveries, self.root, self.fingerprint)
272
+ self.assertEqual(len(merged['unresolved']), len(self.base['unresolved']) - 1)
273
+ self.assertEqual(merged['graph']['resolved_findings'], discoveries['resolved_findings'])
274
+ discoveries['resolved_findings'][0]['id'] = 'unknown'
275
+ with self.assertRaises(module.UtgError):
276
+ module.apply_discoveries(self.base, discoveries, self.root, self.fingerprint)
277
+
278
+ def test_reject_invalid_stale_duplicate_and_dangling(self):
279
+ bad = self.discoveries()
280
+ bad['input_fingerprint'] = 'old'
281
+ with self.assertRaises(module.UtgError):
282
+ module.apply_discoveries(self.base, bad, self.root, self.fingerprint)
283
+ for operations in [[self.operation('node', 'exclude', 'home')],
284
+ [self.operation('edge', 'exclude', 'nav1')] * 2,
285
+ [self.operation('node', 'add', 'home', self.base['nodes'][0])]]:
286
+ with self.assertRaises(module.UtgError):
287
+ module.apply_discoveries(self.base, self.discoveries(operations), self.root, self.fingerprint)
288
+ op = self.operation('edge', 'exclude', 'nav1')
289
+ op['evidence'] = 'Screen.kt:99'
290
+ with self.assertRaises(module.UtgError):
291
+ module.apply_discoveries(self.base, self.discoveries([op]), self.root, self.fingerprint)
292
+ old = module.source_digest(self.root)
293
+ (self.root / 'Screen.kt').write_text('changed\n')
294
+ self.assertNotEqual(old, module.source_digest(self.root))
295
+
296
+ def test_ambiguous_root_not_guessed(self):
297
+ self.graph['entryPoints'].append({'id': 'launch2', 'kind': 'app-launch', 'sourceRef': 'detail', 'resolution': 'resolved'})
298
+ self.assertEqual(module.project(self.graph, self.root)['graph']['root'], '')
299
+
300
+ def test_end_to_end_determinism_offline_and_readonly(self):
301
+ graph_path = Path(self.temp.name) / 'graph.json'
302
+ graph_path.write_text(json.dumps(self.graph), encoding='utf-8')
303
+ original = graph_path.read_bytes()
304
+ ws = Path(self.temp.name) / 'utg'
305
+ command = [sys.executable, str(SCRIPT), '--project-root', str(self.root), '--appgraph', str(graph_path), '--workspace', str(ws)]
306
+ subprocess.run(command, check=True, capture_output=True)
307
+ review = json.loads((ws / 'review-worklist.json').read_text(encoding='utf-8'))
308
+ self.assertEqual(review['navigation_review']['status'], 'pending')
309
+ self.assertEqual(review['navigation_review']['total_findings'], 1)
310
+ self.assertEqual(len(review['navigation_candidates']), 4)
311
+ outputs = ['utg.json', 'click_paths.json', 'page.json', 'utg.html']
312
+ before = {n: (ws / n).read_bytes() for n in outputs}
313
+ subprocess.run(command + ['--force'], check=True, capture_output=True)
314
+ self.assertEqual(before, {n: (ws / n).read_bytes() for n in outputs})
315
+ self.assertEqual(graph_path.read_bytes(), original)
316
+ plan = json.loads((ws / 'page.json').read_text())
317
+ self.assertFalse(any(n['capture'] for n in plan['pages']))
318
+ html = (ws / 'utg.html').read_text(encoding='utf-8')
319
+ self.assertNotIn('<script src=', html)
320
+ self.assertIn('source-filter', html)
321
+ for i, script in enumerate(re.findall(r'<script>(.*?)</script>', html, re.S)):
322
+ js = Path(self.temp.name) / f'html-{i}.js'
323
+ js.write_text(script, encoding='utf-8')
324
+ result = subprocess.run(['node', '--check', str(js)], capture_output=True, text=True)
325
+ self.assertEqual(result.returncode, 0, result.stderr)
326
+ # The existing TS consumer validates this static plan without ADB.
327
+ result = subprocess.run(['node', str(module.SHARED / 'capture_from_page_json.ts'),
328
+ '--page-json', str(ws / 'page.json'), '--output-workspace', str(ws / 'captures'), '--dry-run'],
329
+ capture_output=True, text=True)
330
+ self.assertEqual(result.returncode, 0, result.stderr)
331
+ self.assertFalse((ws / 'captures').exists())
332
+
333
+
334
+ if __name__ == '__main__':
335
+ unittest.main()