592 lines
25 KiB
GDScript
592 lines
25 KiB
GDScript
extends SceneTree
|
|
|
|
const InventoryServiceScript = preload("res://scripts/inventory/inventory_service.gd")
|
|
const LogisticsRequestScript = preload("res://scripts/inventory/logistics_request.gd")
|
|
const LogisticsDispatcherScript = preload("res://scripts/inventory/logistics_dispatcher.gd")
|
|
const InventoryReservationScript = preload("res://scripts/inventory/inventory_reservation.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S2: Inventory and Logistics")
|
|
_test_reservation_limits_and_idempotency()
|
|
_test_transfer_never_duplicates_stock()
|
|
_test_expiration_rules()
|
|
_test_orphan_repair_report()
|
|
_test_dispatch_priority_capacity_and_tie_break()
|
|
_test_dispatch_missing_carrier_preserves_reserved_load()
|
|
_test_atomic_transfer_batches_match_sequential_operations()
|
|
_test_idempotency_conflicts_are_preflighted()
|
|
_test_request_transitions()
|
|
_test_snapshot_round_trip_and_atomic_restore()
|
|
_test_strict_snapshot_migration_and_id_tombstones()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_reservation_limits_and_idempotency() -> void:
|
|
var service = InventoryServiceScript.new()
|
|
var added: Dictionary = service.add_stack(&"warehouse", &"meal", 10, 90, -1, 0, 2)
|
|
_expect(added[&"ok"], "stock can be added to an owner inventory")
|
|
var first: Dictionary = service.reserve_item(
|
|
&"warehouse", &"meal", 7, &"cafeteria", &"service", 10, 60, &"reserve-a"
|
|
)
|
|
_expect(first[&"ok"] and service.available_quantity(&"warehouse", &"meal", 10) == 3, "reservation reduces available but not physical quantity")
|
|
var replay: Dictionary = service.reserve_item(
|
|
&"warehouse", &"meal", 7, &"cafeteria", &"service", 10, 60, &"reserve-a"
|
|
)
|
|
_expect(replay[&"ok"] and replay[&"replay"] and service.get_reservations(true).size() == 1, "reservation operation id replays without creating stock claims")
|
|
var rejected: Dictionary = service.reserve_item(
|
|
&"warehouse", &"meal", 4, &"kitchen", &"service", 10, 60, &"reserve-b"
|
|
)
|
|
_expect(not rejected[&"ok"] and rejected[&"code"] == &"INSUFFICIENT_AVAILABLE", "over-reservation is rejected atomically")
|
|
var reservation_id: StringName = first[&"reservation_ids"][0]
|
|
var consumed: Dictionary = service.consume_reservation(reservation_id, &"consume-a", 20)
|
|
var quantity_after := service.total_quantity(&"warehouse", &"meal")
|
|
var consumed_again: Dictionary = service.consume_reservation(reservation_id, &"consume-b", 21)
|
|
_expect(consumed[&"ok"] and quantity_after == 3, "consumption removes exactly the reserved physical stock")
|
|
_expect(consumed_again[&"ok"] and consumed_again[&"replay"] and service.total_quantity(&"warehouse", &"meal") == quantity_after, "a consumed reservation cannot consume twice")
|
|
_expect(service.validate_invariants()[&"ok"], "reserve and consume preserve quantity minus reserved invariant")
|
|
|
|
|
|
func _test_transfer_never_duplicates_stock() -> void:
|
|
var service = InventoryServiceScript.new()
|
|
service.add_stack(&"source", &"material", 12, 75, 500, 3, 3)
|
|
var before := service.total_quantity(&"source", &"material") + service.total_quantity(&"destination", &"material")
|
|
var moved: Dictionary = service.transfer_available(
|
|
&"source", &"destination", &"material", 5, &"transfer-a", 20
|
|
)
|
|
var after := service.total_quantity(&"source", &"material") + service.total_quantity(&"destination", &"material")
|
|
_expect(moved[&"ok"] and before == after and after == 12, "owner transfer conserves global item quantity")
|
|
_expect(service.total_quantity(&"source", &"material") == 7 and service.total_quantity(&"destination", &"material") == 5, "transfer subtracts source before adding destination")
|
|
var replay: Dictionary = service.transfer_available(
|
|
&"source", &"destination", &"material", 5, &"transfer-a", 20
|
|
)
|
|
_expect(replay[&"replay"] and service.total_quantity(&"destination", &"material") == 5, "transfer replay cannot duplicate destination stock")
|
|
|
|
var reserved: Dictionary = service.reserve_item(
|
|
&"source", &"material", 4, &"delivery-1", &"logistics", 21, 100, &"pickup-reserve"
|
|
)
|
|
var pickup: Dictionary = service.transfer_reserved(
|
|
reserved[&"reservation_ids"], &"carrier-cargo", &"pickup-a", 22
|
|
)
|
|
_expect(pickup[&"ok"] and pickup[&"weight"] == 12, "reserved pickup transfers the exact reserved weight")
|
|
_expect(service.total_quantity(&"source", &"material") == 3 and service.total_quantity(&"carrier-cargo", &"material") == 4, "reserved pickup consumes reservation and creates cargo once")
|
|
_expect(service.validate_invariants()[&"ok"], "available and reserved remain valid after transfer")
|
|
|
|
|
|
func _test_expiration_rules() -> void:
|
|
var service = InventoryServiceScript.new()
|
|
var added: Dictionary = service.add_stack(&"cold-store", &"food", 8, 80, 30, 0, 1)
|
|
var reservation: Dictionary = service.reserve_item(
|
|
&"cold-store", &"food", 3, &"service-a", &"meal", 10, 50, &"expiry-reserve"
|
|
)
|
|
_expect(not service.is_stack_expired(added[&"stack_id"], 29), "stack remains valid before expires_at")
|
|
_expect(service.is_stack_expired(added[&"stack_id"], 30), "stack expires exactly at expires_at")
|
|
var report: Dictionary = service.process_expired(30)
|
|
_expect(report[&"discarded_quantity"] == 5 and service.total_quantity(&"cold-store", &"food") == 3, "expiration discards only unreserved quantity")
|
|
var consume: Dictionary = service.consume_reservation(reservation[&"reservation_ids"][0], &"expired-consume", 30)
|
|
_expect(not consume[&"ok"] and consume[&"code"] == &"STACK_EXPIRED", "reserved stack is checked again before consumption")
|
|
service.process_expired(50)
|
|
_expect(service.total_quantity(&"cold-store", &"food") == 0, "reservation expiry releases and discards remaining expired stock")
|
|
|
|
|
|
func _test_orphan_repair_report() -> void:
|
|
var service = InventoryServiceScript.new()
|
|
var added: Dictionary = service.add_stack(&"store", &"part", 5)
|
|
var reservation: Dictionary = service.reserve_item(
|
|
&"store", &"part", 2, &"repair-job", &"build", 0, -1, &"orphan-reserve"
|
|
)
|
|
var stacks: Dictionary = service._inventories[&"store"]
|
|
stacks.erase(added[&"stack_id"])
|
|
service._inventories[&"store"] = stacks
|
|
var report: Dictionary = service.audit_and_repair_reservations(5)
|
|
var repaired_reservation = service.get_reservation(reservation[&"reservation_ids"][0])
|
|
_expect(report[&"orphan_count"] == 1 and report[&"released_reservation_ids"].size() == 1, "orphan repair returns a structured repair report")
|
|
_expect(repaired_reservation.state == InventoryReservationScript.State.RELEASED, "orphan repair closes the dangling reservation")
|
|
|
|
|
|
func _test_dispatch_priority_capacity_and_tie_break() -> void:
|
|
var high = _reserved_request(&"request-high", 90, 6, 300)
|
|
var low = _reserved_request(&"request-low", 20, 6, 400)
|
|
var dispatcher = LogisticsDispatcherScript.new()
|
|
var carriers := {
|
|
&"carrier-b": {
|
|
&"position": Vector3i(1, 0, 1),
|
|
&"capacity": 10,
|
|
&"current_load": 0,
|
|
&"role": &"logistics",
|
|
&"path_minutes": {&"default": 5},
|
|
},
|
|
&"carrier-a": {
|
|
&"position": Vector3i(0, 0, 1),
|
|
&"capacity": 10,
|
|
&"current_load": 0,
|
|
&"role": &"logistics",
|
|
&"path_minutes": {&"default": 5},
|
|
},
|
|
}
|
|
var assignments: Array[Dictionary] = dispatcher.dispatch([low, high], carriers, 100)
|
|
_expect(assignments.size() == 2 and assignments[0][&"request_id"] == &"request-high", "dispatcher evaluates requests in deterministic priority order")
|
|
_expect(assignments[0][&"carrier_id"] == &"carrier-a", "equal scores use lexical carrier id as tie break")
|
|
_expect(assignments[1][&"carrier_id"] == &"carrier-b", "reserved load prevents assigning beyond one carrier capacity")
|
|
_expect(dispatcher.get_reserved_load(&"carrier-a") == 6 and dispatcher.get_reserved_load(&"carrier-b") == 6, "dispatcher records capacity reservations for later passes")
|
|
var reordered_dispatcher = LogisticsDispatcherScript.new()
|
|
var reordered_carriers := {
|
|
&"carrier-a": carriers[&"carrier-a"],
|
|
&"carrier-b": carriers[&"carrier-b"],
|
|
}
|
|
var reordered_assignments: Array[Dictionary] = reordered_dispatcher.dispatch([
|
|
_reserved_request(&"request-high", 90, 6, 300),
|
|
_reserved_request(&"request-low", 20, 6, 400),
|
|
], reordered_carriers, 100)
|
|
_expect(
|
|
reordered_assignments == assignments,
|
|
"one-pass carrier ordering remains deterministic across request and Dictionary insertion order"
|
|
)
|
|
|
|
|
|
func _test_dispatch_missing_carrier_preserves_reserved_load() -> void:
|
|
var dispatcher = LogisticsDispatcherScript.new()
|
|
var carrier := {
|
|
&"carrier-a": {
|
|
&"position": Vector3i.ZERO,
|
|
&"capacity": 10,
|
|
&"current_load": 0,
|
|
&"role": &"logistics",
|
|
&"path_minutes": 1,
|
|
},
|
|
}
|
|
var first = _reserved_request(&"request-carrier-present", 90, 6, 300)
|
|
_expect(
|
|
dispatcher.dispatch([first], carrier, 100).size() == 1,
|
|
"carrier accepts an assignment within capacity"
|
|
)
|
|
var second = _reserved_request(&"request-carrier-absent", 80, 6, 310)
|
|
_expect(
|
|
dispatcher.dispatch([second], {}, 101).is_empty()
|
|
and dispatcher.get_reserved_load(&"carrier-a") == 6,
|
|
"temporarily absent carrier keeps its accepted reserved load"
|
|
)
|
|
var absent_snapshot: Dictionary = dispatcher.create_snapshot()
|
|
var restored = LogisticsDispatcherScript.new()
|
|
_expect(
|
|
restored.restore_snapshot(absent_snapshot)
|
|
and restored.create_snapshot() == absent_snapshot,
|
|
"missing-carrier reservation snapshot restores exactly"
|
|
)
|
|
_expect(
|
|
restored.dispatch([second], carrier, 102).is_empty()
|
|
and restored.get_reserved_load(&"carrier-a") == 6,
|
|
"reappearing carrier cannot accept another six units into capacity ten"
|
|
)
|
|
_expect(
|
|
restored.release_assignment(&"carrier-a", 6) == 0
|
|
and restored.create_snapshot()[&"carrier_loads"].is_empty(),
|
|
"explicit assignment release removes the retained carrier load"
|
|
)
|
|
|
|
|
|
func _test_atomic_transfer_batches_match_sequential_operations() -> void:
|
|
var seed = InventoryServiceScript.new()
|
|
seed.add_stack(&"warehouse", &"supply", 4, 87, 600, 3, 2, &"batch-source")
|
|
var reservation_a: Dictionary = seed.reserve_item(
|
|
&"warehouse", &"supply", 2, &"request-a", &"delivery", 10, 100, &"batch-reserve-a"
|
|
)
|
|
var reservation_b: Dictionary = seed.reserve_item(
|
|
&"warehouse", &"supply", 2, &"request-b", &"delivery", 10, 100, &"batch-reserve-b"
|
|
)
|
|
var ids_a: Array[StringName] = []
|
|
var ids_b: Array[StringName] = []
|
|
ids_a.assign(reservation_a[&"reservation_ids"])
|
|
ids_b.assign(reservation_b[&"reservation_ids"])
|
|
|
|
var sequential = InventoryServiceScript.new()
|
|
var batched = InventoryServiceScript.new()
|
|
sequential.restore_snapshot(seed.create_snapshot())
|
|
batched.restore_snapshot(seed.create_snapshot())
|
|
sequential.transfer_reserved(ids_a, &"cargo-a", &"pickup-a", 20)
|
|
sequential.transfer_reserved(ids_b, &"cargo-b", &"pickup-b", 20)
|
|
var pickup_entries: Array[Dictionary] = [
|
|
{&"reservation_ids": ids_a, &"destination_inventory_id": &"cargo-a", &"operation_id": &"pickup-a"},
|
|
{&"reservation_ids": ids_b, &"destination_inventory_id": &"cargo-b", &"operation_id": &"pickup-b"},
|
|
]
|
|
var pickup_batch: Dictionary = batched.transfer_reserved_batch(pickup_entries, 20)
|
|
_expect(
|
|
pickup_batch[&"ok"] and pickup_batch[&"transfer_count"] == 2,
|
|
"reserved-stock batch applies every ordered pickup atomically"
|
|
)
|
|
_expect(
|
|
batched.create_snapshot() == sequential.create_snapshot(),
|
|
"reserved-stock batch is snapshot-equivalent to sequential public operations"
|
|
)
|
|
var before_replay := batched.create_snapshot()
|
|
var pickup_replay: Dictionary = batched.transfer_reserved_batch(pickup_entries, 20)
|
|
_expect(
|
|
pickup_replay[&"ok"]
|
|
and pickup_replay[&"results"][0][&"replay"]
|
|
and pickup_replay[&"results"][1][&"replay"]
|
|
and batched.create_snapshot() == before_replay,
|
|
"batch replay preserves per-operation idempotency without another transfer"
|
|
)
|
|
|
|
var pickup_snapshot := batched.create_snapshot()
|
|
sequential.transfer_available(&"cargo-a", &"destination", &"supply", 2, &"deliver-a", 21)
|
|
sequential.transfer_available(&"cargo-b", &"destination", &"supply", 2, &"deliver-b", 21)
|
|
var delivery_batch: Dictionary = batched.transfer_available_batch([
|
|
{
|
|
&"source_inventory_id": &"cargo-a",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 2,
|
|
&"operation_id": &"deliver-a",
|
|
},
|
|
{
|
|
&"source_inventory_id": &"cargo-b",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 2,
|
|
&"operation_id": &"deliver-b",
|
|
},
|
|
], 21)
|
|
_expect(
|
|
delivery_batch[&"ok"] and batched.create_snapshot() == sequential.create_snapshot(),
|
|
"available-stock batch preserves FEFO results, stack ids, and operation order"
|
|
)
|
|
|
|
var rollback = InventoryServiceScript.new()
|
|
rollback.restore_snapshot(pickup_snapshot)
|
|
var before_failure := rollback.create_snapshot()
|
|
var failed_batch: Dictionary = rollback.transfer_available_batch([
|
|
{
|
|
&"source_inventory_id": &"cargo-a",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 2,
|
|
&"operation_id": &"rollback-first",
|
|
},
|
|
{
|
|
&"source_inventory_id": &"missing-cargo",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 1,
|
|
&"operation_id": &"rollback-second",
|
|
},
|
|
], 22)
|
|
_expect(
|
|
not failed_batch[&"ok"] and failed_batch[&"code"] == &"ATOMIC_BATCH_FAILED",
|
|
"a failed line rejects the complete transfer batch"
|
|
)
|
|
_expect(
|
|
rollback.create_snapshot() == before_failure,
|
|
"failed batch rolls back stock, generated ids, and idempotency records exactly"
|
|
)
|
|
|
|
|
|
func _test_idempotency_conflicts_are_preflighted() -> void:
|
|
var sequential = InventoryServiceScript.new()
|
|
sequential.add_stack(&"source-a", &"supply", 4)
|
|
sequential.add_stack(&"source-b", &"supply", 4)
|
|
var first: Dictionary = sequential.transfer_available(
|
|
&"source-a", &"destination", &"supply", 2, &"shared-operation", 10
|
|
)
|
|
var after_first := sequential.create_snapshot()
|
|
var conflict: Dictionary = sequential.transfer_available(
|
|
&"source-b", &"destination", &"supply", 2, &"shared-operation", 10
|
|
)
|
|
_expect(first[&"ok"], "first normalized input binds its operation id")
|
|
_expect(
|
|
not conflict[&"ok"]
|
|
and conflict[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
|
and sequential.create_snapshot() == after_first,
|
|
"sequential operation-id reuse with different input is rejected without side effects"
|
|
)
|
|
var action_conflict: Dictionary = sequential.reserve_item(
|
|
&"source-a", &"supply", 1, &"requester", &"delivery", 10, -1, &"shared-operation"
|
|
)
|
|
_expect(
|
|
not action_conflict[&"ok"]
|
|
and action_conflict[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
|
and sequential.create_snapshot() == after_first,
|
|
"operation id is bound to its action as well as normalized input"
|
|
)
|
|
|
|
var duplicate_batch = InventoryServiceScript.new()
|
|
duplicate_batch.add_stack(&"source-a", &"supply", 4)
|
|
duplicate_batch.add_stack(&"source-b", &"supply", 4)
|
|
var before_duplicate := duplicate_batch.create_snapshot()
|
|
var duplicate_result: Dictionary = duplicate_batch.transfer_available_batch([
|
|
{
|
|
&"source_inventory_id": &"source-a",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 2,
|
|
&"operation_id": &"batch-shared",
|
|
},
|
|
{
|
|
&"source_inventory_id": &"source-b",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 2,
|
|
&"operation_id": &"batch-shared",
|
|
},
|
|
], 10)
|
|
_expect(
|
|
not duplicate_result[&"ok"]
|
|
and duplicate_result[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
|
and duplicate_batch.create_snapshot() == before_duplicate,
|
|
"A-to-D then B-to-D duplicate operation id is rejected before the batch mutates"
|
|
)
|
|
|
|
var existing_conflict = InventoryServiceScript.new()
|
|
existing_conflict.add_stack(&"source-a", &"supply", 4)
|
|
existing_conflict.add_stack(&"source-b", &"supply", 4)
|
|
existing_conflict.transfer_available(
|
|
&"source-a", &"destination", &"supply", 1, &"already-bound", 11
|
|
)
|
|
var before_existing_conflict := existing_conflict.create_snapshot()
|
|
var existing_result: Dictionary = existing_conflict.transfer_available_batch([
|
|
{
|
|
&"source_inventory_id": &"source-b",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 1,
|
|
&"operation_id": &"would-be-first",
|
|
},
|
|
{
|
|
&"source_inventory_id": &"source-b",
|
|
&"destination_inventory_id": &"destination",
|
|
&"item_id": &"supply",
|
|
&"quantity": 1,
|
|
&"operation_id": &"already-bound",
|
|
},
|
|
], 11)
|
|
_expect(
|
|
not existing_result[&"ok"]
|
|
and existing_result[&"code"] == &"IDEMPOTENCY_CONFLICT"
|
|
and existing_conflict.create_snapshot() == before_existing_conflict,
|
|
"existing operation-id conflict is found before an earlier batch row can apply"
|
|
)
|
|
|
|
|
|
func _test_request_transitions() -> void:
|
|
var request = _reserved_request(&"request-state", 50, 4, 200)
|
|
_expect(request.assign(&"carrier", 20), "RESERVED request accepts a carrier assignment")
|
|
_expect(request.transition_to(LogisticsRequestScript.State.PICKING_UP, "", 21), "ASSIGNED transitions to PICKING_UP")
|
|
_expect(request.transition_to(LogisticsRequestScript.State.DELIVERING, "", 22), "PICKING_UP transitions to DELIVERING")
|
|
_expect(request.transition_to(LogisticsRequestScript.State.COMPLETED, "", 23), "DELIVERING transitions to COMPLETED")
|
|
_expect(not request.transition_to(LogisticsRequestScript.State.OPEN, "", 24), "terminal logistics request cannot reopen")
|
|
|
|
|
|
func _test_snapshot_round_trip_and_atomic_restore() -> void:
|
|
var original = InventoryServiceScript.new()
|
|
original.add_stack(&"warehouse", &"supply", 10, 88, 700, 5, 2)
|
|
original.reserve_item(&"warehouse", &"supply", 4, &"facility", &"restock", 10, 80, &"snapshot-reserve")
|
|
var snapshot: Dictionary = original.create_snapshot()
|
|
var restored = InventoryServiceScript.new()
|
|
_expect(restored.restore_snapshot(snapshot), "valid inventory snapshot restores")
|
|
_expect(restored.create_snapshot() == snapshot, "inventory snapshot round trip is exact")
|
|
|
|
var before_invalid: Dictionary = restored.create_snapshot()
|
|
var invalid: Dictionary = snapshot.duplicate(true)
|
|
invalid[&"inventories"][0][&"stacks"][0][&"reserved_quantity"] = 99
|
|
_expect(not restored.restore_snapshot(invalid), "invalid inventory snapshot is rejected")
|
|
_expect(restored.create_snapshot() == before_invalid, "failed restore leaves live inventory state untouched")
|
|
|
|
var request = _reserved_request(&"request-save", 70, 3, 500)
|
|
var request_snapshot: Dictionary = request.create_snapshot()
|
|
var restored_request = LogisticsRequestScript.from_snapshot(request_snapshot)
|
|
_expect(restored_request != null and restored_request.create_snapshot() == request_snapshot, "logistics request round trip preserves state")
|
|
|
|
var dispatcher = LogisticsDispatcherScript.new()
|
|
dispatcher.dispatch([request], {
|
|
&"carrier": {
|
|
&"capacity": 10,
|
|
&"current_load": 0,
|
|
&"role": &"porter",
|
|
&"path_minutes": 2,
|
|
&"position": Vector3i.ZERO,
|
|
},
|
|
}, 40)
|
|
var dispatcher_snapshot: Dictionary = dispatcher.create_snapshot()
|
|
var restored_dispatcher = LogisticsDispatcherScript.new()
|
|
_expect(restored_dispatcher.restore_snapshot(dispatcher_snapshot), "dispatcher snapshot restores")
|
|
_expect(restored_dispatcher.create_snapshot() == dispatcher_snapshot, "dispatcher snapshot round trip is exact")
|
|
|
|
|
|
func _test_strict_snapshot_migration_and_id_tombstones() -> void:
|
|
var base = InventoryServiceScript.new()
|
|
base.add_stack(&"warehouse", &"supply", 3, 90, 100, 5, 1)
|
|
base.reserve_item(
|
|
&"warehouse", &"supply", 1, &"requester", &"delivery", 6, 90, &"legacy-reserve"
|
|
)
|
|
var snapshot: Dictionary = base.create_snapshot()
|
|
var guard = InventoryServiceScript.new()
|
|
_expect(guard.restore_snapshot(snapshot), "strict snapshot fixture restores before corruption checks")
|
|
var guard_snapshot := guard.create_snapshot()
|
|
|
|
var negative_minute := snapshot.duplicate(true)
|
|
negative_minute[&"current_minute"] = -1
|
|
var negative_created := snapshot.duplicate(true)
|
|
negative_created[&"inventories"][0][&"stacks"][0][&"created_at"] = -1
|
|
var negative_expiry := snapshot.duplicate(true)
|
|
negative_expiry[&"inventories"][0][&"stacks"][0][&"expires_at"] = -2
|
|
var invalid_terminal := snapshot.duplicate(true)
|
|
invalid_terminal[&"reservations"][0][&"terminal_at"] = 0
|
|
_expect(
|
|
not guard.restore_snapshot(negative_minute)
|
|
and not guard.restore_snapshot(negative_created)
|
|
and not guard.restore_snapshot(negative_expiry)
|
|
and not guard.restore_snapshot(invalid_terminal),
|
|
"restore rejects negative or state-inconsistent canonical timestamps instead of clamping"
|
|
)
|
|
_expect(
|
|
guard.create_snapshot() == guard_snapshot,
|
|
"strict timestamp rejection is atomic"
|
|
)
|
|
|
|
var low_stack_sequence := snapshot.duplicate(true)
|
|
low_stack_sequence[&"next_stack_sequence"] = 1
|
|
var low_reservation_sequence := snapshot.duplicate(true)
|
|
low_reservation_sequence[&"next_reservation_sequence"] = 1
|
|
_expect(
|
|
not guard.restore_snapshot(low_stack_sequence)
|
|
and not guard.restore_snapshot(low_reservation_sequence)
|
|
and guard.create_snapshot() == guard_snapshot,
|
|
"next sequences at or below existing canonical ids are atomically rejected"
|
|
)
|
|
|
|
var tombstone_source = InventoryServiceScript.new()
|
|
var first_stack: Dictionary = tombstone_source.add_stack(&"source", &"part", 1)
|
|
var reservation: Dictionary = tombstone_source.reserve_item(
|
|
&"source", &"part", 1, &"consumer", &"use", 0, -1, &"tombstone-reserve"
|
|
)
|
|
tombstone_source.consume_reservation(
|
|
reservation[&"reservation_ids"][0], &"tombstone-consume", 1
|
|
)
|
|
var tombstone_snapshot := tombstone_source.create_snapshot()
|
|
_expect(
|
|
tombstone_source.total_quantity(&"source", &"part") == 0
|
|
and tombstone_snapshot[&"known_stack_ids"].has(String(first_stack[&"stack_id"])),
|
|
"deleted generated stack id remains in persisted known-id history"
|
|
)
|
|
var corrupt_tombstone_sequence := tombstone_snapshot.duplicate(true)
|
|
corrupt_tombstone_sequence[&"next_stack_sequence"] = 1
|
|
var tombstone_restored = InventoryServiceScript.new()
|
|
_expect(
|
|
not tombstone_restored.restore_snapshot(corrupt_tombstone_sequence),
|
|
"tombstone prevents a corrupted sequence from reusing a deleted auto id"
|
|
)
|
|
_expect(
|
|
tombstone_restored.restore_snapshot(tombstone_snapshot)
|
|
and tombstone_restored.create_snapshot() == tombstone_snapshot
|
|
and tombstone_restored.add_stack(&"source", &"part", 1)[&"stack_id"] == &"stack_00000002",
|
|
"valid tombstone snapshot round-trips exactly and allocates the next unused id"
|
|
)
|
|
var legacy_deleted = InventoryServiceScript.new()
|
|
var migrated_deleted_ok := legacy_deleted.restore_snapshot(
|
|
_as_legacy_inventory_snapshot(tombstone_snapshot)
|
|
)
|
|
_expect(
|
|
migrated_deleted_ok
|
|
and legacy_deleted.create_snapshot()[&"known_stack_ids"].has("stack_00000001"),
|
|
"version-1 counter migration reconstructs tombstones for deleted generated ids"
|
|
)
|
|
|
|
var legacy_snapshot := _as_legacy_inventory_snapshot(snapshot)
|
|
var migrated = InventoryServiceScript.new()
|
|
var legacy_restored := migrated.restore_snapshot(legacy_snapshot)
|
|
var legacy_replay: Dictionary = migrated.reserve_item(
|
|
&"warehouse", &"supply", 1, &"requester", &"delivery", 6, 90, &"legacy-reserve"
|
|
)
|
|
_expect(
|
|
legacy_restored and legacy_replay[&"ok"] and legacy_replay[&"replay"],
|
|
"version-1 snapshot migrates while preserving legacy replay behavior"
|
|
)
|
|
var rebound_conflict: Dictionary = migrated.reserve_item(
|
|
&"warehouse", &"supply", 2, &"requester", &"delivery", 6, 90, &"legacy-reserve"
|
|
)
|
|
_expect(
|
|
not rebound_conflict[&"ok"] and rebound_conflict[&"code"] == &"IDEMPOTENCY_CONFLICT",
|
|
"first legacy replay upgrades the operation id to an input-bound record"
|
|
)
|
|
var migrated_snapshot: Dictionary = migrated.create_snapshot()
|
|
var migrated_roundtrip = InventoryServiceScript.new()
|
|
_expect(
|
|
migrated_snapshot[&"version"] == 2
|
|
and migrated_roundtrip.restore_snapshot(migrated_snapshot)
|
|
and migrated_roundtrip.create_snapshot() == migrated_snapshot,
|
|
"migrated version-2 snapshot is canonical and exact on subsequent restore"
|
|
)
|
|
var ambiguous_legacy := _as_legacy_inventory_snapshot(snapshot)
|
|
ambiguous_legacy[&"idempotency_results"].append({
|
|
&"operation_key": "transfer|legacy-reserve",
|
|
&"result": {&"ok": true, &"code": &"OK", &"replay": false},
|
|
})
|
|
var ambiguous = InventoryServiceScript.new()
|
|
var ambiguous_loaded := ambiguous.restore_snapshot(ambiguous_legacy)
|
|
var ambiguous_use: Dictionary = ambiguous.transfer_available(
|
|
&"warehouse", &"destination", &"supply", 1, &"legacy-reserve", 6
|
|
)
|
|
_expect(
|
|
ambiguous_loaded
|
|
and not ambiguous_use[&"ok"]
|
|
and ambiguous_use[&"code"] == &"IDEMPOTENCY_CONFLICT",
|
|
"legacy cross-action duplicate ids load compatibly and fail closed on replay"
|
|
)
|
|
|
|
|
|
func _as_legacy_inventory_snapshot(snapshot: Dictionary) -> Dictionary:
|
|
var legacy_snapshot := snapshot.duplicate(true)
|
|
legacy_snapshot[&"version"] = 1
|
|
legacy_snapshot.erase(&"known_stack_ids")
|
|
legacy_snapshot.erase(&"known_reservation_ids")
|
|
var legacy_results: Array[Dictionary] = []
|
|
for record: Dictionary in legacy_snapshot[&"idempotency_results"]:
|
|
legacy_results.append({
|
|
&"operation_key": "%s|%s" % [record[&"action"], record[&"operation_id"]],
|
|
&"result": (record[&"result"] as Dictionary).duplicate(true),
|
|
})
|
|
legacy_snapshot[&"idempotency_results"] = legacy_results
|
|
return legacy_snapshot
|
|
|
|
|
|
func _reserved_request(
|
|
request_id: StringName,
|
|
priority: int,
|
|
weight: int,
|
|
deadline: int
|
|
):
|
|
var request = LogisticsRequestScript.new().configure(
|
|
request_id,
|
|
&"warehouse",
|
|
&"facility",
|
|
{&"supply": weight},
|
|
priority,
|
|
10,
|
|
deadline,
|
|
weight
|
|
)
|
|
var reservations: Array[StringName] = [StringName("reservation-%s" % request_id)]
|
|
var stacks: Array[StringName] = [StringName("stack-%s" % request_id)]
|
|
request.mark_reserved(reservations, stacks, 11)
|
|
return request
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|