Files
TheTree_mock/tests/s3/test_staff_payroll_operations.gd

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)