From ecf0b8a6fd426baf0ecaefd0fd97ab5ae42dd853 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Wed, 26 Aug 2026 14:01:26 +0800 Subject: [PATCH] test: add M45 contract return safety coverage --- .../dev6/m45_contract_return_safety_test.gd | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 runtime/tests/dev6/m45_contract_return_safety_test.gd diff --git a/runtime/tests/dev6/m45_contract_return_safety_test.gd b/runtime/tests/dev6/m45_contract_return_safety_test.gd new file mode 100644 index 00000000..d98765c2 --- /dev/null +++ b/runtime/tests/dev6/m45_contract_return_safety_test.gd @@ -0,0 +1,297 @@ +extends SceneTree + +const EntryShell = preload("res://entry/entry_shell.gd") +const SettingsStore = preload("res://entry/settings_store.gd") +const DraftService = preload("res://entry/initial_company_draft_service.gd") +const ContractService = preload("res://entry/guild_contract_service.gd") +const FormationService = preload("res://entry/formation_risk_service.gd") + +const DRAFT_PATH := "user://m45_return_safety_draft.json" +const COMPANY_PATH := "user://m45_return_safety_company.json" +const SEED := "13579bdf2468ace0fedcba9876543210" +const CONTRACT_OP := "abcdefabcdefabcdefabcdefabcdefab" +const RETURN_OP := "deadbeefdeadbeefdeadbeefdeadbeef" +const CONFLICT_OP := "0123456789abcdef0123456789abcdef" +const HIRE_OP_1 := "11112222333344445555666677778888" +const HIRE_OP_2 := "88887777666655554444333322221111" + +var failures: Array[String] = [] +var parsed_touches := 0 + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + _cleanup() + var market := _create_market_company() + expect(not market.is_empty(), "fixture creates a valid pre-return Market Company") + if not market.is_empty(): + verify_legacy_market_compatibility(market) + await verify_cancel_no_mutation() + _cleanup() + var returned_fixture := _create_expanded_company() + if not returned_fixture.is_empty(): + verify_return_receipt_and_retry() + verify_receipt_validation(returned_fixture) + _cleanup() + var locked_fixture := _create_expanded_company() + if not locked_fixture.is_empty(): + verify_formation_lock() + _cleanup() + if failures.is_empty(): + print("ABG_M45_CONTRACT_RETURN_SAFETY_OK cancel_no_mutation=true receipt_identity=true receipt_validation=true confirm_once=true duplicate_retry=true conflict_reject=true restart_persistence=true formation_lock=true legacy_market_compat=true parsed_touch=%d" % parsed_touches) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func verify_legacy_market_compatibility(company: Dictionary) -> void: + var legacy := company.duplicate(true) + legacy.active_contract.erase("contract_return_receipts") + _rehash_company(legacy) + var validation: Dictionary = _draft_service().validate_company(legacy) + expect(validation.ok, "pre-M45 Market save without contract_return_receipts remains valid") + var saved: Dictionary = _draft_service().save_company(legacy) + expect(saved.ok, "pre-M45 Market save can be written without an empty receipt array") + var loaded: Dictionary = _draft_service().load_company() + expect(loaded.ok and not loaded.company.active_contract.has("contract_return_receipts"), "legacy Market reload preserves optional-field compatibility") + + +func verify_cancel_no_mutation() -> void: + _cleanup() + var company := _create_market_company() + expect(not company.is_empty(), "cancel fixture creates a Market Company") + if company.is_empty(): + return + var before: Dictionary = _draft_service().load_company() + var shell := _shell() + shell.show_market(company) + await _frames(3) + var return_button := shell.find_child("MarketReturnContract", true, false) as Button + expect(return_button != null and not return_button.disabled, "cancel fixture exposes an enabled return command") + if return_button == null: + shell.queue_free() + return + await _touch(return_button) + var cancel := shell.find_child("ContractReturnCancel", true, false) as Button + expect(shell.current_screen == "contract_return_review" and cancel != null, "return command opens the existing review") + if cancel != null: + await _touch(cancel) + var after: Dictionary = _draft_service().load_company() + expect(shell.current_screen == "market", "Cancel returns to the same Market") + expect(before.ok and after.ok and String(after.company.company_hash) == String(before.company.company_hash), "Cancel changes no persisted Company bytes") + if after.ok: + expect(String(after.company.active_contract.status) == "market" and Array(after.company.active_contract.get("contract_return_receipts", [])).is_empty(), "Cancel creates no return receipt") + shell.queue_free() + await _timer_seconds(0.1) + + +func verify_return_receipt_and_retry() -> void: + var service := _contract_service() + var before: Dictionary = service.load_company() + if not before.ok: + expect(false, "return fixture loads before confirmation") + return + var before_ids := _owned_ids(before.company) + var before_coin := int(before.company.active_contract.coin) + var before_revision := int(before.company.company_revision) + var applied: Dictionary = service.return_contract(RETURN_OP) + expect(applied.ok and applied.code == "contract_returned", "Confirm commits Contract Return once") + if not applied.ok: + return + var returned: Dictionary = applied.company + var receipts: Array = Array(returned.active_contract.get("contract_return_receipts", [])) + expect(String(returned.active_contract.status) == "returned", "Confirm marks the Contract returned") + expect(receipts.size() == 1, "Confirm writes exactly one return receipt") + expect(_owned_ids(returned) == before_ids and int(returned.active_contract.coin) == before_coin, "Confirm retains Company identities and Coin") + expect(int(returned.company_revision) == before_revision + 1, "Confirm advances Company revision exactly once") + if receipts.is_empty(): + return + var receipt: Dictionary = receipts[0] + expect(String(receipt.company_id) == String(returned.company_id) and String(receipt.run_id) == String(returned.active_contract.run_id) and String(receipt.contract_id) == String(returned.active_contract.contract_id), "Receipt identity names Company, run and Contract") + expect(String(receipt.destination) == "guild" and int(receipt.retained_coin) == before_coin and receipt.retained_recruit_ids == before_ids, "Receipt records retained values and Guild destination") + expect(String(receipt.operation_id) == RETURN_OP and String(receipt.intent_hash).length() == 64 and String(receipt.receipt_hash).length() == 64, "Receipt carries operation, intent and content hashes") + var restarted: Dictionary = _draft_service().load_company() + expect(restarted.ok and String(restarted.company.company_hash) == String(returned.company_hash), "Restart preserves returned Company state") + var duplicate: Dictionary = _contract_service().return_contract(RETURN_OP) + expect(duplicate.ok and duplicate.code == "contract_return_already_applied" and String(duplicate.receipt.get("receipt_hash", "")) == String(receipt.get("receipt_hash", "")), "Same operation returns the original receipt") + var after_duplicate: Dictionary = _draft_service().load_company() + expect(after_duplicate.ok and String(after_duplicate.company.company_hash) == String(returned.company_hash) and Array(after_duplicate.company.active_contract.contract_return_receipts).size() == 1, "Duplicate retry creates no second receipt or revision") + var conflict: Dictionary = _contract_service().return_contract(CONFLICT_OP) + expect(not conflict.ok and conflict.code == "CONTRACT_RETURN_ALREADY_APPLIED", "Different operation is rejected after return") + var after_conflict: Dictionary = _draft_service().load_company() + expect(after_conflict.ok and String(after_conflict.company.company_hash) == String(returned.company_hash), "Conflicting retry leaves state unchanged") + + +func verify_receipt_validation(_market_company: Dictionary) -> void: + _cleanup() + var fixture := _create_market_company() + var applied: Dictionary = _contract_service().return_contract(RETURN_OP) + expect(not fixture.is_empty() and applied.ok, "validator fixture commits a valid return") + if not applied.ok: + return + var valid: Dictionary = applied.company + var cases := [ + {"field": "operation_id", "value": "bad", "code": "CONTRACT_RETURN_RECEIPT_ID_INVALID"}, + {"field": "intent_hash", "value": "0".repeat(64), "code": "CONTRACT_RETURN_RECEIPT_INTENT_INVALID"}, + {"field": "retained_recruit_ids", "value": ["00000000000000000000000000000000"], "code": "CONTRACT_RETURN_RECEIPT_ROSTER_INVALID"}, + {"field": "destination", "value": "market", "code": "CONTRACT_RETURN_RECEIPT_STATE_INVALID"}, + {"field": "receipt_hash", "value": "0".repeat(64), "code": "CONTRACT_RETURN_RECEIPT_HASH_INVALID"}, + ] + for item in cases: + var tampered := valid.duplicate(true) + tampered.active_contract.contract_return_receipts[0][String(item.field)] = item.value + _rehash_company(tampered) + var result: Dictionary = _draft_service().validate_company(tampered) + expect(not result.ok and String(result.code) == String(item.code), "Receipt tamper %s is rejected with %s" % [String(item.field), String(item.code)]) + + +func verify_formation_lock() -> void: + var formation := FormationService.new() + formation.company_path = COMPANY_PATH + var begun: Dictionary = formation.begin_formation() + expect(begun.ok, "Formation fixture opens before Contract Return") + if not begun.ok: + return + var before: Dictionary = _draft_service().load_company() + var blocked: Dictionary = _contract_service().return_contract(CONFLICT_OP) + var after: Dictionary = _draft_service().load_company() + expect(not blocked.ok and blocked.code == "CONTRACT_RETURN_FORMATION_LOCKED", "Formation lock directly blocks Contract Return") + expect(before.ok and after.ok and String(after.company.company_hash) == String(before.company.company_hash), "Formation-locked return changes no Company state") + if after.ok: + expect(Array(after.company.active_contract.get("contract_return_receipts", [])).is_empty() and String(after.company.active_contract.status) == "market", "Formation lock creates no receipt and keeps Market status") + + +func _create_market_company() -> Dictionary: + var draft_service := _draft_service() + var draft: Dictionary = draft_service.begin_or_load(SEED) + if not draft.ok: + return {} + var selected: Dictionary = draft_service.set_selection([String(draft.draft.offers[0].offer_id), String(draft.draft.offers[1].offer_id)]) + var committed: Dictionary = draft_service.commit_company() + if not selected.ok or not committed.ok: + return {} + return _contract_service().create_contract(CONTRACT_OP, "CTR-R1-01", "standard").get("company", {}) + + +func _create_expanded_company() -> Dictionary: + var company := _create_market_company() + if company.is_empty(): + return {} + var offers: Array = company.active_contract.market_board.recruit_offers + var first: Dictionary = _contract_service().hire_recruit(HIRE_OP_1, String(offers[0].source_offer_id), 14) + var second: Dictionary = _contract_service().hire_recruit(HIRE_OP_2, String(offers[1].source_offer_id), 14) + return second.company if first.ok and second.ok else {} + + +func _draft_service() -> RefCounted: + var service := DraftService.new() + service.path = DRAFT_PATH + service.company_path = COMPANY_PATH + return service + + +func _contract_service() -> RefCounted: + var service := ContractService.new() + service.company_path = COMPANY_PATH + return service + + +func _shell() -> Control: + root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS + root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND + root.size = Vector2i(844, 390) + var shell := EntryShell.new() + shell.auto_start = false + shell.initial_company_service.path = DRAFT_PATH + shell.initial_company_service.company_path = COMPANY_PATH + root.add_child(shell) + var settings := SettingsStore.new().defaults() + settings.setup_completed = true + settings.locale = "en" + settings.text_scale = 1.0 + shell.settings = settings + shell.setup_draft = settings.duplicate(true) + return shell + + +func _owned_ids(company: Dictionary) -> Array[String]: + var ids: Array[String] = [] + for recruit in company.get("owned_recruits", []): + ids.append(String(recruit.get("recruit_id", ""))) + return ids + + +func _rehash_company(company: Dictionary) -> void: + var contract: Dictionary = company.active_contract + contract.erase("contract_hash") + contract.contract_hash = _hash(contract) + company.active_contract = contract + company.erase("company_hash") + company.company_hash = _hash(company) + + +func _touch(control: Control) -> void: + if control == null: + expect(false, "touch target exists") + return + var center := control.get_global_rect().get_center() + var press := InputEventScreenTouch.new() + press.position = center + press.pressed = true + press.index = 0 + Input.parse_input_event(press) + await _frames(2) + var release := InputEventScreenTouch.new() + release.position = center + release.pressed = false + release.index = 0 + Input.parse_input_event(release) + parsed_touches += 1 + await _frames(4) + + +func _frames(count: int) -> void: + for _index in range(count): + await process_frame + + +func _timer_seconds(seconds: float) -> void: + await create_timer(seconds).timeout + + +func _cleanup() -> void: + for path in [DRAFT_PATH, "%s.bak" % DRAFT_PATH, "%s.candidate" % DRAFT_PATH, "%s.bak.candidate" % DRAFT_PATH, COMPANY_PATH, "%s.bak" % COMPANY_PATH, "%s.candidate" % COMPANY_PATH, "%s.bak.candidate" % COMPANY_PATH, "%s.corrupt" % COMPANY_PATH]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("M45 Contract Return safety: %s" % message) + + +func _hash(value) -> String: + return JSON.stringify(_canonical(value)).sha256_text() + + +func _canonical(value): + if value is float and is_equal_approx(value, round(value)): + return int(round(value)) + if value is Dictionary: + var result := {} + var keys: Array = value.keys() + keys.sort_custom(func(left, right): return String(left) < String(right)) + for key in keys: + result[key] = _canonical(value[key]) + return result + if value is Array: + var result: Array = [] + for item in value: + result.append(_canonical(item)) + return result + return value