214 lines
11 KiB
GDScript
214 lines
11 KiB
GDScript
extends SceneTree
|
|
|
|
const DraftService = preload("res://entry/initial_company_draft_service.gd")
|
|
const ContractService = preload("res://entry/guild_contract_service.gd")
|
|
|
|
const DRAFT_PATH := "user://dev3_contract_foundation_draft.json"
|
|
const COMPANY_PATH := "user://dev3_contract_foundation_company.json"
|
|
const SEED := "2468ace013579bdfeca86420fdb97531"
|
|
const OPERATION_ID := "abcdef0123456789abcdef0123456789"
|
|
const OTHER_OPERATION_ID := "0123456789abcdef0123456789abcdef"
|
|
|
|
var failures: Array[String] = []
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run_test")
|
|
|
|
|
|
func run_test() -> void:
|
|
_cleanup()
|
|
verify_v1_migration()
|
|
verify_contract_creation_and_restart()
|
|
verify_retry_conflict_and_validation()
|
|
verify_backup_and_candidate_recovery()
|
|
_cleanup()
|
|
if failures.is_empty():
|
|
print("ABG_DEV3_CONTRACT_OK migration=v1_to_v2 company=preserved contract=atomic retry=idempotent conflict=stable coin=52 line=4 board=3,4,3 prices=14+14 receipts=true restart=true backup=true candidate=true")
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
func verify_v1_migration() -> void:
|
|
var created := _create_company()
|
|
if created.is_empty():
|
|
return
|
|
var original_id := String(created.company_id)
|
|
var original_recruits := _recruit_hashes(created)
|
|
var legacy := created.duplicate(true)
|
|
legacy.company_version = 1
|
|
for field in ["company_revision", "next_contract_ordinal", "active_contract", "contract_receipts"]:
|
|
legacy.erase(field)
|
|
_refresh_hash(legacy, "company_hash")
|
|
_remove_company_files()
|
|
_write(COMPANY_PATH, JSON.stringify(_canonical(legacy)))
|
|
var loaded: Dictionary = _draft_service().load_company()
|
|
expect(loaded.ok and loaded.code == "migrated_v1", "a valid DEV-2 Company migrates exactly once")
|
|
if not loaded.ok:
|
|
return
|
|
expect(int(loaded.company.company_version) == 2 and int(loaded.company.company_revision) == 0, "migration establishes v2 revision zero")
|
|
expect(String(loaded.company.company_id) == original_id and _recruit_hashes(loaded.company) == original_recruits, "migration preserves Company and Recruit identity byte facts")
|
|
expect(loaded.company.active_contract.is_empty() and loaded.company.contract_receipts.is_empty() and int(loaded.company.next_contract_ordinal) == 0, "migration adds no Contract or transaction")
|
|
var restarted: Dictionary = _draft_service().load_company()
|
|
expect(restarted.ok and restarted.code == "loaded" and String(restarted.company.company_hash) == String(loaded.company.company_hash), "restart reads the migrated authoritative Company without a second migration")
|
|
|
|
|
|
func verify_contract_creation_and_restart() -> void:
|
|
var service := _contract_service()
|
|
var created: Dictionary = service.create_contract(OPERATION_ID, "CTR-R1-01", "standard")
|
|
expect(created.ok and created.code == "created", "explicit confirm creates the first Contract")
|
|
if not created.ok:
|
|
return
|
|
var company: Dictionary = created.company
|
|
var contract: Dictionary = created.contract
|
|
expect(int(company.company_revision) == 1 and int(company.next_contract_ordinal) == 1, "Contract creation advances Company revision and ordinal once")
|
|
expect(company.owned_recruits.size() == 2 and _draft_service().validate_company(company).ok, "Contract creation preserves the opening two and validates the one-file state")
|
|
expect(int(contract.coin) == 52 and int(contract.line_capacity) == 4 and contract.status == "market", "first Contract begins at the exact Market resources")
|
|
var board: Dictionary = contract.market_board
|
|
expect(board.recruit_offers.size() == 3 and board.equipment_offers.size() == 4 and board.battle_offers.size() == 3, "first Market persists the exact 3/4/3 offer shape")
|
|
var first_two_cost := 0
|
|
for index in range(board.recruit_offers.size()):
|
|
var offer: Dictionary = board.recruit_offers[index]
|
|
expect(int(offer.offer_level) == 1 and String(offer.profession_id).begins_with("PF-B"), "Recruit offer %d is a level-1 base Profession" % index)
|
|
expect(int(offer.list_price) == 14, "Recruit offer %d has the protected first-Market price" % index)
|
|
if index < 2:
|
|
first_two_cost += int(offer.list_price)
|
|
expect(first_two_cost == 28 and int(contract.coin) - first_two_cost == 24, "two separate protected hires total 28 and leave 24 Coin")
|
|
expect(contract.available_recruit_offer_ids.size() == 3 and contract.available_equipment_offer_ids.size() == 4 and contract.available_battle_offer_ids.size() == 3, "all generated offers begin available")
|
|
expect(contract.hire_receipts.is_empty() and company.contract_receipts == [contract.creation_receipt], "creation records one Contract receipt and no fake hire")
|
|
var restarted: Dictionary = _contract_service().load_company()
|
|
expect(restarted.ok and String(restarted.company.active_contract.market_board.board_hash) == String(board.board_hash), "restart preserves the exact first Market board")
|
|
expect(String(restarted.company.active_contract.creation_receipt.receipt_hash) == String(created.receipt.receipt_hash), "restart preserves the exact creation receipt")
|
|
|
|
|
|
func verify_retry_conflict_and_validation() -> void:
|
|
var service := _contract_service()
|
|
var before: Dictionary = service.load_company().company
|
|
var duplicate: Dictionary = service.create_contract(OPERATION_ID, "CTR-R1-01", "standard")
|
|
expect(duplicate.ok and duplicate.code == "already_created" and String(duplicate.receipt.receipt_hash) == String(before.active_contract.creation_receipt.receipt_hash), "duplicate confirm returns the original receipt")
|
|
var recovery_retry: Dictionary = service.create_contract(OTHER_OPERATION_ID, "CTR-R1-01", "standard")
|
|
expect(recovery_retry.ok and recovery_retry.code == "already_created" and String(recovery_retry.receipt.receipt_hash) == String(before.active_contract.creation_receipt.receipt_hash), "a recovered new operation ID resolves to the same Contract")
|
|
expect_rejected_unchanged(func(): return service.create_contract(OPERATION_ID, "CTR-R1-01", "wayfinder"), "CONTRACT_OPERATION_CONFLICT", "reused operation ID with changed difficulty")
|
|
expect_rejected_unchanged(func(): return service.create_contract(OTHER_OPERATION_ID, "CTR-R1-02", "standard"), "CONTRACT_LOCKED", "locked Contract")
|
|
expect_rejected_unchanged(func(): return service.create_contract(OTHER_OPERATION_ID, "CTR-R1-01", "unknown"), "CONTRACT_DIFFICULTY_INVALID", "unknown difficulty")
|
|
expect_rejected_unchanged(func(): return service.create_contract("bad-id", "CTR-R1-01", "standard"), "CONTRACT_OPERATION_ID_INVALID", "malformed operation ID")
|
|
var invalid: Dictionary = before.duplicate(true)
|
|
invalid.active_contract.market_board.recruit_offers.pop_back()
|
|
_refresh_hash(invalid.active_contract.market_board, "board_hash")
|
|
_refresh_hash(invalid.active_contract, "contract_hash")
|
|
_refresh_hash(invalid, "company_hash")
|
|
expect(_draft_service().validate_company(invalid).code == "CONTRACT_RECRUIT_OFFER_COUNT_INVALID", "hash-consistent incomplete Market board is rejected")
|
|
|
|
|
|
func verify_backup_and_candidate_recovery() -> void:
|
|
var expected: Dictionary = _contract_service().load_company().company
|
|
_write(COMPANY_PATH, "{broken primary")
|
|
var backup: Dictionary = _contract_service().load_company()
|
|
expect(backup.ok and backup.code == "recovered_backup", "valid backup repairs a corrupt Contract primary")
|
|
expect(String(backup.company.active_contract.contract_hash) == String(expected.active_contract.contract_hash), "backup recovery preserves the active Contract")
|
|
_remove_company_files()
|
|
_write("%s.candidate" % COMPANY_PATH, JSON.stringify(_canonical(expected)))
|
|
var candidate: Dictionary = _contract_service().load_company()
|
|
expect(candidate.ok and candidate.code == "recovered_candidate", "verified interrupted candidate promotes on restart")
|
|
expect(String(candidate.company.active_contract.market_board.board_hash) == String(expected.active_contract.market_board.board_hash), "candidate recovery preserves the fixed Market")
|
|
expect(FileAccess.file_exists(COMPANY_PATH) and not FileAccess.file_exists("%s.candidate" % COMPANY_PATH), "candidate recovery leaves one authoritative primary")
|
|
|
|
|
|
func _create_company() -> Dictionary:
|
|
var service := _draft_service()
|
|
var draft: Dictionary = service.begin_or_load(SEED)
|
|
if not draft.ok:
|
|
expect(false, "fixture creates the Initial Company draft")
|
|
return {}
|
|
var ids := [String(draft.draft.offers[0].offer_id), String(draft.draft.offers[1].offer_id)]
|
|
var selected: Dictionary = service.set_selection(ids)
|
|
if not selected.ok:
|
|
expect(false, "fixture selects exactly two Recruits")
|
|
return {}
|
|
var committed: Dictionary = service.commit_company()
|
|
expect(committed.ok, "fixture commits the persistent Company")
|
|
return committed.company if committed.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 _recruit_hashes(company: Dictionary) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for recruit in company.owned_recruits:
|
|
result.append(String(recruit.recruit_hash))
|
|
return result
|
|
|
|
|
|
func expect_rejected_unchanged(call: Callable, code: String, label: String) -> void:
|
|
var before: Dictionary = _contract_service().load_company().company
|
|
var result: Dictionary = call.call()
|
|
var after: Dictionary = _contract_service().load_company().company
|
|
expect(not result.ok and result.code == code and after == before, "%s rejects with %s and changes nothing" % [label, code])
|
|
|
|
|
|
func _refresh_hash(value: Dictionary, field: String) -> void:
|
|
value.erase(field)
|
|
value[field] = _hash(value)
|
|
|
|
|
|
func _write(file_path: String, text: String) -> void:
|
|
var file := FileAccess.open(file_path, FileAccess.WRITE)
|
|
file.store_string(text)
|
|
file.close()
|
|
|
|
|
|
func _remove_company_files() -> void:
|
|
for suffix in ["", ".bak", ".candidate", ".bak.candidate", ".corrupt"]:
|
|
var file_path := "%s%s" % [COMPANY_PATH, suffix]
|
|
if FileAccess.file_exists(file_path):
|
|
DirAccess.remove_absolute(file_path)
|
|
|
|
|
|
func _cleanup() -> void:
|
|
for base in [DRAFT_PATH, COMPANY_PATH]:
|
|
for suffix in ["", ".bak", ".candidate", ".bak.candidate", ".corrupt"]:
|
|
var file_path := "%s%s" % [base, suffix]
|
|
if FileAccess.file_exists(file_path):
|
|
DirAccess.remove_absolute(file_path)
|
|
|
|
|
|
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
|
|
|
|
|
|
func expect(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append("DEV-3 Contract foundation: %s" % message)
|