Files
TheTree_mock/tests/s2/test_economy_service.gd

193 lines
9.5 KiB
GDScript

extends SceneTree
const LedgerEntryType = preload("res://scripts/economy/ledger_entry.gd")
const EconomyServiceType = preload("res://scripts/economy/economy_service.gd")
const CommandResultType = preload("res://scripts/core/command_result.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("S2: Economy Service")
_test_initialization_and_insufficient_balance()
_test_idempotency_posts_once()
_test_reverse_and_partial_refund()
_test_compound_post_is_atomic()
_test_daily_aggregation_and_reconciliation()
_test_snapshot_round_trip_is_atomic()
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _test_initialization_and_insufficient_balance() -> void:
var economy := _economy()
var initialized = economy.initialize_account(&"PLAYER_CASH", 1_000, 360, &"init-player")
_expect(initialized.is_success(), "initial Gold is posted through SYSTEM_SOURCE")
_expect(economy.get_balance(&"PLAYER_CASH") == 1_000, "initialization debits player cash")
_expect(economy.get_balance(EconomyServiceType.SYSTEM_SOURCE) == -1_000, "SYSTEM_SOURCE is the explicit counter-account")
var entry = economy.get_transaction_entries(initialized.details[&"transaction_id"])[0]
_expect(entry.amount == 1_000 and entry.category == &"INITIALIZATION", "initialization creates a positive integer ledger entry")
var before := economy.create_snapshot()
var rejected = economy.transfer(
&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 1_001, 361, &"TEST", &"", &"too-much"
)
_expect(not rejected.is_success(), "an account without credit cannot overspend")
_expect(rejected.code == CommandResultType.Code.VALIDATION_FAILED, "insufficient Gold is a validation failure")
_expect(economy.create_snapshot() == before, "rejected transfer changes neither ledger nor balances")
_expect(not economy.register_account(&"PLAYER_CASH"), "an account definition cannot be replaced to bypass its policy")
func _test_idempotency_posts_once() -> void:
var economy := _economy()
economy.initialize_account(&"PLAYER_CASH", 500, 360, &"init")
var first = economy.transfer(
&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 125, 400, &"GIFT", &"player", &"gift-once"
)
var entry_count := economy.get_entries().size()
var duplicate = economy.transfer(
&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 125, 400, &"GIFT", &"player", &"gift-once"
)
_expect(first.is_success(), "first idempotent command posts")
_expect(duplicate.code == CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY, "duplicate idempotency key is rejected explicitly")
_expect(economy.get_entries().size() == entry_count, "duplicate command appends no second entry")
_expect(economy.get_balance(&"PLAYER_CASH") == 375, "sender is credited exactly once")
_expect(economy.get_balance(&"GUEST_WALLET:guest_1") == 125, "receiver is debited exactly once")
_expect(
economy.get_transaction_id_for_idempotency_key(&"gift-once") == first.details[&"transaction_id"],
"idempotency index resolves the original transaction"
)
func _test_reverse_and_partial_refund() -> void:
var economy := _economy()
economy.initialize_account(&"GUEST_WALLET:guest_1", 500, 360, &"init-guest")
var payment = economy.transfer(
&"GUEST_WALLET:guest_1", &"FACILITY_REVENUE:inn", 200, 500, &"SERVICE_SALE", &"inn", &"sale-1"
)
var payment_id: StringName = payment.details[&"transaction_id"]
var reversed = economy.reverse(payment_id, 510, &"reverse-sale")
_expect(reversed.is_success(), "an existing transaction reverses with a new entry")
_expect(economy.get_balance(&"GUEST_WALLET:guest_1") == 500, "reversal restores the payer balance")
_expect(economy.get_balance(&"FACILITY_REVENUE:inn") == 0, "reversal restores the receiver balance")
var reverse_entry = economy.get_transaction_entries(reversed.details[&"transaction_id"])[0]
_expect(reverse_entry.related_transaction_id == payment_id, "reversal retains the original transaction reference")
_expect(reverse_entry.debit_account == &"GUEST_WALLET:guest_1" and reverse_entry.credit_account == &"FACILITY_REVENUE:inn", "reversal swaps debit and credit")
_expect(not economy.reverse(payment_id, 511, &"reverse-again").is_success(), "a transaction cannot be fully reversed twice")
var second_payment = economy.transfer(
&"GUEST_WALLET:guest_1", &"FACILITY_REVENUE:inn", 120, 520, &"SERVICE_SALE", &"inn", &"sale-2"
)
var second_id: StringName = second_payment.details[&"transaction_id"]
_expect(economy.refund(second_id, 40, 530, &"refund-1").is_success(), "a one-line transaction supports a partial refund")
_expect(economy.refund(second_id, 80, 540, &"refund-2").is_success(), "remaining refundable Gold can be returned")
_expect(economy.get_refunded_amount(second_id) == 120, "refund total is derived from related entries")
_expect(not economy.refund(second_id, 1, 541, &"refund-over").is_success(), "refunds cannot exceed the source transaction")
func _test_compound_post_is_atomic() -> void:
var economy := _economy()
economy.initialize_account(&"PLAYER_CASH", 400, 360, &"init-player")
economy.initialize_account(&"GUEST_WALLET:guest_1", 50, 360, &"init-guest")
var before := economy.create_snapshot()
var entries: Array = [
_entry(600, &"STAFF_WALLET:staff_1", &"PLAYER_CASH", 100, &"PAYROLL"),
_entry(600, &"STAFF_WALLET:staff_1", &"GUEST_WALLET:guest_1", 51, &"PAYROLL"),
]
var rejected = economy.post(entries, &"compound-fail")
_expect(not rejected.is_success(), "one invalid line rejects a compound transaction")
_expect(economy.create_snapshot() == before, "compound failure rolls back every line and index")
entries[1].amount = 50
var posted = economy.post(entries, &"compound-ok")
_expect(posted.is_success(), "a fully valid compound transaction posts")
_expect(economy.get_transaction_entries(posted.details[&"transaction_id"]).size() == 2, "compound lines share one transaction ID")
_expect(economy.get_balance(&"STAFF_WALLET:staff_1") == 150, "compound debit deltas commit together")
_expect(economy.get_balance(&"PLAYER_CASH") == 300 and economy.get_balance(&"GUEST_WALLET:guest_1") == 0, "compound credits commit together")
func _test_daily_aggregation_and_reconciliation() -> void:
var economy := _economy()
economy.initialize_account(&"PLAYER_CASH", 1_000, 360, &"init")
economy.transfer(&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 75, 700, &"GIFT", &"player", &"gift")
economy.transfer(&"PLAYER_CASH", &"STAFF_WALLET:staff_1", 125, 1_500, &"WAGE", &"staff_1", &"wage")
var day_one := economy.get_daily_summary(1)
_expect(day_one[&"transaction_count"] == 2 and day_one[&"balanced"], "day close counts only transactions inside its minute range")
_expect(day_one[&"category_totals"][&"INITIALIZATION"] == 1_000, "day close aggregates category amounts")
_expect(day_one[&"account_totals"][&"PLAYER_CASH"][&"net"] == 925, "day close aggregates per-account debit, credit and net")
_expect(economy.validate_ledger()[&"ok"], "ledger and derived balance cache reconcile")
economy._balance_cache[&"PLAYER_CASH"] = 999_999
var mismatch := economy.validate_ledger()
_expect(not mismatch[&"ok"] and mismatch[&"errors"][-1][&"code"] == &"BALANCE_CACHE_MISMATCH", "reconciliation detects a damaged balance cache")
_expect(economy.rebuild_balance_cache(), "balance cache can be rebuilt only from valid ledger entries")
_expect(economy.get_balance(&"PLAYER_CASH") == 800, "rebuild never invents a compensating ledger entry")
_expect(economy.validate_ledger()[&"ok"], "rebuilt balance cache reconciles exactly")
func _test_snapshot_round_trip_is_atomic() -> void:
var source := _economy()
source.set_current_minute(777)
source.initialize_account(&"PLAYER_CASH", 900, 360, &"init")
source.transfer(&"PLAYER_CASH", &"GUEST_WALLET:guest_1", 120, 650, &"GIFT", &"player", &"gift")
var snapshot := source.create_snapshot()
var restored := EconomyServiceType.new()
_expect(restored.restore_snapshot(snapshot), "valid economy snapshot restores")
_expect(restored.create_snapshot() == snapshot, "economy snapshot round trip is exact")
_expect(restored.validate_ledger()[&"ok"], "restored ledger and balances reconcile")
var before_invalid := restored.create_snapshot()
var corrupt := snapshot.duplicate(true)
corrupt[&"entries"][0][&"amount"] = 0
_expect(not restored.restore_snapshot(corrupt), "snapshot containing a zero-amount entry is rejected")
_expect(restored.create_snapshot() == before_invalid, "failed snapshot restore is fully atomic")
var duplicate_account := snapshot.duplicate(true)
duplicate_account[&"accounts"].append(duplicate_account[&"accounts"][0].duplicate(true))
_expect(not restored.restore_snapshot(duplicate_account), "snapshot with duplicate account definitions is rejected")
_expect(restored.create_snapshot() == before_invalid, "second rejected restore also preserves live state")
func _economy() -> EconomyService:
return EconomyServiceType.new({
&"PLAYER_CASH": {&"allow_negative": false},
&"GUEST_WALLET:guest_1": {&"allow_negative": false},
&"FACILITY_REVENUE:inn": {&"allow_negative": false},
&"STAFF_WALLET:staff_1": {&"allow_negative": false},
&"STAFF_PAYABLE:staff_1": {&"allow_negative": true},
})
func _entry(
absolute_minute: int,
debit_account: StringName,
credit_account: StringName,
amount: int,
category: StringName
) -> LedgerEntry:
return LedgerEntryType.new().configure(
&"",
absolute_minute,
debit_account,
credit_account,
amount,
category
)
func _expect(condition: bool, label: String) -> void:
_checks += 1
if condition:
print(" ✓ %s" % label)
return
_failures += 1
push_error(" ✗ %s" % label)