test: add M45 contract return safety coverage
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user