286 lines
16 KiB
GDScript
286 lines
16 KiB
GDScript
extends SceneTree
|
|
|
|
const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd")
|
|
const StaffTaskType = preload("res://scripts/staff/staff_task.gd")
|
|
const StaffServiceType = preload("res://scripts/staff/staff_service.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S3: Staff payroll Operations boundary")
|
|
_test_preview_and_settlement_contract()
|
|
_test_employment_effective_minute_boundary()
|
|
_test_replay_conflict_and_atomic_validation()
|
|
_test_snapshot_contract()
|
|
_test_service_owned_task_cadence()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_preview_and_settlement_contract() -> void:
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"z_active", 40, 0))
|
|
service.register_staff(_staff(&"a_active", 100, 20))
|
|
service.register_staff(_staff(&"m_departed", 70, 90))
|
|
service.remove_staff(&"m_departed", &"LEFT", true, 0)
|
|
var preview := service.preview_daily_payroll(1)
|
|
_expect(preview[&"ok"], "positive integer day previews")
|
|
_expect(
|
|
preview[&"rows"].map(func(row: Dictionary) -> StringName: return row[&"staff_id"]) \
|
|
== [&"a_active", &"m_departed", &"z_active"],
|
|
"preview rows are globally lexical"
|
|
)
|
|
_expect(preview[&"rows"][0][&"prior_arrears"] == 20 and preview[&"rows"][0][&"new_wages"] == 100, "active row separates arrears from today's wage")
|
|
_expect(preview[&"rows"][1][&"new_wages"] == 0 and preview[&"rows"][1][&"total_due"] == 90, "departed row carries debt without new wage")
|
|
_expect(preview[&"new_wages_total"] == 140, "Operations receives exact new-wage expense")
|
|
_expect(preview[&"prior_arrears_total"] == 110, "Operations receives exact carried arrears")
|
|
_expect(preview[&"total_due"] == 250, "Operations receives exact total obligation")
|
|
|
|
var applied = service.apply_daily_payroll_settlement(
|
|
&"operations.payroll.1", 1, {&"a_active": 120, &"m_departed": 30, &"z_active": 0}
|
|
)
|
|
_expect(applied.is_success() and not applied.details[&"replayed"], "settlement commits")
|
|
_expect(applied.details[&"paid_total"] == 150, "settlement reports actual cash allocation independently")
|
|
_expect(applied.details[&"unpaid_after_total"] == 100, "settlement reports remaining liability")
|
|
_expect(service.get_staff(&"a_active").unpaid_wages == 0, "full active payment clears balance")
|
|
_expect(service.get_staff(&"z_active").unpaid_wages == 40, "zero active payment becomes arrears")
|
|
_expect(service.get_departed_unpaid_wages(&"m_departed") == 60, "partial departed payment preserves remainder")
|
|
var settlement_copy := service.get_payroll_settlement(&"operations.payroll.1")
|
|
settlement_copy[&"paid_total"] = 999
|
|
_expect(service.get_payroll_settlement(&"operations.payroll.1")[&"paid_total"] == 150, "settlement query returns a defensive deep copy")
|
|
_expect(service.validate_invariants()[&"ok"], "committed settlement history satisfies Staff invariants")
|
|
|
|
var day_two := service.preview_daily_payroll(2)
|
|
_expect(day_two[&"new_wages_total"] == 140 and day_two[&"prior_arrears_total"] == 100, "next day accrues once on top of exact arrears")
|
|
service.apply_daily_payroll_settlement(
|
|
&"operations.payroll.2", 2, {&"a_active": 50, &"m_departed": 60, &"z_active": 80}
|
|
)
|
|
_expect(service.get_departed_staff_ids().is_empty(), "departed liability disappears after full paydown")
|
|
_expect(service.get_staff(&"a_active").unpaid_wages == 50, "partial next-day active payment is retained")
|
|
service.remove_staff(&"a_active", &"LEFT", true, 2)
|
|
_expect(service.get_departed_unpaid_wages(&"a_active") == 50, "departure transfers active arrears")
|
|
_expect(service.preview_daily_payroll(3)[&"rows"][0][&"new_wages"] == 0, "transferred departed debt never accrues wage")
|
|
|
|
|
|
func _test_employment_effective_minute_boundary() -> void:
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"before_boundary", 100, 0, 1_439))
|
|
service.register_staff(_staff(&"exact_boundary", 80, 35, 1_440))
|
|
service.register_staff(_staff(&"after_boundary", 60, 0, 1_441))
|
|
|
|
var day_one := service.preview_daily_payroll(1)
|
|
var rows_by_id: Dictionary = {}
|
|
for row: Dictionary in day_one[&"rows"]:
|
|
rows_by_id[row[&"staff_id"]] = row
|
|
_expect(day_one[&"ok"] and day_one[&"new_wages_total"] == 100, "day close accrues only hires strictly before its end")
|
|
_expect(rows_by_id[&"before_boundary"][&"new_wages"] == 100, "minute before boundary accrues on that day")
|
|
_expect(rows_by_id[&"exact_boundary"][&"new_wages"] == 0, "exact-boundary hire starts accruing next day")
|
|
_expect(rows_by_id[&"exact_boundary"][&"total_due"] == 35, "exact-boundary exclusion preserves prior arrears")
|
|
_expect(rows_by_id[&"after_boundary"][&"new_wages"] == 0, "post-boundary hire does not accrue early")
|
|
|
|
var settled = service.apply_daily_payroll_settlement(
|
|
&"effective.day.1", 1, {&"before_boundary": 100, &"exact_boundary": 10}
|
|
)
|
|
_expect(settled.is_success() and settled.details[&"new_wages_total"] == 100, "settlement uses the same effective-time boundary as preview")
|
|
_expect(service.get_staff(&"exact_boundary").unpaid_wages == 25, "settlement pays carried arrears without inventing a boundary-day wage")
|
|
var day_two := service.preview_daily_payroll(2)
|
|
_expect(day_two[&"new_wages_total"] == 240, "all three hires accrue at the following day close")
|
|
|
|
var invalid_service := StaffServiceType.new()
|
|
var string_minute := StaffRuntimeType.new().configure(&"string_minute", &"worker", {
|
|
&"daily_wage": 10,
|
|
&"employment_effective_minute": "1440",
|
|
})
|
|
var float_minute := StaffRuntimeType.new().configure(&"float_minute", &"worker", {
|
|
&"daily_wage": 10,
|
|
&"employment_effective_minute": 1_440.0,
|
|
})
|
|
var negative_minute := StaffRuntimeType.new().configure(&"negative_minute", &"worker", {
|
|
&"daily_wage": 10,
|
|
&"employment_effective_minute": -1,
|
|
})
|
|
_expect(not invalid_service.register_staff(string_minute).is_success(), "string employment effective option is rejected")
|
|
_expect(not invalid_service.register_staff(float_minute).is_success(), "floating employment effective option is rejected")
|
|
_expect(not invalid_service.register_staff(negative_minute).is_success(), "negative employment effective option is rejected")
|
|
_expect(invalid_service.get_staff_members().is_empty(), "invalid effective-time options never partially register")
|
|
|
|
|
|
func _test_replay_conflict_and_atomic_validation() -> void:
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"worker", 100, 0))
|
|
var before := service.create_snapshot()
|
|
_expect(not service.preview_daily_payroll(0)[&"ok"], "zero day fails closed")
|
|
_expect(not service.preview_daily_payroll("1")[&"ok"], "string day fails closed")
|
|
_expect(not service.apply_daily_payroll_settlement(&"bad id", 1, {}).is_success(), "unstable settlement ID fails closed")
|
|
_expect(not service.apply_daily_payroll_settlement(&"not_map", 1, []).is_success(), "non-map allocation fails closed")
|
|
_expect(not service.apply_daily_payroll_settlement(&"string", 1, {&"worker": "1"}).is_success(), "string payment is not coerced")
|
|
_expect(not service.apply_daily_payroll_settlement(&"float", 1, {&"worker": 1.0}).is_success(), "float payment is not coerced")
|
|
_expect(not service.apply_daily_payroll_settlement(&"negative", 1, {&"worker": -1}).is_success(), "negative payment fails closed")
|
|
_expect(not service.apply_daily_payroll_settlement(&"over", 1, {&"worker": 101}).is_success(), "overpayment fails closed")
|
|
_expect(not service.apply_daily_payroll_settlement(&"unknown", 1, {&"ghost": 0}).is_success(), "unknown Staff ID fails closed")
|
|
_expect(service.create_snapshot() == before, "all rejected allocations preserve state atomically")
|
|
|
|
var first = service.apply_daily_payroll_settlement(&"stable.1", 1, {&"worker": 25})
|
|
var committed := service.create_snapshot()
|
|
_expect(first.is_success() and service.get_staff(&"worker").unpaid_wages == 75, "valid allocation commits once")
|
|
var replay = service.apply_daily_payroll_settlement(&"stable.1", 1, {&"worker": 25})
|
|
_expect(replay.is_success() and replay.details[&"replayed"], "same ID and fingerprint replays")
|
|
_expect(service.create_snapshot() == committed, "replay never applies wages twice")
|
|
_expect(not service.apply_daily_payroll_settlement(&"stable.1", 1, {&"worker": 24}).is_success(), "same ID rejects conflicting payment")
|
|
_expect(not service.apply_daily_payroll_settlement(&"stable.1", 2, {&"worker": 25}).is_success(), "same ID rejects conflicting day")
|
|
_expect(not service.apply_daily_payroll_settlement(&"different.1", 1, {}).is_success(), "same day rejects a second ID")
|
|
_expect(service.create_snapshot() == committed, "all settlement conflicts preserve committed state")
|
|
|
|
|
|
func _test_snapshot_contract() -> void:
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"a", 100, 20))
|
|
service.register_staff(_staff(&"b", 50, 0))
|
|
service.apply_daily_payroll_settlement(&"snapshot.1", 1, {&"a": 30, &"b": 0})
|
|
service.apply_daily_payroll_settlement(&"snapshot.2", 2, {&"a": 50, &"b": 100})
|
|
var snapshot := service.create_snapshot()
|
|
var restored := StaffServiceType.new()
|
|
_expect(restored.restore_snapshot(snapshot), "v3 snapshot restores")
|
|
_expect(restored.create_snapshot() == snapshot, "v3 snapshot round trip is exact")
|
|
var before := restored.create_snapshot()
|
|
|
|
var fingerprint := snapshot.duplicate(true)
|
|
fingerprint[&"payroll_settlements"][0][&"fingerprint"] = "corrupt"
|
|
_expect(not restored.restore_snapshot(fingerprint), "derived fingerprint tamper is rejected")
|
|
_expect(restored.create_snapshot() == before, "fingerprint rejection is atomic")
|
|
var total := snapshot.duplicate(true)
|
|
total[&"payroll_settlements"][0][&"paid_total"] += 1
|
|
_expect(not restored.restore_snapshot(total), "derived total tamper is rejected")
|
|
_expect(restored.create_snapshot() == before, "total rejection is atomic")
|
|
var order := snapshot.duplicate(true)
|
|
order[&"payroll_settlements"].reverse()
|
|
_expect(not restored.restore_snapshot(order), "non-canonical settlement order is rejected")
|
|
_expect(restored.create_snapshot() == before, "order rejection is atomic")
|
|
var duplicate_day := snapshot.duplicate(true)
|
|
duplicate_day[&"payroll_settlements"][1][&"day"] = 1
|
|
_expect(not restored.restore_snapshot(duplicate_day), "duplicate settled day is rejected")
|
|
_expect(restored.create_snapshot() == before, "day uniqueness rejection is atomic")
|
|
var negative := snapshot.duplicate(true)
|
|
negative[&"payroll_settlements"][0][&"rows"][0][&"payment"] = -1
|
|
_expect(not restored.restore_snapshot(negative), "negative historical amount is rejected")
|
|
_expect(restored.create_snapshot() == before, "negative history rejection is atomic")
|
|
var unknown_field := snapshot.duplicate(true)
|
|
unknown_field[&"payroll_settlements"][0][&"extra"] = 1
|
|
_expect(not restored.restore_snapshot(unknown_field), "unknown current history field is rejected")
|
|
_expect(restored.create_snapshot() == before, "unknown field rejection is atomic")
|
|
var effective_type := snapshot.duplicate(true)
|
|
effective_type[&"staff"][0][&"employment_effective_minute"] = 1_440.0
|
|
_expect(not restored.restore_snapshot(effective_type), "floating employment effective minute is rejected")
|
|
_expect(restored.create_snapshot() == before, "effective-minute type rejection is atomic")
|
|
var effective_negative := snapshot.duplicate(true)
|
|
effective_negative[&"staff"][0][&"employment_effective_minute"] = -1
|
|
_expect(not restored.restore_snapshot(effective_negative), "negative employment effective minute is rejected")
|
|
_expect(restored.create_snapshot() == before, "negative effective-minute rejection is atomic")
|
|
var effective_missing := snapshot.duplicate(true)
|
|
effective_missing[&"staff"][0].erase(&"employment_effective_minute")
|
|
_expect(not restored.restore_snapshot(effective_missing), "current Staff snapshot rejects a missing employment effective minute")
|
|
_expect(restored.create_snapshot() == before, "missing effective-minute rejection is atomic")
|
|
var unversioned_current_row: Dictionary = snapshot[&"staff"][0].duplicate(true)
|
|
unversioned_current_row.erase(&"snapshot_version")
|
|
_expect(StaffRuntimeType.from_snapshot(unversioned_current_row) == null, "StaffRuntime rejects an effective minute without its row version")
|
|
var erased_current_row_schema := snapshot.duplicate(true)
|
|
erased_current_row_schema[&"staff"][0].erase(&"snapshot_version")
|
|
erased_current_row_schema[&"staff"][0].erase(&"employment_effective_minute")
|
|
_expect(not restored.restore_snapshot(erased_current_row_schema), "v3 rejects a Staff row disguised as pre-effective schema")
|
|
_expect(restored.create_snapshot() == before, "row-schema erasure rejection is atomic")
|
|
var effective_version := snapshot.duplicate(true)
|
|
effective_version[&"staff"][0][&"snapshot_version"] = 2
|
|
_expect(not restored.restore_snapshot(effective_version), "unknown Staff row snapshot version is rejected")
|
|
_expect(restored.create_snapshot() == before, "Staff row version rejection is atomic")
|
|
var outer_downgrade := snapshot.duplicate(true)
|
|
outer_downgrade[&"version"] = 2
|
|
_expect(not restored.restore_snapshot(outer_downgrade), "changing only the outer version cannot downgrade a v3 Staff snapshot")
|
|
_expect(restored.create_snapshot() == before, "outer version downgrade rejection is atomic")
|
|
|
|
var pre_effective_v2 := snapshot.duplicate(true)
|
|
pre_effective_v2[&"version"] = 2
|
|
pre_effective_v2.erase(&"work_assignments")
|
|
for staff_snapshot: Dictionary in pre_effective_v2[&"staff"]:
|
|
staff_snapshot.erase(&"snapshot_version")
|
|
staff_snapshot.erase(&"employment_effective_minute")
|
|
var migrated_v2 := StaffServiceType.new()
|
|
_expect(migrated_v2.restore_snapshot(pre_effective_v2), "pre-effective-minute v2 Staff snapshot migrates")
|
|
_expect(migrated_v2.get_staff(&"a").employment_effective_minute == 0, "v2 legacy Staff employment starts at minute zero")
|
|
_expect(
|
|
migrated_v2.create_snapshot()[&"version"] == StaffServiceType.SNAPSHOT_VERSION
|
|
and migrated_v2.create_snapshot()[&"staff"][0][&"snapshot_version"] == 1,
|
|
"v2 legacy Staff row is re-emitted in the current service and row schemas"
|
|
)
|
|
|
|
var legacy := snapshot.duplicate(true)
|
|
legacy[&"version"] = 1
|
|
legacy.erase(&"work_assignments")
|
|
legacy.erase(&"last_advanced_minute")
|
|
legacy.erase(&"payroll_settlements")
|
|
for staff_snapshot: Dictionary in legacy[&"staff"]:
|
|
staff_snapshot.erase(&"snapshot_version")
|
|
staff_snapshot.erase(&"employment_effective_minute")
|
|
var migrated := StaffServiceType.new()
|
|
_expect(migrated.restore_snapshot(legacy), "v1 snapshot migrates")
|
|
_expect(migrated.get_staff(&"a").unpaid_wages == 140, "v1 migration preserves active unpaid wages")
|
|
_expect(migrated.get_staff(&"a").employment_effective_minute == 0, "legacy Staff employment starts deterministically at minute zero")
|
|
_expect(migrated.create_snapshot()[&"payroll_settlements"].is_empty(), "v1 migration starts with empty settlement history")
|
|
|
|
|
|
func _test_service_owned_task_cadence() -> void:
|
|
var service := StaffServiceType.new()
|
|
var staff := _staff(&"task_worker", 0, 0)
|
|
var task := StaffTaskType.new().configure(&"task", &"work", 50, 0, {
|
|
&"required_role_tags": [&"worker"],
|
|
&"required_skill_min": {&"operation": 1},
|
|
&"task_tags": [&"work"],
|
|
&"estimated_minutes": 2,
|
|
&"target_cell": Vector3i.ZERO,
|
|
})
|
|
service.register_staff(staff)
|
|
service.register_task(task)
|
|
service.dispatch_now(0)
|
|
service.advance_one_minute(1)
|
|
_expect(task.state == StaffTaskType.State.WORKING and task.progress_minutes == 0, "RESERVED begins without same-tick work")
|
|
_expect(not service.progress_task(task.task_id, 1, 1).is_success(), "same-minute explicit progress is blocked")
|
|
service.advance_one_minute(2)
|
|
_expect(task.progress_minutes == 1, "WORKING advances exactly one minute per tick")
|
|
_expect(not service.advance_one_minute(2)[&"ok"] and task.progress_minutes == 1, "duplicate service tick is idempotently rejected")
|
|
service.advance_one_minute(3)
|
|
_expect(task.state == StaffTaskType.State.COMPLETED, "automatic work completes exactly")
|
|
|
|
|
|
func _staff(
|
|
staff_id: StringName,
|
|
daily_wage: int,
|
|
unpaid_wages: int,
|
|
employment_effective_minute: int = 0
|
|
) -> StaffRuntime:
|
|
return StaffRuntimeType.new().configure(staff_id, &"worker", {
|
|
&"state": StaffRuntimeType.State.AVAILABLE,
|
|
&"role_tags": [&"worker"],
|
|
&"permitted_task_tags": [&"work"],
|
|
&"skills": {&"operation": 10},
|
|
&"daily_wage": daily_wage,
|
|
&"employment_effective_minute": employment_effective_minute,
|
|
&"unpaid_wages": unpaid_wages,
|
|
})
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|