204 lines
9.0 KiB
GDScript
204 lines
9.0 KiB
GDScript
extends SceneTree
|
|
|
|
const CommandType = preload("res://scripts/core/command.gd")
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
const CommandQueueType = preload("res://scripts/core/command_queue.gd")
|
|
const TransactionType = preload("res://scripts/core/state_transaction.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S0: Command Queue and State Transaction")
|
|
_test_deterministic_queue_order()
|
|
_test_idempotency_and_result_retention()
|
|
_test_snapshot_preserves_accepted_idempotency_history()
|
|
_test_legacy_snapshot_migrates_accepted_metadata()
|
|
_test_transaction_commit()
|
|
_test_validation_is_atomic()
|
|
_test_apply_failure_rolls_back()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_deterministic_queue_order() -> void:
|
|
var queue = CommandQueueType.new()
|
|
_expect(queue.enqueue(_command(3, 11, 100, &"three")).is_success(), "command 3 accepted")
|
|
_expect(queue.enqueue(_command(2, 10, 10, &"two")).is_success(), "command 2 accepted")
|
|
_expect(queue.enqueue(_command(1, 10, 10, &"one")).is_success(), "command 1 accepted")
|
|
_expect(queue.enqueue(_command(4, 10, 90, &"four")).is_success(), "command 4 accepted")
|
|
var ready: Array = queue.drain_ready(10)
|
|
var ids: Array[int] = []
|
|
for command in ready:
|
|
ids.append(command.command_id)
|
|
_expect(ids == [4, 1, 2], "ready order is tick, descending priority, command id")
|
|
_expect(queue.next_execute_tick() == 11, "future command remains queued")
|
|
|
|
|
|
func _test_idempotency_and_result_retention() -> void:
|
|
var queue = CommandQueueType.new()
|
|
var original = _command(10, 1, 0, &"same-operation")
|
|
_expect(queue.enqueue(original).is_success(), "idempotent command accepted once")
|
|
var duplicate = _command(11, 1, 0, &"same-operation")
|
|
var duplicate_result = queue.enqueue(duplicate)
|
|
_expect(
|
|
duplicate_result.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY,
|
|
"duplicate idempotency key rejected"
|
|
)
|
|
queue.drain_ready(1)
|
|
queue.complete(10, CommandResultType.succeeded(10, {"value": 42}))
|
|
var retained = queue.get_result_by_idempotency_key(&"same-operation")
|
|
_expect(retained != null and retained.details["value"] == 42, "processed result retained by key")
|
|
_expect(retained.is_replay, "retained lookup is marked as replay")
|
|
|
|
|
|
func _test_snapshot_preserves_accepted_idempotency_history() -> void:
|
|
var queue = CommandQueueType.new()
|
|
_expect(queue.enqueue(_command(1, 0, 10, &"processed-key")).is_success(), "snapshot fixture accepts keyed command")
|
|
_expect(queue.enqueue(_command(2, 0, 5, &"")).is_success(), "snapshot fixture accepts keyless command")
|
|
_expect(queue.enqueue(_command(3, 5, 1, &"pending-key")).is_success(), "snapshot fixture accepts pending keyed command")
|
|
var completed: Array = queue.execute_ready(0, func(command: SimulationCommand) -> CommandResult:
|
|
return CommandResultType.succeeded(command.command_id, {"completed": true})
|
|
)
|
|
_expect(completed.size() == 2, "snapshot fixture completes its ready commands")
|
|
var snapshot: Dictionary = queue.create_snapshot()
|
|
_expect(int(snapshot["version"]) == CommandQueueType.SNAPSHOT_VERSION, "Command Queue writes the current snapshot version")
|
|
_expect(snapshot["accepted"] == [
|
|
{"command_id": 1, "idempotency_key": "processed-key"},
|
|
{"command_id": 2, "idempotency_key": ""},
|
|
{"command_id": 3, "idempotency_key": "pending-key"},
|
|
], "snapshot retains accepted metadata after Command inputs leave the queue")
|
|
var restored = CommandQueueType.new()
|
|
_expect(restored.restore_snapshot(snapshot), "strict current Command Queue snapshot restores")
|
|
_expect(restored.create_snapshot() == snapshot, "current Command Queue snapshot round trip is exact")
|
|
var metadata: Array[Dictionary] = restored.get_accepted_command_metadata()
|
|
metadata[0]["idempotency_key"] = "changed"
|
|
metadata.clear()
|
|
_expect(restored.get_accepted_command_metadata() == snapshot["accepted"], "accepted metadata query is a defensive deep copy")
|
|
var live_before: Dictionary = restored.create_snapshot()
|
|
|
|
var missing_map: Dictionary = snapshot.duplicate(true)
|
|
missing_map["idempotency"].erase("processed-key")
|
|
_expect(not restored.restore_snapshot(missing_map), "processed idempotency mapping deletion is rejected")
|
|
_expect(restored.create_snapshot() == live_before, "mapping deletion rejection is restore-atomic")
|
|
var changed_metadata: Dictionary = snapshot.duplicate(true)
|
|
changed_metadata["accepted"][0]["idempotency_key"] = ""
|
|
_expect(not restored.restore_snapshot(changed_metadata), "accepted metadata cannot disagree with the reverse map")
|
|
_expect(restored.create_snapshot() == live_before, "metadata mismatch rejection is restore-atomic")
|
|
var missing_metadata: Dictionary = snapshot.duplicate(true)
|
|
missing_metadata["accepted"].remove_at(0)
|
|
_expect(not restored.restore_snapshot(missing_metadata), "every known Command requires one accepted metadata row")
|
|
var unordered_metadata: Dictionary = snapshot.duplicate(true)
|
|
var first: Dictionary = unordered_metadata["accepted"][0]
|
|
unordered_metadata["accepted"][0] = unordered_metadata["accepted"][1]
|
|
unordered_metadata["accepted"][1] = first
|
|
_expect(not restored.restore_snapshot(unordered_metadata), "accepted metadata must be ordered by Command ID")
|
|
var unknown_field: Dictionary = snapshot.duplicate(true)
|
|
unknown_field["extra"] = true
|
|
_expect(not restored.restore_snapshot(unknown_field), "current snapshot rejects unknown root fields")
|
|
var string_version: Dictionary = snapshot.duplicate(true)
|
|
string_version["version"] = "2"
|
|
_expect(not restored.restore_snapshot(string_version), "current snapshot rejects a coerced version")
|
|
var float_result_id: Dictionary = snapshot.duplicate(true)
|
|
float_result_id["processed"][0]["command_id"] = 1.0
|
|
_expect(not restored.restore_snapshot(float_result_id), "current snapshot rejects a coerced processed Command ID")
|
|
_expect(restored.create_snapshot() == live_before, "all malformed current snapshots preserve live queue state")
|
|
|
|
|
|
func _test_legacy_snapshot_migrates_accepted_metadata() -> void:
|
|
var source = CommandQueueType.new()
|
|
source.enqueue(_command(7, 0, 1, &"legacy-processed"))
|
|
source.drain_ready(0)
|
|
source.complete(7, CommandResultType.succeeded(7))
|
|
source.enqueue(_command(9, 20, 1, &"legacy-pending"))
|
|
var legacy: Dictionary = source.create_snapshot()
|
|
legacy.erase("version")
|
|
legacy.erase("accepted")
|
|
var restored = CommandQueueType.new()
|
|
_expect(restored.restore_snapshot(legacy), "legacy unversioned Command Queue snapshot restores")
|
|
var migrated: Dictionary = restored.create_snapshot()
|
|
_expect(int(migrated["version"]) == CommandQueueType.SNAPSHOT_VERSION, "legacy restore writes the current version next time")
|
|
_expect(migrated["accepted"] == [
|
|
{"command_id": 7, "idempotency_key": "legacy-processed"},
|
|
{"command_id": 9, "idempotency_key": "legacy-pending"},
|
|
], "legacy reverse map reconstructs processed accepted idempotency metadata")
|
|
_expect(restored.get_result_by_idempotency_key(&"legacy-processed") != null, "legacy processed key remains exactly-once after migration")
|
|
|
|
|
|
func _test_transaction_commit() -> void:
|
|
var state := {"gold": 100, "stock": 3}
|
|
var transaction = TransactionType.new()
|
|
transaction.stage_dictionary_increment(state, "gold", -40, 0)
|
|
transaction.stage_dictionary_increment(state, "stock", -1, 0)
|
|
_expect(state == {"gold": 100, "stock": 3}, "staging does not mutate state")
|
|
var result = transaction.commit()
|
|
_expect(result.is_success(), "valid transaction commits")
|
|
_expect(state == {"gold": 60, "stock": 2}, "all staged changes commit together")
|
|
|
|
|
|
func _test_validation_is_atomic() -> void:
|
|
var state := {"gold": 100, "stock": 3}
|
|
var transaction = TransactionType.new()
|
|
transaction.stage_dictionary_set(state, "gold", 60)
|
|
transaction.stage_dictionary_set(state, "stock", 2)
|
|
transaction.add_validator(func() -> bool: return false)
|
|
var result = transaction.commit()
|
|
_expect(result.code == CommandResultType.Code.VALIDATION_FAILED, "validation failure reported")
|
|
_expect(state == {"gold": 100, "stock": 3}, "validation failure applies no changes")
|
|
|
|
|
|
func _test_apply_failure_rolls_back() -> void:
|
|
var state := {"gold": 100, "stock": 3}
|
|
var transaction = TransactionType.new()
|
|
transaction.stage_dictionary_set(state, "gold", 60)
|
|
transaction.stage_custom(
|
|
"failing stock update",
|
|
func() -> bool: return true,
|
|
func() -> bool:
|
|
state["stock"] = 2
|
|
return false,
|
|
func() -> void: state["stock"] = 3
|
|
)
|
|
var result = transaction.commit()
|
|
_expect(
|
|
result.code == CommandResultType.Code.TRANSACTION_APPLY_FAILED,
|
|
"apply failure reported"
|
|
)
|
|
_expect(state == {"gold": 100, "stock": 3}, "applied changes roll back in reverse order")
|
|
|
|
|
|
func _command(
|
|
command_id: int,
|
|
execute_tick: int,
|
|
priority: int,
|
|
idempotency_key: StringName
|
|
):
|
|
return CommandType.new(
|
|
command_id,
|
|
0,
|
|
execute_tick,
|
|
priority,
|
|
&"Test",
|
|
&"actor",
|
|
&"target",
|
|
{},
|
|
idempotency_key
|
|
)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|