Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
853 lines
46 KiB
GDScript
853 lines
46 KiB
GDScript
extends SceneTree
|
|
|
|
const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd")
|
|
const ShiftInstanceType = preload("res://scripts/staff/shift_instance.gd")
|
|
const StaffTaskType = preload("res://scripts/staff/staff_task.gd")
|
|
const TaskDispatcherType = preload("res://scripts/staff/task_dispatcher.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 Shift and Task Dispatcher")
|
|
_test_night_shift_crosses_midnight()
|
|
_test_shift_state_machine_and_override()
|
|
_test_multiple_future_shifts_and_strict_overlap()
|
|
_test_deterministic_dispatch_and_pair_reservation()
|
|
_test_skill_score_and_tie_break()
|
|
_test_item_reservation_failure_is_atomic()
|
|
_test_interrupt_levels()
|
|
_test_fatigue_blocks_dangerous_tasks()
|
|
_test_missing_staff_reopens_task()
|
|
_test_task_progression()
|
|
_test_automatic_task_progression_once_per_tick()
|
|
_test_employment_and_daily_payroll_contract()
|
|
_test_retirement_preserves_unpaid_wages()
|
|
_test_unique_employment_protection()
|
|
_test_daily_payroll_settlement_lifecycle()
|
|
_test_payroll_validation_and_idempotency()
|
|
_test_payroll_snapshot_migration_and_tamper()
|
|
_test_remove_staff_cleans_schedule_graph()
|
|
_test_snapshot_round_trip_and_atomic_restore()
|
|
_test_orphan_recovery_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_night_shift_crosses_midnight() -> void:
|
|
var shift := ShiftInstanceType.create_standard(
|
|
&"night_d2",
|
|
&"staff_night",
|
|
&"NIGHT",
|
|
2,
|
|
&"warehouse"
|
|
)
|
|
_expect(shift != null and shift.is_valid(), "standard NIGHT Shift is valid")
|
|
_expect(shift.start_day == 2 and shift.shift_instance_id == &"night_d2", "overnight Shift keeps its start-day identity")
|
|
_expect(shift.start_at == 3_960 and shift.end_at == 4_680, "NIGHT uses exact absolute-minute bounds")
|
|
_expect(shift.crosses_midnight(), "NIGHT is marked as crossing midnight")
|
|
_expect(shift.contains_minute(4_379), "same Shift contains time before calendar midnight")
|
|
_expect(shift.contains_minute(4_380), "same Shift contains time after calendar midnight")
|
|
_expect(not shift.contains_minute(4_680), "Shift end minute is exclusive")
|
|
shift.record_duty_minute(4_379)
|
|
shift.record_duty_minute(4_380)
|
|
_expect(shift.worked_minutes == 2, "work on both calendar dates aggregates into one Shift")
|
|
var restored := ShiftInstanceType.from_snapshot(shift.create_snapshot())
|
|
_expect(restored != null and restored.create_snapshot() == shift.create_snapshot(), "overnight Shift snapshot round trip is exact")
|
|
|
|
|
|
func _test_shift_state_machine_and_override() -> void:
|
|
var service := StaffServiceType.new()
|
|
var staff := _staff(
|
|
&"shift_worker",
|
|
0,
|
|
{&"state": StaffRuntimeType.State.OFF_DUTY}
|
|
)
|
|
_expect(service.register_staff(staff).is_success(), "Staff registers before scheduling")
|
|
_expect(
|
|
service.set_schedule_override(
|
|
staff.staff_id,
|
|
1,
|
|
{&"start_minute_of_day": 420, &"end_minute_of_day": 900}
|
|
).is_success(),
|
|
"one-day ScheduleOverride is stored separately"
|
|
)
|
|
_expect(
|
|
service.create_standard_shift(staff.staff_id, &"DAY", 1, &"reception", &"shift_override").is_success(),
|
|
"standard Shift instance accepts a one-day override"
|
|
)
|
|
var shift := service.get_shift(&"shift_override")
|
|
_expect(shift.start_minute_of_day == 420 and shift.end_minute_of_day == 900, "override changes only the concrete Shift times")
|
|
_expect(shift.template_id == &"DAY", "ScheduleOverride does not rewrite the base template ID")
|
|
service.advance_one_minute(1_830)
|
|
_expect(staff.state == StaffRuntimeType.State.COMMUTING, "Staff starts commuting exactly 30 minutes before Shift")
|
|
service.advance_one_minute(1_860)
|
|
_expect(staff.state == StaffRuntimeType.State.AVAILABLE, "Staff becomes available at Shift start")
|
|
_expect(staff.assigned_facility_id == &"reception", "Shift assigns the work facility")
|
|
service.advance_one_minute(2_340)
|
|
_expect(staff.state == StaffRuntimeType.State.OFF_DUTY and staff.shift_id.is_empty(), "Staff goes off duty at Shift end")
|
|
_expect(shift.actual_ended_at == 2_340, "Shift records its exact end transition")
|
|
|
|
|
|
func _test_multiple_future_shifts_and_strict_overlap() -> void:
|
|
var service := StaffServiceType.new()
|
|
var staff := _staff(&"multi_shift_worker", 10, {&"state": StaffRuntimeType.State.OFF_DUTY})
|
|
_expect(service.register_staff(staff).is_success(), "Staff registers for multiple future Shifts")
|
|
_expect(
|
|
service.create_flex_shift(staff.staff_id, 1, 540, 600, &"workshop", &"shift_second").is_success(),
|
|
"later future Shift can be registered first"
|
|
)
|
|
_expect(
|
|
service.create_flex_shift(staff.staff_id, 1, 420, 480, &"reception", &"shift_first").is_success(),
|
|
"earlier non-overlapping Shift can be registered second"
|
|
)
|
|
_expect(staff.shift_id.is_empty(), "future Shift registration does not overwrite the active Shift pointer")
|
|
|
|
service.advance_one_minute(1_830)
|
|
_expect(
|
|
staff.shift_id == &"shift_first" and staff.state == StaffRuntimeType.State.COMMUTING,
|
|
"chronologically first Shift enters COMMUTING at its commute boundary"
|
|
)
|
|
service.advance_one_minute(1_860)
|
|
var first := service.get_shift(&"shift_first")
|
|
_expect(
|
|
staff.shift_id == first.shift_instance_id and staff.state == StaffRuntimeType.State.AVAILABLE,
|
|
"chronologically first Shift becomes ACTIVE at start"
|
|
)
|
|
_expect(first.actual_started_at == 1_860 and staff.assigned_facility_id == &"reception", "first Shift records start and facility")
|
|
service.advance_one_minute(1_920)
|
|
_expect(
|
|
first.actual_ended_at == 1_920 and staff.shift_id.is_empty() and staff.state == StaffRuntimeType.State.OFF_DUTY,
|
|
"first Shift becomes COMPLETED and releases its active pointer"
|
|
)
|
|
service.advance_one_minute(1_950)
|
|
_expect(
|
|
staff.shift_id == &"shift_second" and staff.state == StaffRuntimeType.State.COMMUTING,
|
|
"second future Shift becomes the active commute after the first completes"
|
|
)
|
|
service.advance_one_minute(1_980)
|
|
var second := service.get_shift(&"shift_second")
|
|
_expect(
|
|
staff.shift_id == second.shift_instance_id and staff.state == StaffRuntimeType.State.AVAILABLE,
|
|
"second Shift becomes ACTIVE in sequence"
|
|
)
|
|
service.advance_one_minute(2_040)
|
|
_expect(
|
|
second.actual_ended_at == 2_040 and staff.shift_id.is_empty() and staff.state == StaffRuntimeType.State.OFF_DUTY,
|
|
"second Shift becomes COMPLETED in sequence"
|
|
)
|
|
_expect(service.validate_invariants()[&"ok"], "multiple completed Shift records preserve invariants")
|
|
|
|
var overlap_service := StaffServiceType.new()
|
|
var overlap_staff := _staff(&"overlap_worker", 10, {&"state": StaffRuntimeType.State.OFF_DUTY})
|
|
overlap_service.register_staff(overlap_staff)
|
|
overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 420, 480, &"a", &"base_shift")
|
|
_expect(
|
|
not overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 400, 430, &"b", &"overlap_left").is_success(),
|
|
"overlap is rejected when the proposed Shift starts before the existing Shift"
|
|
)
|
|
_expect(
|
|
not overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 470, 500, &"b", &"overlap_right").is_success(),
|
|
"overlap is rejected when the proposed Shift starts inside the existing Shift"
|
|
)
|
|
_expect(
|
|
not overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 400, 500, &"b", &"overlap_enclosing").is_success(),
|
|
"overlap is rejected when the proposed Shift encloses the existing Shift"
|
|
)
|
|
_expect(
|
|
overlap_service.create_flex_shift(overlap_staff.staff_id, 2, 480, 510, &"b", &"adjacent_shift").is_success(),
|
|
"exactly adjacent half-open Shift intervals are accepted"
|
|
)
|
|
|
|
|
|
func _test_deterministic_dispatch_and_pair_reservation() -> void:
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"staff_b", 10))
|
|
service.register_staff(_staff(&"staff_a", 10))
|
|
service.register_task(_task(&"task_b", 50, 100))
|
|
service.register_task(_task(&"task_a", 50, 100))
|
|
var report: Dictionary = service.dispatch_now(100)
|
|
_expect(report[&"assigned"].size() == 2, "dispatcher fills two eligible Staff reservations")
|
|
_expect(report[&"assigned"][0][&"task_id"] == &"task_a", "Task tie breaks lexically after priority and deadline")
|
|
_expect(report[&"assigned"][0][&"staff_id"] == &"staff_a", "Staff score tie breaks lexically")
|
|
_expect(report[&"assigned"][1][&"task_id"] == &"task_b", "second Task remains in deterministic order")
|
|
_expect(report[&"assigned"][1][&"staff_id"] == &"staff_b", "one Staff is not assigned twice in one dispatch pass")
|
|
var task_a := service.get_task(&"task_a")
|
|
var staff_a := service.get_staff(&"staff_a")
|
|
_expect(task_a.assigned_staff_id == staff_a.staff_id, "Task points to its reserved Staff")
|
|
_expect(staff_a.current_task_id == task_a.task_id, "Staff points back to its reserved Task")
|
|
_expect(service.validate_invariants()[&"ok"], "dispatcher preserves Task/Staff bidirectional invariant")
|
|
|
|
|
|
func _test_skill_score_and_tie_break() -> void:
|
|
var dispatcher := TaskDispatcherType.new()
|
|
var task := _task(&"skill_task", 40, 0, {
|
|
&"required_skill_min": {&"operation": 5},
|
|
})
|
|
var novice := _staff(&"novice", 5)
|
|
var expert := _staff(&"expert", 30)
|
|
var novice_score := dispatcher.score_candidate(task, novice, 2)
|
|
var expert_score := dispatcher.score_candidate(task, expert, 2)
|
|
_expect(expert_score > novice_score, "higher matching Skill raises StaffTaskScore")
|
|
_expect(
|
|
dispatcher.score_candidate(task, expert, 5) < dispatcher.score_candidate(task, expert, 2),
|
|
"longer travel lowers StaffTaskScore with integer-minute penalty"
|
|
)
|
|
expert.is_overtime = true
|
|
_expect(dispatcher.score_candidate(task, expert, 2) < expert_score, "overtime applies a deterministic score penalty")
|
|
|
|
|
|
func _test_item_reservation_failure_is_atomic() -> void:
|
|
var service := StaffServiceType.new()
|
|
var reserve_calls := [0]
|
|
service.configure_callbacks(
|
|
func(_task_value: StaffTask, _minute: int) -> Dictionary:
|
|
reserve_calls[0] += 1
|
|
return {&"ok": false, &"reason": &"NO_STOCK"},
|
|
func(_ids: Array[StringName], _task_value: StaffTask, _minute: int) -> bool:
|
|
return true
|
|
)
|
|
var staff := _staff(&"stock_worker", 10)
|
|
service.register_staff(staff)
|
|
var task := _task(&"stock_task", 80, 0, {&"required_items": {&"meal": 2}})
|
|
service.register_task(task)
|
|
var report: Dictionary = service.dispatch_now(0)
|
|
_expect(reserve_calls[0] == 1 and report[&"blocked"].size() == 1, "required items are reserved before Staff mutation")
|
|
_expect(task.state == StaffTaskType.State.BLOCKED and task.assigned_staff_id.is_empty(), "stock failure sends Task to BLOCKED without half reservation")
|
|
_expect(staff.state == StaffRuntimeType.State.AVAILABLE and staff.current_task_id.is_empty(), "stock failure leaves Staff fully available")
|
|
_expect(service.validate_invariants()[&"ok"], "failed item transaction preserves Staff invariants")
|
|
|
|
|
|
func _test_interrupt_levels() -> void:
|
|
var protected_service := StaffServiceType.new()
|
|
var protected_staff := _staff(&"protected_worker", 10)
|
|
protected_service.register_staff(protected_staff)
|
|
var protected_task := _task(&"protected_task", 20, 0, {&"interrupt_level": 0})
|
|
protected_service.register_task(protected_task)
|
|
protected_service.dispatch_now(0)
|
|
protected_service.begin_task(protected_task.task_id, 0)
|
|
var replacement := _task(&"replacement", 100, 1, {&"emergency": true})
|
|
protected_service.register_task(replacement)
|
|
var protected_report: Dictionary = protected_service.dispatch_now(1)
|
|
_expect(protected_report[&"assigned"].is_empty(), "interrupt level 0 rejects even emergency replacement")
|
|
_expect(protected_task.state == StaffTaskType.State.WORKING and protected_staff.current_task_id == protected_task.task_id, "non-interruptible Task/Staff pair remains untouched")
|
|
|
|
var emergency_service := StaffServiceType.new()
|
|
var emergency_staff := _staff(&"response_worker", 10)
|
|
emergency_service.register_staff(emergency_staff)
|
|
var level_one := _task(&"level_one", 20, 0, {&"interrupt_level": 1})
|
|
emergency_service.register_task(level_one)
|
|
emergency_service.dispatch_now(0)
|
|
emergency_service.begin_task(level_one.task_id, 0)
|
|
var normal := _task(&"normal_high", 100, 1, {&"emergency": false})
|
|
var emergency := _task(&"emergency", 99, 1, {&"emergency": true})
|
|
emergency_service.register_task(normal)
|
|
emergency_service.register_task(emergency)
|
|
var emergency_report: Dictionary = emergency_service.dispatch_now(1)
|
|
_expect(emergency_report[&"assigned"].size() == 1 and emergency_report[&"assigned"][0][&"task_id"] == &"emergency", "interrupt level 1 accepts only emergency Task")
|
|
_expect(level_one.state == StaffTaskType.State.OPEN and level_one.assigned_staff_id.is_empty(), "interrupted Task returns atomically to OPEN")
|
|
_expect(emergency_staff.current_task_id == emergency.task_id and emergency.assigned_staff_id == emergency_staff.staff_id, "replacement Task and Staff reserve together")
|
|
_expect(emergency_service.validate_invariants()[&"ok"], "interrupt transaction preserves pair invariants")
|
|
|
|
|
|
func _test_fatigue_blocks_dangerous_tasks() -> void:
|
|
var service := StaffServiceType.new()
|
|
var exhausted := _staff(&"exhausted", 20, {&"fatigue": 90})
|
|
service.register_staff(exhausted)
|
|
var dangerous := _task(&"dangerous", 100, 0, {&"dangerous": true})
|
|
var safe := _task(&"rest_break", 90, 0, {&"dangerous": false, &"task_tags": [&"rest"]})
|
|
service.register_task(dangerous)
|
|
service.register_task(safe)
|
|
var report: Dictionary = service.dispatch_now(0)
|
|
_expect(dangerous.state == StaffTaskType.State.OPEN and dangerous.assigned_staff_id.is_empty(), "fatigue 90 forbids dangerous Task assignment")
|
|
_expect(report[&"assigned"].size() == 1 and safe.assigned_staff_id == exhausted.staff_id, "fatigued Staff can still receive a safe rest Task")
|
|
_expect(exhausted.current_task_id == safe.task_id, "safe Task reserves the fatigued Staff atomically")
|
|
|
|
|
|
func _test_missing_staff_reopens_task() -> void:
|
|
var service := StaffServiceType.new()
|
|
var released_ids: Array[StringName] = []
|
|
service.configure_callbacks(
|
|
func(task: StaffTask, _minute: int) -> Dictionary:
|
|
return {&"ok": true, &"reservation_ids": [StringName("reserve_%s" % task.task_id)]},
|
|
func(ids: Array[StringName], _task_value: StaffTask, _minute: int) -> bool:
|
|
for reservation_id: StringName in ids:
|
|
released_ids.append(reservation_id)
|
|
return true
|
|
)
|
|
var staff := _staff(&"vanishing_staff", 10)
|
|
var task := _task(&"orphan_task", 60, 0, {&"required_items": {&"tool": 1}})
|
|
service.register_staff(staff)
|
|
service.register_task(task)
|
|
service.dispatch_now(0)
|
|
_expect(task.state == StaffTaskType.State.RESERVED and task.reservation_ids.size() == 1, "Task owns item Reservation after successful dispatch")
|
|
service._staff.erase(staff.staff_id)
|
|
var audit: Dictionary = service.audit_orphan_tasks(1)
|
|
_expect(audit[&"reopened_task_ids"] == [&"orphan_task"], "orphan audit reports vanished Staff Task")
|
|
_expect(task.state == StaffTaskType.State.OPEN and task.assigned_staff_id.is_empty(), "vanished Staff Task returns to OPEN")
|
|
_expect(task.reservation_ids.is_empty() and released_ids == [&"reserve_orphan_task"], "orphan recovery releases every item Reservation")
|
|
_expect(service.validate_invariants()[&"ok"], "orphan recovery restores all Staff invariants")
|
|
|
|
|
|
func _test_task_progression() -> void:
|
|
var service := StaffServiceType.new()
|
|
var staff := _staff(&"progress_worker", 10)
|
|
var task := _task(&"progress_task", 50, 0, {&"estimated_minutes": 2})
|
|
service.register_staff(staff)
|
|
service.register_task(task)
|
|
service.dispatch_now(0)
|
|
_expect(service.begin_task(task.task_id, 1).is_success(), "reserved Task enters WORKING with its Staff")
|
|
var first = service.progress_task(task.task_id, 1, 2)
|
|
_expect(first.is_success() and not first.details[&"completed"], "Task remains WORKING before estimated duration")
|
|
var second = service.progress_task(task.task_id, 1, 3)
|
|
_expect(second.is_success() and second.details[&"completed"], "Task completes exactly at estimated duration")
|
|
_expect(task.state == StaffTaskType.State.COMPLETED and staff.current_task_id.is_empty(), "completion releases Staff while Task becomes terminal")
|
|
_expect(staff.state == StaffRuntimeType.State.AVAILABLE, "completed Task returns on-duty Staff to AVAILABLE")
|
|
|
|
|
|
func _test_automatic_task_progression_once_per_tick() -> void:
|
|
var service := StaffServiceType.new()
|
|
var staff := _staff(&"auto_worker", 10)
|
|
var task := _task(&"auto_task", 50, 0, {&"estimated_minutes": 2})
|
|
service.register_staff(staff)
|
|
service.register_task(task)
|
|
service.dispatch_now(0)
|
|
var begin_tick: Dictionary = service.advance_one_minute(1)
|
|
_expect(
|
|
begin_tick[&"task_actions"].size() == 1
|
|
and begin_tick[&"task_actions"][0][&"action"] == &"BEGAN_WORK",
|
|
"automatic cadence begins a RESERVED Task after its assignment minute"
|
|
)
|
|
_expect(task.state == StaffTaskType.State.WORKING and task.progress_minutes == 0, "begin tick does not also consume a work minute")
|
|
_expect(not service.progress_task(task.task_id, 1, 1).is_success(), "explicit same-minute progress cannot double apply after automatic begin")
|
|
_expect(not service.advance_one_minute(1)[&"ok"], "the same StaffService minute cannot run twice")
|
|
_expect(task.progress_minutes == 0, "duplicate tick rejection preserves Task progress")
|
|
var progress_tick: Dictionary = service.advance_one_minute(2)
|
|
_expect(
|
|
progress_tick[&"task_actions"][0][&"action"] == &"PROGRESSED"
|
|
and task.progress_minutes == 1,
|
|
"the following tick applies exactly one work minute"
|
|
)
|
|
_expect(not service.progress_task(task.task_id, 1, 2).is_success(), "automatic progress blocks a second same-minute write")
|
|
service.advance_one_minute(3)
|
|
_expect(task.state == StaffTaskType.State.COMPLETED and task.progress_minutes == 2, "automatic cadence completes at the exact estimated duration")
|
|
_expect(staff.state == StaffRuntimeType.State.AVAILABLE and staff.current_task_id.is_empty(), "automatic completion releases Staff exactly once")
|
|
|
|
var travel_service := StaffServiceType.new()
|
|
var traveler := _staff(&"travel_worker", 10, {&"current_cell": Vector3i.ZERO})
|
|
var remote := _task(&"remote_task", 50, 0, {
|
|
&"target_cell": Vector3i(2, 0, 0),
|
|
&"estimated_minutes": 1,
|
|
})
|
|
travel_service.register_staff(traveler)
|
|
travel_service.register_task(remote)
|
|
travel_service.dispatch_now(0)
|
|
travel_service.advance_one_minute(1)
|
|
_expect(remote.state == StaffTaskType.State.MOVING and remote.progress_minutes == 0, "non-arrived Staff enters MOVING without phantom work")
|
|
traveler.current_cell = remote.target_cell
|
|
travel_service.advance_one_minute(2)
|
|
_expect(remote.state == StaffTaskType.State.WORKING and remote.progress_minutes == 0, "arrival begins work without same-tick progress")
|
|
travel_service.advance_one_minute(3)
|
|
_expect(remote.state == StaffTaskType.State.COMPLETED, "arrived Staff completes through the same deterministic cadence")
|
|
|
|
|
|
func _test_employment_and_daily_payroll_contract() -> void:
|
|
var ordinary := _staff(&"staff_z", 10)
|
|
_expect(
|
|
ordinary.daily_wage == 0
|
|
and ordinary.employment_kind == StaffRuntimeType.EmploymentKind.FREE_HIRE,
|
|
"legacy generic Staff defaults to a zero-wage FREE_HIRE employment row"
|
|
)
|
|
var service := StaffServiceType.new()
|
|
_expect(service.register_staff(ordinary).is_success(), "generic Staff remains registrable without adult-content fields")
|
|
_expect(
|
|
service.register_staff(_staff(&"staff_a", 10, {
|
|
&"daily_wage": 240,
|
|
&"employment_kind": &"ADULT_CONTRACT",
|
|
})).is_success(),
|
|
"ADULT_CONTRACT is accepted as a commercial kind without duplicating the catalog gate"
|
|
)
|
|
_expect(
|
|
service.register_staff(_staff(&"staff_m", 10, {
|
|
&"daily_wage": 75,
|
|
&"employment_kind": StaffRuntimeType.EmploymentKind.UNIQUE,
|
|
})).is_success(),
|
|
"UNIQUE employment accepts the strict enum value"
|
|
)
|
|
var expected_rows: Array[Dictionary] = [
|
|
{&"staff_id": &"staff_a", &"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 240},
|
|
{&"staff_id": &"staff_m", &"employment_kind": &"UNIQUE", &"daily_wage": 0},
|
|
{&"staff_id": &"staff_z", &"employment_kind": &"FREE_HIRE", &"daily_wage": 0},
|
|
]
|
|
_expect(service.get_daily_payroll_rows() == expected_rows, "daily payroll rows have an exact shape and lexical Staff order")
|
|
_expect(service.get_daily_payroll_total() == 240, "daily payroll total is the exact sum of active Staff wages")
|
|
|
|
var invalid_service := StaffServiceType.new()
|
|
_expect(
|
|
not invalid_service.register_staff(_staff(&"string_wage", 10, {&"daily_wage": "100"})).is_success(),
|
|
"string daily wage is rejected rather than coerced"
|
|
)
|
|
_expect(
|
|
not invalid_service.register_staff(_staff(&"float_wage", 10, {&"daily_wage": 10.0})).is_success(),
|
|
"floating-point daily wage is rejected rather than truncated"
|
|
)
|
|
_expect(
|
|
not invalid_service.register_staff(_staff(&"negative_wage", 10, {&"daily_wage": -1})).is_success(),
|
|
"negative daily wage is rejected"
|
|
)
|
|
_expect(
|
|
not invalid_service.register_staff(_staff(&"unknown_kind", 10, {&"employment_kind": &"CONTRACT"})).is_success(),
|
|
"unknown employment kind is rejected"
|
|
)
|
|
_expect(invalid_service.get_staff_members().is_empty(), "invalid employment rows never partially register")
|
|
|
|
var current_snapshot := service.create_snapshot()
|
|
var exact := StaffServiceType.new()
|
|
_expect(exact.restore_snapshot(current_snapshot), "current payroll snapshot restores")
|
|
_expect(exact.create_snapshot() == current_snapshot, "current payroll snapshot round trip is exact")
|
|
var legacy_snapshot: Dictionary = current_snapshot.duplicate(true)
|
|
for staff_snapshot: Dictionary in legacy_snapshot[&"staff"]:
|
|
staff_snapshot.erase(&"daily_wage")
|
|
staff_snapshot.erase(&"employment_kind")
|
|
var migrated := StaffServiceType.new()
|
|
_expect(migrated.restore_snapshot(legacy_snapshot), "pre-payroll Staff snapshot restores through deterministic defaults")
|
|
_expect(
|
|
migrated.get_daily_payroll_total() == 0
|
|
and migrated.get_staff(&"staff_a").employment_kind == StaffRuntimeType.EmploymentKind.FREE_HIRE,
|
|
"legacy payroll migration defaults every existing Staff to zero-wage FREE_HIRE"
|
|
)
|
|
|
|
var atomic_before := exact.create_snapshot()
|
|
var negative_snapshot: Dictionary = current_snapshot.duplicate(true)
|
|
negative_snapshot[&"staff"][0][&"daily_wage"] = -1
|
|
_expect(not exact.restore_snapshot(negative_snapshot), "negative wage snapshot corruption is rejected")
|
|
_expect(exact.create_snapshot() == atomic_before, "negative wage restore failure is atomic")
|
|
var typed_snapshot: Dictionary = current_snapshot.duplicate(true)
|
|
typed_snapshot[&"staff"][0][&"daily_wage"] = "240"
|
|
_expect(not exact.restore_snapshot(typed_snapshot), "malformed wage snapshot type is rejected")
|
|
_expect(exact.create_snapshot() == atomic_before, "malformed wage restore failure is atomic")
|
|
var kind_snapshot: Dictionary = current_snapshot.duplicate(true)
|
|
kind_snapshot[&"staff"][0][&"employment_kind"] = "UNKNOWN"
|
|
_expect(not exact.restore_snapshot(kind_snapshot), "unknown employment snapshot kind is rejected")
|
|
_expect(exact.create_snapshot() == atomic_before, "unknown kind restore failure is atomic")
|
|
var unpaid_type_snapshot: Dictionary = current_snapshot.duplicate(true)
|
|
unpaid_type_snapshot[&"staff"][0][&"unpaid_wages"] = "0"
|
|
_expect(not exact.restore_snapshot(unpaid_type_snapshot), "malformed active unpaid-wage type is rejected")
|
|
_expect(exact.create_snapshot() == atomic_before, "malformed active liability restore failure is atomic")
|
|
|
|
var invalid_runtime := service.get_staff(&"staff_a")
|
|
invalid_runtime.daily_wage = -1
|
|
_expect(not service.validate_invariants()[&"ok"], "Staff invariants detect a corrupted live daily wage")
|
|
invalid_runtime.daily_wage = 240
|
|
service._departed_payroll[&"former_staff"] = -5
|
|
_expect(not service.validate_invariants()[&"ok"], "Staff invariants detect a corrupted departed wage liability")
|
|
service._departed_payroll.erase(&"former_staff")
|
|
_expect(service.validate_invariants()[&"ok"], "valid employment and payroll state satisfies all Staff invariants")
|
|
|
|
|
|
func _test_retirement_preserves_unpaid_wages() -> void:
|
|
var service := StaffServiceType.new()
|
|
var staff := _staff(&"retiring_staff", 10, {
|
|
&"daily_wage": 125,
|
|
&"employment_kind": &"ADULT_CONTRACT",
|
|
&"unpaid_wages": 375,
|
|
})
|
|
service.register_staff(staff)
|
|
var result = service.retire_staff(staff.staff_id, 50)
|
|
_expect(result.is_success() and not service.has_staff(staff.staff_id), "retirement removes active Staff after cleanup")
|
|
_expect(service.get_departed_unpaid_wages(staff.staff_id) == 375, "retirement preserves unpaid wages by former Staff ID")
|
|
_expect(service.get_daily_payroll_rows().is_empty() and service.get_daily_payroll_total() == 0, "departed Staff is removed from future daily wage expense")
|
|
var snapshot: Dictionary = service.create_snapshot()
|
|
_expect(snapshot[&"departed_payroll"][0][&"unpaid_wages"] == 375, "departed payroll liability is included in Save snapshot")
|
|
var restored := StaffServiceType.new()
|
|
_expect(restored.restore_snapshot(snapshot), "departed payroll snapshot restores exactly")
|
|
var before_corruption := restored.create_snapshot()
|
|
var malformed: Dictionary = snapshot.duplicate(true)
|
|
malformed[&"departed_payroll"][0][&"unpaid_wages"] = "375"
|
|
_expect(not restored.restore_snapshot(malformed), "malformed departed payroll amount is rejected")
|
|
_expect(restored.create_snapshot() == before_corruption, "departed payroll corruption rejection is atomic")
|
|
|
|
|
|
func _test_daily_payroll_settlement_lifecycle() -> void:
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"staff_z", 10, {&"daily_wage": 40}))
|
|
service.register_staff(_staff(&"staff_a", 10, {
|
|
&"daily_wage": 100,
|
|
&"employment_kind": &"ADULT_CONTRACT",
|
|
&"unpaid_wages": 50,
|
|
}))
|
|
service.register_staff(_staff(&"former", 10, {
|
|
&"daily_wage": 80,
|
|
&"unpaid_wages": 90,
|
|
}))
|
|
service.remove_staff(&"former", &"CONTRACT_ENDED", true, 0)
|
|
|
|
var preview: Dictionary = service.preview_daily_payroll(1)
|
|
var expected_rows: Array[Dictionary] = [
|
|
{
|
|
&"staff_id": &"former", &"payroll_status": &"DEPARTED", &"employment_kind": &"",
|
|
&"daily_wage": 0, &"prior_arrears": 90, &"new_wages": 0, &"total_due": 90,
|
|
},
|
|
{
|
|
&"staff_id": &"staff_a", &"payroll_status": &"ACTIVE", &"employment_kind": &"ADULT_CONTRACT",
|
|
&"daily_wage": 100, &"prior_arrears": 50, &"new_wages": 100, &"total_due": 150,
|
|
},
|
|
{
|
|
&"staff_id": &"staff_z", &"payroll_status": &"ACTIVE", &"employment_kind": &"FREE_HIRE",
|
|
&"daily_wage": 40, &"prior_arrears": 0, &"new_wages": 40, &"total_due": 40,
|
|
},
|
|
]
|
|
_expect(preview[&"ok"] and preview[&"rows"] == expected_rows, "payroll preview has an exact uniform shape and lexical Staff order")
|
|
_expect(
|
|
preview[&"new_wages_total"] == 140
|
|
and preview[&"prior_arrears_total"] == 140
|
|
and preview[&"total_due"] == 280,
|
|
"payroll preview strictly separates new wages, prior arrears and total due"
|
|
)
|
|
preview[&"rows"][0][&"total_due"] = 999
|
|
_expect(service.get_departed_unpaid_wages(&"former") == 90, "payroll preview is a defensive copy")
|
|
|
|
var first = service.apply_daily_payroll_settlement(
|
|
&"payroll.day.1",
|
|
1,
|
|
{&"staff_a": 150, &"former": 30, &"staff_z": 0}
|
|
)
|
|
_expect(first.is_success() and not first.details[&"replayed"], "first payroll settlement commits once")
|
|
_expect(
|
|
first.details[&"paid_total"] == 180
|
|
and first.details[&"unpaid_after_total"] == 100,
|
|
"full, partial and zero payments settle in one atomic batch"
|
|
)
|
|
_expect(service.get_staff(&"staff_a").unpaid_wages == 0, "fully paid active Staff has no arrears")
|
|
_expect(service.get_staff(&"staff_z").unpaid_wages == 40, "zero payment carries the new active wage forward")
|
|
_expect(service.get_departed_unpaid_wages(&"former") == 60, "partial departed payment reduces only preserved debt")
|
|
_expect(not service.preview_daily_payroll(1)[&"ok"], "settled day cannot be previewed as a second accrual")
|
|
|
|
var obligations := service.get_payroll_obligations()
|
|
_expect(
|
|
obligations.map(func(row: Dictionary) -> StringName: return row[&"staff_id"]) == [&"former", &"staff_a", &"staff_z"],
|
|
"Operations obligation query is ordered across active and departed Staff"
|
|
)
|
|
obligations[0][&"unpaid_wages"] = 999
|
|
_expect(service.get_departed_unpaid_wages(&"former") == 60, "obligation query returns defensive row copies")
|
|
|
|
var second_preview := service.preview_daily_payroll(2)
|
|
_expect(
|
|
second_preview[&"new_wages_total"] == 140
|
|
and second_preview[&"prior_arrears_total"] == 100
|
|
and second_preview[&"total_due"] == 240,
|
|
"next day accrues active wages once while retaining all arrears"
|
|
)
|
|
var second = service.apply_daily_payroll_settlement(
|
|
&"payroll.day.2",
|
|
2,
|
|
{&"former": 60, &"staff_a": 50, &"staff_z": 80}
|
|
)
|
|
_expect(second.is_success() and second.details[&"paid_total"] == 190, "next-day partial allocation commits")
|
|
_expect(service.get_departed_staff_ids().is_empty(), "full departed paydown removes the departed liability row")
|
|
_expect(
|
|
service.get_staff(&"staff_a").unpaid_wages == 50
|
|
and service.get_staff(&"staff_z").unpaid_wages == 0,
|
|
"active Staff carry only their exact post-payment balances"
|
|
)
|
|
service.remove_staff(&"staff_a", &"CONTRACT_ENDED", true, 10)
|
|
_expect(service.get_departed_unpaid_wages(&"staff_a") == 50, "removal after settlement preserves remaining active arrears")
|
|
var third_preview := service.preview_daily_payroll(3)
|
|
_expect(
|
|
third_preview[&"rows"][0][&"staff_id"] == &"staff_a"
|
|
and third_preview[&"rows"][0][&"new_wages"] == 0
|
|
and third_preview[&"rows"][0][&"total_due"] == 50,
|
|
"departed Staff never accrue another daily wage"
|
|
)
|
|
_expect(
|
|
service.apply_daily_payroll_settlement(&"payroll.day.3", 3, {&"staff_a": 50, &"staff_z": 40}).is_success(),
|
|
"later settlement can fully clear active and departed obligations"
|
|
)
|
|
_expect(service.get_departed_staff_ids().is_empty() and service.get_staff(&"staff_z").unpaid_wages == 0, "full settlement clears every liability")
|
|
|
|
|
|
func _test_payroll_validation_and_idempotency() -> void:
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"paid_staff", 10, {&"daily_wage": 100}))
|
|
var pristine := service.create_snapshot()
|
|
_expect(not service.preview_daily_payroll(0)[&"ok"], "zero payroll day is rejected")
|
|
_expect(not service.preview_daily_payroll("1")[&"ok"], "string payroll day is rejected rather than coerced")
|
|
_expect(not service.apply_daily_payroll_settlement(&"bad id", 1, {}).is_success(), "unstable settlement ID is rejected")
|
|
_expect(not service.apply_daily_payroll_settlement(&"bad_payments", 1, []).is_success(), "non-Dictionary payments are rejected")
|
|
_expect(not service.apply_daily_payroll_settlement(&"typed", 1, {&"paid_staff": "100"}).is_success(), "string payment is rejected")
|
|
_expect(not service.apply_daily_payroll_settlement(&"float", 1, {&"paid_staff": 100.0}).is_success(), "floating payment is rejected")
|
|
_expect(not service.apply_daily_payroll_settlement(&"negative", 1, {&"paid_staff": -1}).is_success(), "negative payment is rejected")
|
|
_expect(not service.apply_daily_payroll_settlement(&"unknown", 1, {&"missing": 0}).is_success(), "unknown Staff payment is rejected even when zero")
|
|
_expect(not service.apply_daily_payroll_settlement(&"overpay", 1, {&"paid_staff": 101}).is_success(), "payment above exact obligation is rejected")
|
|
_expect(service.create_snapshot() == pristine, "all malformed and overpayment requests are atomic")
|
|
|
|
var committed = service.apply_daily_payroll_settlement(&"stable.day.1", 1, {&"paid_staff": 25})
|
|
_expect(committed.is_success() and service.get_staff(&"paid_staff").unpaid_wages == 75, "valid partial settlement commits exact arrears")
|
|
var committed_snapshot := service.create_snapshot()
|
|
var replay = service.apply_daily_payroll_settlement(&"stable.day.1", 1, {&"paid_staff": 25})
|
|
_expect(replay.is_success() and replay.details[&"replayed"], "same settlement ID and fingerprint replays successfully")
|
|
_expect(service.create_snapshot() == committed_snapshot, "idempotent replay performs no second wage accrual or payment")
|
|
_expect(not service.apply_daily_payroll_settlement(&"stable.day.1", 1, {&"paid_staff": 24}).is_success(), "same settlement ID rejects a conflicting payment fingerprint")
|
|
_expect(not service.apply_daily_payroll_settlement(&"stable.day.1", 2, {&"paid_staff": 25}).is_success(), "same settlement ID rejects a conflicting day")
|
|
_expect(not service.apply_daily_payroll_settlement(&"other.day.1", 1, {}).is_success(), "different settlement ID cannot accrue the same day twice")
|
|
_expect(service.create_snapshot() == committed_snapshot, "all idempotency conflicts preserve committed state")
|
|
|
|
|
|
func _test_payroll_snapshot_migration_and_tamper() -> void:
|
|
var legacy_source := StaffServiceType.new()
|
|
legacy_source.register_staff(_staff(&"active_debt", 10, {&"daily_wage": 75, &"unpaid_wages": 125}))
|
|
legacy_source.register_staff(_staff(&"departed_debt", 10, {&"daily_wage": 50, &"unpaid_wages": 200}))
|
|
legacy_source.remove_staff(&"departed_debt", &"LEFT", true, 0)
|
|
var legacy: Dictionary = legacy_source.create_snapshot()
|
|
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 StaffService snapshot migrates to empty settlement history")
|
|
_expect(
|
|
migrated.get_staff(&"active_debt").unpaid_wages == 125
|
|
and migrated.get_departed_unpaid_wages(&"departed_debt") == 200,
|
|
"v1 migration preserves active and departed unpaid wages"
|
|
)
|
|
var migrated_snapshot := migrated.create_snapshot()
|
|
_expect(migrated_snapshot[&"version"] == StaffServiceType.SNAPSHOT_VERSION and migrated_snapshot[&"payroll_settlements"].is_empty(), "v1 migration emits canonical current schema with empty history")
|
|
|
|
var service := StaffServiceType.new()
|
|
service.register_staff(_staff(&"staff_a", 10, {&"daily_wage": 100, &"unpaid_wages": 20}))
|
|
service.register_staff(_staff(&"staff_b", 10, {&"daily_wage": 50}))
|
|
service.apply_daily_payroll_settlement(&"snapshot.day.1", 1, {&"staff_a": 30, &"staff_b": 0})
|
|
service.apply_daily_payroll_settlement(&"snapshot.day.2", 2, {&"staff_a": 50, &"staff_b": 100})
|
|
var snapshot: Dictionary = service.create_snapshot()
|
|
var restored := StaffServiceType.new()
|
|
_expect(restored.restore_snapshot(snapshot), "canonical current schema payroll history restores")
|
|
_expect(restored.create_snapshot() == snapshot, "current payroll settlement history round trip is exact")
|
|
var v2_snapshot: Dictionary = snapshot.duplicate(true)
|
|
v2_snapshot[&"version"] = 2
|
|
v2_snapshot.erase(&"work_assignments")
|
|
for staff_snapshot: Dictionary in v2_snapshot[&"staff"]:
|
|
staff_snapshot.erase(&"snapshot_version")
|
|
staff_snapshot.erase(&"employment_effective_minute")
|
|
var v2_migrated := StaffServiceType.new()
|
|
_expect(v2_migrated.restore_snapshot(v2_snapshot), "pre-effective-minute v2 payroll history migrates")
|
|
_expect(
|
|
v2_migrated.create_snapshot()[&"version"] == StaffServiceType.SNAPSHOT_VERSION
|
|
and v2_migrated.get_staff(&"staff_a").employment_effective_minute == 0,
|
|
"v2 migration emits canonical current schema Staff rows at effective minute zero"
|
|
)
|
|
var before_tamper := restored.create_snapshot()
|
|
|
|
var fingerprint_tamper: Dictionary = snapshot.duplicate(true)
|
|
fingerprint_tamper[&"payroll_settlements"][0][&"fingerprint"] = "0".repeat(64)
|
|
_expect(not restored.restore_snapshot(fingerprint_tamper), "tampered payroll fingerprint is rejected")
|
|
_expect(restored.create_snapshot() == before_tamper, "fingerprint rejection is atomic")
|
|
var total_tamper: Dictionary = snapshot.duplicate(true)
|
|
total_tamper[&"payroll_settlements"][0][&"total_due"] += 1
|
|
_expect(not restored.restore_snapshot(total_tamper), "tampered payroll derived total is rejected")
|
|
_expect(restored.create_snapshot() == before_tamper, "derived total rejection is atomic")
|
|
var amount_tamper: Dictionary = snapshot.duplicate(true)
|
|
amount_tamper[&"payroll_settlements"][0][&"rows"][0][&"payment"] = -1
|
|
_expect(not restored.restore_snapshot(amount_tamper), "negative historical payment is rejected")
|
|
_expect(restored.create_snapshot() == before_tamper, "negative history rejection is atomic")
|
|
var order_tamper: Dictionary = snapshot.duplicate(true)
|
|
order_tamper[&"payroll_settlements"].reverse()
|
|
_expect(not restored.restore_snapshot(order_tamper), "unordered settlement days are rejected")
|
|
_expect(restored.create_snapshot() == before_tamper, "history order rejection is atomic")
|
|
var duplicate_day: Dictionary = snapshot.duplicate(true)
|
|
duplicate_day[&"payroll_settlements"][1][&"day"] = 1
|
|
_expect(not restored.restore_snapshot(duplicate_day), "duplicate settlement day is rejected")
|
|
_expect(restored.create_snapshot() == before_tamper, "duplicate day rejection is atomic")
|
|
var extra_field: Dictionary = snapshot.duplicate(true)
|
|
extra_field[&"payroll_settlements"][0][&"unexpected"] = true
|
|
_expect(not restored.restore_snapshot(extra_field), "unknown current payroll history field is rejected")
|
|
_expect(restored.create_snapshot() == before_tamper, "unknown field rejection is atomic")
|
|
var typed_day: Dictionary = snapshot.duplicate(true)
|
|
typed_day[&"payroll_settlements"][0][&"day"] = "1"
|
|
_expect(not restored.restore_snapshot(typed_day), "string historical day is rejected rather than coerced")
|
|
_expect(restored.create_snapshot() == before_tamper, "historical type rejection is atomic")
|
|
|
|
|
|
func _test_remove_staff_cleans_schedule_graph() -> void:
|
|
var service := StaffServiceType.new()
|
|
var staff := _staff(&"scheduled_departure", 10, {&"state": StaffRuntimeType.State.OFF_DUTY})
|
|
service.register_staff(staff)
|
|
service.set_schedule_override(staff.staff_id, 3, {&"start_minute_of_day": 420})
|
|
service.set_schedule_override(staff.staff_id, 4, {&"assigned_facility_id": &"remote"})
|
|
service.create_standard_shift(staff.staff_id, &"DAY", 3, &"reception", &"departure_day_3")
|
|
service.create_standard_shift(staff.staff_id, &"DAY", 4, &"warehouse", &"departure_day_4")
|
|
var removal = service.remove_staff(staff.staff_id, &"CONTRACT_ENDED", true, 100)
|
|
_expect(removal.is_success() and not service.has_staff(staff.staff_id), "Staff removal succeeds with future schedule records")
|
|
_expect(service.get_shifts().is_empty(), "Staff removal deletes every related Shift instance")
|
|
_expect(
|
|
service.get_schedule_override(staff.staff_id, 3).is_empty()
|
|
and service.get_schedule_override(staff.staff_id, 4).is_empty(),
|
|
"Staff removal deletes every related ScheduleOverride"
|
|
)
|
|
_expect(
|
|
removal.details[&"removed_shift_ids"] == [&"departure_day_3", &"departure_day_4"]
|
|
and removal.details[&"removed_override_days"] == [3, 4],
|
|
"Staff removal reports deterministic schedule cleanup"
|
|
)
|
|
_expect(service.validate_invariants()[&"ok"], "Staff removal leaves no orphan schedule graph")
|
|
var snapshot := service.create_snapshot()
|
|
var restored := StaffServiceType.new()
|
|
_expect(restored.restore_snapshot(snapshot), "post-removal StaffService snapshot restores")
|
|
_expect(restored.create_snapshot() == snapshot, "post-removal snapshot round trip is exact")
|
|
|
|
|
|
func _test_snapshot_round_trip_and_atomic_restore() -> void:
|
|
var original := StaffServiceType.new()
|
|
var staff := _staff(&"snapshot_staff", 20)
|
|
original.register_staff(staff)
|
|
original.create_standard_shift(staff.staff_id, &"NIGHT", 0, &"warehouse", &"snapshot_shift")
|
|
original.advance_one_minute(1_080)
|
|
var task := _task(&"snapshot_task", 70, 1_080)
|
|
original.register_task(task)
|
|
original.dispatch_now(1_080)
|
|
var snapshot: Dictionary = original.create_snapshot()
|
|
var restored := StaffServiceType.new()
|
|
_expect(restored.restore_snapshot(snapshot), "valid StaffService snapshot restores")
|
|
_expect(restored.create_snapshot() == snapshot, "Staff, Shift, Task and Dispatcher round trip exactly")
|
|
_expect(restored.validate_invariants()[&"ok"], "restored Staff graph satisfies pair invariants")
|
|
|
|
var before_invalid: Dictionary = restored.create_snapshot()
|
|
var invalid: Dictionary = snapshot.duplicate(true)
|
|
invalid[&"tasks"][0][&"assigned_staff_id"] = "missing_staff"
|
|
_expect(not restored.restore_snapshot(invalid), "snapshot with orphan Task is rejected")
|
|
_expect(restored.create_snapshot() == before_invalid, "failed restore leaves live Staff state untouched")
|
|
|
|
|
|
func _test_orphan_recovery_is_atomic() -> void:
|
|
var release_outcome := {&"ok": false}
|
|
var release_calls: Array = []
|
|
var service := StaffServiceType.new()
|
|
service.configure_callbacks(
|
|
Callable(),
|
|
func(ids: Array[StringName], _task_value: StaffTask, _minute: int) -> Dictionary:
|
|
release_calls.append(ids.duplicate())
|
|
return {&"ok": bool(release_outcome[&"ok"])}
|
|
)
|
|
service.register_staff(_staff(&"live_staff", 10))
|
|
var live_before: Dictionary = service.create_snapshot()
|
|
var reopened_signals: Array[StringName] = []
|
|
service.task_reopened.connect(
|
|
func(task_id: StringName, _reason: StringName) -> void:
|
|
reopened_signals.append(task_id)
|
|
)
|
|
|
|
var candidate := StaffServiceType.new().create_snapshot()
|
|
var orphan_a := _task(&"orphan_a", 50, 0, {
|
|
&"required_items": {&"tool": 1},
|
|
&"state": StaffTaskType.State.RESERVED,
|
|
&"assigned_staff_id": &"missing_a",
|
|
&"reservation_ids": [&"reservation_a"],
|
|
})
|
|
var orphan_b := _task(&"orphan_b", 40, 0, {
|
|
&"required_items": {&"tool": 1},
|
|
&"state": StaffTaskType.State.RESERVED,
|
|
&"assigned_staff_id": &"missing_b",
|
|
&"reservation_ids": [&"reservation_b"],
|
|
})
|
|
candidate[&"tasks"] = [orphan_a.create_snapshot(), orphan_b.create_snapshot()]
|
|
_expect(not service.restore_snapshot_with_orphan_recovery(candidate), "failed external Reservation release rejects orphan recovery")
|
|
_expect(service.create_snapshot() == live_before and reopened_signals.is_empty(), "failed orphan recovery preserves live Staff snapshot and emits no repair signals")
|
|
_expect(release_calls == [[&"reservation_a", &"reservation_b"]], "all orphan Reservations use one external release transaction")
|
|
_expect(service.last_recovery_repair_report[&"release_failed_task_ids"] == [&"orphan_a", &"orphan_b"], "failed transaction reports every unrepaired Task exactly")
|
|
|
|
release_outcome[&"ok"] = true
|
|
release_calls.clear()
|
|
_expect(service.restore_snapshot_with_orphan_recovery(candidate), "successful external release commits the isolated repaired candidate")
|
|
_expect(release_calls == [[&"reservation_a", &"reservation_b"]], "successful recovery still performs exactly one Reservation transaction")
|
|
_expect(service.get_task(&"orphan_a").state == StaffTaskType.State.OPEN and service.get_task(&"orphan_b").state == StaffTaskType.State.OPEN, "committed recovery reopens only the two orphan Tasks")
|
|
_expect(reopened_signals == [&"orphan_a", &"orphan_b"] and service.validate_invariants()[&"ok"], "repair signals publish only after the valid candidate commits")
|
|
|
|
var committed_before: Dictionary = service.create_snapshot()
|
|
var signal_count_before := reopened_signals.size()
|
|
var unrelated_damage: Dictionary = candidate.duplicate(true)
|
|
var missing_staff_shift := ShiftInstanceType.create_standard(&"invalid_shift", &"missing_shift_staff", &"DAY", 0)
|
|
unrelated_damage[&"shifts"] = [missing_staff_shift.create_snapshot()]
|
|
release_calls.clear()
|
|
_expect(not service.restore_snapshot_with_orphan_recovery(unrelated_damage), "recovery rejects non-orphan Shift corruption mixed with repairable Tasks")
|
|
_expect(release_calls.is_empty(), "non-repairable damage is rejected before any external Reservation release")
|
|
_expect(service.create_snapshot() == committed_before and reopened_signals.size() == signal_count_before, "non-repairable recovery failure preserves live snapshot and signals atomically")
|
|
|
|
var exact := StaffServiceType.new()
|
|
_expect(exact.restore_snapshot_with_orphan_recovery(committed_before), "ordinary valid snapshot also restores through the recovery boundary")
|
|
_expect(exact.create_snapshot() == committed_before, "ordinary recovery-boundary round trip remains exact")
|
|
|
|
|
|
func _staff(
|
|
staff_id: StringName,
|
|
operation_skill: int,
|
|
options: Dictionary = {}
|
|
) -> StaffRuntime:
|
|
var configuration := {
|
|
&"state": StaffRuntimeType.State.AVAILABLE,
|
|
&"role_tags": [&"worker"],
|
|
&"permitted_task_tags": [&"work", &"rest"],
|
|
&"skills": {&"operation": operation_skill},
|
|
}
|
|
for key: Variant in options:
|
|
configuration[key] = options[key]
|
|
return StaffRuntimeType.new().configure(staff_id, &"worker", configuration)
|
|
|
|
|
|
func _task(
|
|
task_id: StringName,
|
|
priority: int,
|
|
created_at: int,
|
|
options: Dictionary = {}
|
|
) -> StaffTask:
|
|
var configuration := {
|
|
&"required_role_tags": [&"worker"],
|
|
&"required_skill_min": {&"operation": 1},
|
|
&"task_tags": [&"work"],
|
|
&"estimated_minutes": 5,
|
|
&"interrupt_level": 3,
|
|
}
|
|
for key: Variant in options:
|
|
configuration[key] = options[key]
|
|
return StaffTaskType.new().configure(task_id, &"work", priority, created_at, configuration)
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|
|
|
|
|
|
func _test_unique_employment_protection() -> void:
|
|
var service := StaffServiceType.new()
|
|
var unique := _staff(&"original_unique", 10, {&"employment_kind": &"UNIQUE", &"daily_wage": 75, &"unpaid_wages": 25})
|
|
_expect(service.register_staff(unique).is_success(), "legacy unique with stored wage remains loadable")
|
|
var before := service.create_snapshot()
|
|
for reason: StringName in [&"DISMISSED", &"SOLD", &"REMOVED"]:
|
|
_expect(not service.remove_staff(unique.staff_id, reason).is_success() and service.create_snapshot() == before, "unique removal is rejected atomically for %s" % reason)
|
|
_expect(not service.retire_staff(unique.staff_id).is_success() and not unique.is_retiring and service.create_snapshot() == before, "unique retirement rejects before setting retirement flag")
|
|
var preview := service.preview_daily_payroll(1)
|
|
_expect(preview[&"ok"] and preview[&"new_wages_total"] == 0 and preview[&"prior_arrears_total"] == 25 and preview[&"total_due"] == 25, "unique accrues zero wages without silently deleting historical liabilities")
|
|
_expect(service.get_daily_payroll_total() == 0 and service.get_payroll_obligations()[0][&"daily_wage"] == 0, "all payroll views use unique zero upkeep")
|
|
var restored := StaffServiceType.new()
|
|
_expect(restored.restore_snapshot(before) and restored.create_snapshot() == before and restored.get_daily_payroll_total() == 0, "unique zero-upkeep rule survives exact save round trip")
|
|
_expect(not restored.retire_staff(unique.staff_id).is_success() and restored.create_snapshot() == before, "restored unique remains protected against removal")
|
|
_expect(restored.apply_daily_payroll_settlement(&"unique_day1", 1, {unique.staff_id: 25}).is_success(), "unique settlement pays only preserved historical arrears")
|
|
_expect(restored.get_staff(unique.staff_id).unpaid_wages == 0 and restored.get_payroll_settlement(&"unique_day1")[&"new_wages_total"] == 0, "unique settlement adds no new upkeep")
|
|
var settled := restored.create_snapshot()
|
|
_expect(restored.apply_daily_payroll_settlement(&"unique_day1", 1, {unique.staff_id: 25}).is_success() and restored.create_snapshot() == settled, "unique settlement retry is idempotent")
|
|
_expect(restored.preview_daily_payroll(2)[&"total_due"] == 0, "following day keeps unique upkeep free")
|