235 lines
15 KiB
Python
235 lines
15 KiB
Python
import contextlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import migration_workflows as workflows
|
|
import prepare_business_acceptance_batch as acceptance
|
|
import prepare_feature_capability_batch as features
|
|
import generate_feature_capability_registry as registry
|
|
import prepare_implementation_business_batch as planner
|
|
import prepare_module_implementation_packet as packets
|
|
import run_freecad_web_scheduler as scheduler
|
|
from report_migration_delivery import report
|
|
|
|
|
|
class DeliveryTests(unittest.TestCase):
|
|
def test_native_marker_recognizes_fully_qualified_desktop_api_id(self):
|
|
source = '''
|
|
// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Assembly_CreateJointAngle")
|
|
if (apiId == QStringLiteral("Assembly:desktop:Assembly_CreateJointAngle")) {
|
|
result.insert(QStringLiteral("status"), QStringLiteral("exposed"));
|
|
return true;
|
|
}
|
|
'''
|
|
self.assertIn("Assembly_CreateJointAngle", registry.implemented_desktop_commands(source))
|
|
|
|
def test_native_marker_recognizes_dispatcher_boolean_command_alias(self):
|
|
source = '''
|
|
// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Assembly_SelectComponentsWithDoFs")
|
|
const bool dofs = apiId == QStringLiteral("Assembly:desktop:Assembly_SelectComponentsWithDoFs");
|
|
if (dofs) { result.insert(QStringLiteral("status"), QStringLiteral("exposed")); }
|
|
'''
|
|
self.assertIn("Assembly_SelectComponentsWithDoFs", registry.implemented_desktop_commands(source))
|
|
|
|
def test_native_marker_recognizes_name_value_alias_with_json_status(self):
|
|
source = r'''
|
|
// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("BIM_Nudge_Down")
|
|
const bool nudge = familyValue == "desktop-command" &&
|
|
(nameValue == "BIM_Nudge_Down" || nameValue == "BIM_Nudge_Left");
|
|
if (nudge) { recordsJson += "\"status\":\"exposed\""; }
|
|
'''
|
|
self.assertIn("BIM_Nudge_Down", registry.implemented_desktop_commands(source))
|
|
self.assertNotIn("BIM_Nudge_Right", registry.implemented_desktop_commands(source))
|
|
|
|
def test_native_marker_recognizes_shared_draft_snap_branch(self):
|
|
source = r'''
|
|
// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Draft_Snap_Grid")
|
|
const int snapIndex = name.startsWith(QStringLiteral("Draft_Snap_")) ? 1 : -1;
|
|
if (family == QStringLiteral("desktop-command") && snapIndex >= 0) {
|
|
result.insert(QStringLiteral("status"), QStringLiteral("exposed"));
|
|
}
|
|
'''
|
|
self.assertIn("Draft_Snap_Grid", registry.implemented_desktop_commands(source))
|
|
|
|
def test_same_module_cases_for_different_native_owners_are_not_combined(self):
|
|
rows = [{"id": "Part:create", "module": "Part", "command": "create"},
|
|
{"id": "Part:cylinder", "module": "Part", "command": "cylinder"},
|
|
{"id": "Part:sphere", "module": "Part", "command": "sphere"}]
|
|
document = {"tree": "build", "target": "FreeCADWebDocumentProbe"}
|
|
state = {"pending": rows, "rows": rows, "accepted": set(), "cooling": set(), "receipts": {},
|
|
"covered": {"Part": {row["id"] for row in rows}},
|
|
"buildOverrides": {"Part": {"Part:cylinder": document, "Part:sphere": document}}}
|
|
groups = acceptance.candidates(state)
|
|
self.assertEqual([len(group["rows"]) for group in groups], [1, 2])
|
|
self.assertEqual(len(state["pending"]), 3)
|
|
contract = {"build": {"target": "PartBoxProbe"}, "buildOverrides": state["buildOverrides"]["Part"]}
|
|
self.assertEqual(packets.acceptance_build_for_ids(contract, {"Part:cylinder", "Part:sphere"}), (document, True))
|
|
self.assertEqual(packets.acceptance_build_for_ids(contract, {"Part:create"}), (contract["build"], False))
|
|
self.assertEqual(packets.acceptance_build_for_ids(contract, set()), (contract["build"], False))
|
|
|
|
def state(self):
|
|
rows = [{"id": key, "module": "Web", "command": key} for key, family in workflows.PRODUCT_WORKFLOWS.items() if family == "product-workspace"]
|
|
rows += [{"id": "Part:save", "module": "Part", "command": "part.saveReopen", "category": "persistence"},
|
|
{"id": "TechDraw:desktop:View", "module": "TechDraw", "command": "View", "kind": "desktop-gui"},
|
|
{"id": "Import:inspect", "module": "Import", "command": "import.inspectSourceInventory", "category": "format"}]
|
|
return {"rows": rows, "pending": rows, "accepted": set(), "receipts": {}, "cooling": set(),
|
|
"covered": {row["module"]: set() for row in rows}}
|
|
|
|
def test_core_workspace_precedes_desktop_and_support_routes_without_dropping_scope(self):
|
|
state = self.state()
|
|
batch = acceptance.plan(state, delivery_only=True)
|
|
self.assertEqual(batch["selected"]["module"], "Web")
|
|
self.assertEqual(batch["selected"]["recordCount"], 4)
|
|
self.assertEqual(batch["businessWorkflow"], "product-workspace")
|
|
self.assertEqual(batch["records"][0][0], "product:react-primary-shell")
|
|
state["cooling"] = {row[0] for row in batch["records"]}
|
|
batch = acceptance.plan(state, delivery_only=True)
|
|
self.assertEqual([row[0] for row in batch["records"]], ["Part:save"])
|
|
self.assertEqual(len(state["pending"]), 7)
|
|
self.assertFalse(workflows.delivery_route(state["rows"][-1]))
|
|
|
|
def test_save_and_format_families_are_distinct_and_aliases_share_one_family(self):
|
|
self.assertEqual(workflows.workflow_family({"id": "Part:save", "category": "persistence"}), "document-persistence")
|
|
self.assertEqual(workflows.workflow_family({"id": "business-format:Import:import:.step"}),
|
|
workflows.workflow_family({"id": "business-format:Import:export:.stp"}))
|
|
|
|
def test_business_and_desktop_feature_families_never_mix(self):
|
|
groups = [(('Part', 'module-business-document-persistence'), [{"id": 'save'}]),
|
|
(('Part', 'desktop-command-command'), [{"id": 'gui'}])]
|
|
_, family, rows, _ = features.select_group_batch(groups)
|
|
self.assertEqual(family, 'module-business-document-persistence')
|
|
self.assertEqual([row['id'] for row in rows], ['save'])
|
|
_, _, rows, _ = features.select_group_batch(list(reversed(groups)))
|
|
self.assertEqual([row['id'] for row in rows], ['gui'])
|
|
|
|
def test_delivery_and_runtime_filters_preserve_original_inventory(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
paths = {key: root / f'{key}.json' for key in ('REGISTRY', 'ISSUES', 'STATE', 'OUTPUT', 'PIPELINE', 'PROGRESS')}
|
|
rows = [{"id": "Sketcher:create", "module": "Sketcher", "command": "sketcher.createRecompute", "category": "modeling"},
|
|
{"id": "TechDraw:desktop:View", "module": "TechDraw", "command": "View", "kind": "desktop-gui"}]
|
|
paths['REGISTRY'].write_text(json.dumps({'records': rows}))
|
|
paths['ISSUES'].write_text(json.dumps({'issues': [{'id': 'bug', 'capabilityId': rows[1]['id'], 'status': 'open'}]}))
|
|
paths['STATE'].write_text('{"active_task":"task"}')
|
|
original = paths['REGISTRY'].read_bytes()
|
|
with patch.multiple(features, **paths), patch.object(features, 'capability_source_fingerprint', return_value='stable'), \
|
|
patch.object(features, 'implementation_ready_ids', return_value=set()), patch.object(features, 'cooling_ids', return_value=set()), \
|
|
contextlib.redirect_stdout(io.StringIO()):
|
|
features.main(['--delivery-only'])
|
|
batch = json.loads(paths['OUTPUT'].read_text())
|
|
self.assertEqual([row[0] for row in batch['records']], ['Sketcher:create'])
|
|
self.assertTrue(batch['selected']['family'].startswith('module-business-'))
|
|
features.main(['--runtime-only'])
|
|
self.assertEqual(json.loads(paths['OUTPUT'].read_text())['selected']['family'], 'runtime-issue')
|
|
self.assertEqual(paths['REGISTRY'].read_bytes(), original)
|
|
|
|
def test_delivery_handoff_is_not_interrupted_by_unrelated_historical_review(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
output = Path(directory) / 'batch.json'
|
|
output.write_text('{"selected":{"module":"TechDraw","tier":"feature-workflow"}}')
|
|
calls = []
|
|
def run(command, **kwargs):
|
|
calls.append(command)
|
|
chosen = command[1] == 'tools/prepare_business_acceptance_batch.py' and '--delivery-only' in command
|
|
output.write_text(json.dumps({'status': 'ready', 'selectionScope': 'delivery', 'selected': {'module': 'Web'}} if chosen else {'status': 'no_ready_batch'}))
|
|
return type('Result', (), {'returncode': 0, 'stdout': '', 'stderr': ''})()
|
|
with patch.object(planner, 'BATCH', output), patch.object(planner, 'feature_phase_remaining', return_value=600), \
|
|
patch.object(planner.subprocess, 'run', side_effect=run), contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(planner.main(), 0)
|
|
self.assertFalse(any('tools/prepare_failure_reconciliation_batch.py' in call for call in calls))
|
|
phases = planner.phase_order({}, 600)
|
|
self.assertTrue(any(command == ['tools/prepare_feature_capability_batch.py'] for _, command in phases))
|
|
self.assertTrue(any(command == ['tools/prepare_business_acceptance_batch.py'] for _, command in phases))
|
|
|
|
def test_remaining_module_handoff_preserves_business_batch(self):
|
|
for target in ("tools/prepare_feature_capability_batch.py", "tools/prepare_business_acceptance_batch.py"):
|
|
with self.subTest(target=target), tempfile.TemporaryDirectory() as directory:
|
|
output = Path(directory) / 'batch.json'
|
|
output.write_text('{"selected":{"module":"Assembly","tier":"feature-workflow"}}')
|
|
calls = []
|
|
selected = {"status": "ready", "batchId": "business-next", "selected": {"module": "Draft"}}
|
|
def run(command, **kwargs):
|
|
calls.append(command)
|
|
chosen = command[1:] == [target]
|
|
review = "tools/prepare_failure_reconciliation_batch.py" in command
|
|
output.write_text(json.dumps(selected if chosen else {"status": "ready", "batchId": "historical-review"}
|
|
if review else {"status": "no_ready_batch"}))
|
|
return type('Result', (), {'returncode': 0, 'stdout': '', 'stderr': ''})()
|
|
with patch.object(planner, 'BATCH', output), patch.object(planner, 'feature_phase_remaining', return_value=600), \
|
|
patch.object(planner.subprocess, 'run', side_effect=run), contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(planner.main(), 0)
|
|
self.assertEqual(json.loads(output.read_text())['batchId'], 'business-next')
|
|
self.assertFalse(any('tools/prepare_failure_reconciliation_batch.py' in call for call in calls))
|
|
|
|
def test_historical_review_remains_available_after_business_phases(self):
|
|
phases = planner.phase_order({}, 600)
|
|
self.assertEqual(phases[-1][1], ['tools/prepare_failure_reconciliation_batch.py'])
|
|
self.assertIn(('P0-observed-runtime-repair', ['tools/prepare_feature_capability_batch.py', '--runtime-only']), phases)
|
|
|
|
def test_persistence_uses_its_existing_document_worker_source(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
probes = root / 'probes'
|
|
probes.mkdir()
|
|
(probes / 'DocumentProbe.cpp').write_text('document source')
|
|
(probes / 'CMakeLists.txt').write_text('add_executable(FreeCADWebDocumentProbe DocumentProbe.cpp)')
|
|
manifest = root / 'web/public/modules/manifest.json'
|
|
manifest.parent.mkdir(parents=True)
|
|
manifest.write_text(json.dumps({'modules': {'Part': {'workers': {'part-save.worker.ts': {'runtimeUrl': '/kernel/FreeCADWebDocumentProbe.js'}}}}}))
|
|
route = root / 'web/src/module-registry.generated.ts'
|
|
route.parent.mkdir(parents=True)
|
|
route.write_text('case "Part:part.saveReopen": return "part-save.worker.ts";')
|
|
row = {'id': 'Part:save', 'command': 'part.saveReopen', 'category': 'persistence', 'worker': 'part.worker.ts'}
|
|
with patch.multiple(packets, ROOT=root, PROBE_ROOT=probes):
|
|
self.assertEqual(packets.workflow_worker_sources('Part', ['Part:save'], {'Part:save': row}), [probes / 'DocumentProbe.cpp'])
|
|
row['kind'] = 'desktop-gui'
|
|
self.assertEqual(packets.workflow_worker_sources('Part', ['Part:save'], {'Part:save': row}), [])
|
|
|
|
def test_report_keeps_full_scope_and_does_not_count_receipts_as_acceptance(self):
|
|
state = self.state()
|
|
state['receipts']['Part:save'] = {'status': 'implementation_ready'}
|
|
result = report(state)
|
|
self.assertEqual(result['fullScope'], 7)
|
|
self.assertEqual(result['deliverySubset'], 5)
|
|
self.assertEqual(result['fullAccepted'], 0)
|
|
self.assertEqual(sum(row['reusableReceipts'] for row in result['groups']), 1)
|
|
self.assertEqual(len(state['pending']), 7)
|
|
|
|
def test_product_acceptance_packet_targets_real_page_not_web_server_probe(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
current = root / 'docs/current'
|
|
current.mkdir(parents=True)
|
|
source = root / 'web/src/main.ts'
|
|
source.parent.mkdir(parents=True)
|
|
source.write_text('mountWorkspace();\n')
|
|
(root / 'web/index.html').write_text('<script src="/src/main.ts"></script>\n')
|
|
(source.parent / 'web-facade.ts').write_text('unrelatedServerFixture();\n')
|
|
(current / 'feature-capability-registry.json').write_text('{"records": []}')
|
|
(current / 'runtime-issue-ledger.json').write_text('{"issues": []}')
|
|
batch_path = current / 'batch.json'
|
|
output_path = current / 'packet.json'
|
|
batch_path.write_text(json.dumps({'status': 'ready', 'batchId': 'batch', 'selected': {
|
|
'module': 'Web', 'tier': 'business-acceptance', 'family': 'business-acceptance'},
|
|
'records': [['product:react-primary-shell', 'React shell', 'acceptance_required', 'web/src/main.ts', 1]]}))
|
|
with patch.multiple(packets, ROOT=root, BATCH_PATH=batch_path, OUTPUT_PATH=output_path, PROBE_ROOT=root / 'probes'), \
|
|
patch.object(packets, 'probe_sources', return_value=[]), patch.object(packets, 'reconcile_failures', return_value={}), \
|
|
patch.object(packets, 'module_context', return_value={}), contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(packets.main(), 0)
|
|
packet = json.loads(output_path.read_text())
|
|
self.assertEqual(packet['build']['kind'], 'frontend')
|
|
paths = {row['path'] for row in packet['editableExcerpts']}
|
|
self.assertIn('web/src/main.ts', paths)
|
|
self.assertIn('web/index.html', paths)
|
|
self.assertNotIn('web/src/web-facade.ts', paths)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|