P16-01-DISMISS: 一般従業員の通常操作での解雇(退職金を含む。P16-01 の補助の作業。原単位への加算 0)
This commit is contained in:
+1
-1
@@ -3997,7 +3997,7 @@
|
||||
"template": "PASS: <checks> checks",
|
||||
"required": true,
|
||||
"fixed_check_count_used": false,
|
||||
"source_sha256_at_schema_audit": "e325c7ac24f979bc8ac3f4222279c317b2603ef20cae046475647dd90d423123",
|
||||
"source_sha256_at_schema_audit": "f1f77bfd6320a3c8e0851915b150e95fc40f4a352876358393572a6d3f7c79ce",
|
||||
"authority": {
|
||||
"path": "docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/SPEC.md",
|
||||
"scope": "new P16-01-DISMISS suite marker; native execution pending"
|
||||
|
||||
+32
-5
@@ -1,4 +1,4 @@
|
||||
# P16-01-DISMISS 実装の記録(実 Opus の実装担当、2026-10-24、修正 1 は 2026-10-25)
|
||||
# P16-01-DISMISS 実装の記録(実 Opus の実装担当、2026-10-24、修正 1 と修正 2 は 2026-10-25)
|
||||
|
||||
- 仕様: 同じフォルダの `SPEC.md`(登録版 r04。main `0b59d7c` に固定。ファイルの SHA-256 は `b4cc31c0…5f77`、Runner の `spec_sha256` は `14efffd9…e6b`)。出典は `PROVENANCE.json`。
|
||||
- 原本への加算は 0。原単位 P16-01・P16-03、`AR.staff.dismissal_severance` の package P05 の原 work・原 AC は閉じない(SPEC §8)。
|
||||
@@ -16,6 +16,15 @@
|
||||
- medium と low: 条文が明示する確かめ方を代用していた所がある(同じ process の読み込み、通勤の昇降機、拒否の行の画面、bytes の比べ、別の process の境目など)。条文どおりの試験を足した(§7)。
|
||||
- 新しい拒否や、R9 に似た境界は足していない。受入の条件も削っていない。
|
||||
|
||||
### 修正 2 の経過(S `a073ccb` → 修正の後の S)
|
||||
|
||||
- S `a073ccb` は、新しい fresh な別の実 reviewer の判定で changes_requested になった(medium 1・low 3。`p16-01-dismiss-independent-review-a073ccb-r01/DESKTOP_RESULT.json`)。F1〜F8 と F10 は満たし、F9 を満たさないという判定。結果・配送・transcript は書き換えずに残している。
|
||||
- 所見と直し(製品のファイルは変えていない。試験と記録だけ):
|
||||
- medium(F9・§4.3 の通常の入力): S `a073ccb` の画面の試験は、Main のキーの処理を直接呼び、ボタンの `pressed` の信号を直接出していた。viewport の入力の経路で、解雇と確定のボタンに届くことを確かめていなかった。画面の試験を viewport の入力に置き換えた(§4・§7)。
|
||||
- low(§4.1 (d)): 試験で失敗を起こしていなかった2つの巻き戻しの枝を、開示した fixture で起こした。記録の「起こせない」という理由は、`remove_staff` の枝について正しくなかったので直した(§1)。
|
||||
- low(§4.3): 裏金からの補填の文と、命令の中で取り消す救護の文を、画面で確かめた(§7)。
|
||||
- low(後続のスクリプトの1回目の元の bytes の欠落): 管理の記録の欠落として残す。この W の中で直すものは無い。
|
||||
|
||||
## 1. 命令と判定の順(SPEC §4.1)
|
||||
|
||||
- `SimulationRuntime` に通常の command `dismiss_staff`(payload `staff_id`)を足した(`_execute_dismiss_staff`)。
|
||||
@@ -41,7 +50,12 @@
|
||||
- `remove_staff` が予約を解けない時 `RESERVATION_RELEASE_FAILED`(R3 の最後の守り)
|
||||
- 退職金を記帳できない時 `SEVERANCE_POST_FAILED`
|
||||
- 救護の作業を取り消せない時 `RESCUE_CLEANUP_FAILED`
|
||||
- 退職金と救護の2つは、試験で失敗を起こし、5.c の比べ方で何も変わらないことを確かめる。座席と `remove_staff` の2つは、試験で失敗を起こしていない(前もった判定と公開の照合の形で、今の試験の組み立てからは起こせない守り)。
|
||||
- 4つとも、試験で失敗を起こし、5.c の比べ方で何も変わらないことを確かめる(修正 2 で座席と `remove_staff` の2つを足した。§7)。
|
||||
- 退職金: 記帳の key を先に使う(開示した fixture)。乗ったままの組の段でも起こし、座席を解いた後の path と昇降機の巻き戻しを確かめる。
|
||||
- 救護の後始末: 迎えの作業に無い予約の ID を持たせる(開示した fixture)。
|
||||
- `remove_staff`: 在庫の解放の操作 ID(`staff.task.release.<作業>.<予約>`)を、先に別の予約の操作で束縛する(開示した fixture)。前もった判定(予約が無い・消費済み)は通るが、在庫が `IDEMPOTENCY_CONFLICT` で解放を拒む。修正 1 の記録の「試験の組み立てからは起こせない」は、この枝について誤りだった。
|
||||
- 座席: 運搬者の Path の乗車の要求を、公開の `PathService.complete_elevator_request` で先に終える(開示した fixture)。この状態は保存の照合が拒む形で、通常のゲームの状態からは起きない。
|
||||
- 巻き戻しの関数 `_rollback_staff_dismissal` の戻り値は使っていない。既存の雇用の巻き戻し(`_rollback_staff_candidate_hire`)と同じ形のまま。
|
||||
- 画面と試験のための読むだけの関数 `get_staff_dismissal_preview(staff_id)`。返すのは、解雇できるか・理由・退職金・未払い・開き直す作業・命令の中で取り消す救護の作業と役(運搬者か患者か)・表金・資金の合計。
|
||||
- 運営の終了の後は、理由を `BANKRUPT` にする(R5。命令は受付の前に拒まれる)。
|
||||
- `get_staff_management_report` の各行に `dismissal` として載る。
|
||||
@@ -93,9 +107,17 @@
|
||||
- 結果として、払った退職金・補填・保持した未払い・開き直した作業・取り消した救護、または拒否の理由(押した後に資金が減って拒まれた場合を含む)を出し、名簿と選択を更新する。
|
||||
- 解雇の受理を待つ間は、その職員の配属の変更とドラッグを止める。
|
||||
- `Main` の職員の管理の画面は、今の部品と送り手(`_audio_feedback.submit_command`)のままで使えるので、`scenes/main/main.gd` は変えていない。UI は今の部品のままで、完成の見た目ではない。
|
||||
- 入力の経路(F9 の開示): 試験は本物の Main と Runtime を使う。Main のキーの処理(`_unhandled_key_input`)と、画面のボタンの `pressed` の信号で操作し、他の Main の suite と同じ形で通常の入口を通す。
|
||||
- viewport や OS の入力イベントは通していない。headless の診断では、`push_input` と `Input.parse_input_event` の形を1回ずつ試したが、ボタンは反応しなかった。これは headless の診断の結果で、実機の OS 入力を試して失敗した証拠ではない。
|
||||
- Windows の実機・IME・OS の倍率・同じ候補での実機の受入は、未達のまま残す。
|
||||
- 入力の経路(F9、修正 2): 試験は本物の Main と Runtime を使い、viewport の入力で操作する。
|
||||
- キー(雇用の候補を開く H)は `root.push_input` の `InputEventKey`。
|
||||
- ボタン(候補の雇用・「雇用済み」・名簿の行・「解雇する…」・「やめる」・「解雇を確定」・締めの後に戻る「従業員」)は、全 roster で PASS している `tests/ui/test_business_management.gd` の `_click_principal_button` と同じ形で押す。ScrollContainer で見える位置へ送り、マウスの移動、押しと離しを viewport の local の座標で送り、各フレームを待つ。
|
||||
- 拒否の行では、無効のボタンを viewport の入力で押しても命令を送らず、snapshot の全体の bytes が同じことを確かめる。
|
||||
- 時計が動いている時の押し方: 離す瞬間だけ時計を動かし、すぐ止める。命令は次の分の処理になり、フレームが分を進めない(1分は実時間 1/6 秒なので、フレームを待つ間に時計を動かしたままにすると、確かめる前に分が進む)。
|
||||
- 修正 1 の headless の診断でボタンが反応しなかった原因(修正 2 の局所の診断で確かめた。正式の証拠ではない):
|
||||
- Main は基準の大きさ 1440×810 から縮めて表示される(窓 1280×720 で倍率 0.889)。修正 1 の診断は `push_input` に local の座標を指定せず(もう1つの形の `Input.parse_input_event` も窓の座標)、押した位置が約 1.125 倍ずれてボタンから外れていた。
|
||||
- 名簿の行は `refresh` で作り直される。そのフレームに足した部品では `ScrollContainer.ensure_control_visible` がスクロールしないので、1フレーム待ってから見える位置へ送る。
|
||||
- 0 時の締めの後、Main は側面の panel を帳簿に切り替え、職員の画面を含む部分を隠す(今の `_on_snapshot_published` の動き)。試験は、プレイヤーと同じく「従業員」のボタンで戻る。
|
||||
- どれも試験の押し方の問題で、画面の部品や配置は変えていない。
|
||||
- これは viewport の入力で、OS の入力ではない。Windows の実機・IME・OS の倍率・同じ候補での実機の受入は、SPEC §10「Windows 実機は後」の後続の条件として、実施しておらず未達のまま残す。
|
||||
|
||||
## 5. 予約の操作 ID(修正 1、A5・F7)
|
||||
|
||||
@@ -129,6 +151,11 @@
|
||||
- 保持した未払い(本物の 0 時の締めで資金が足りず、日給が未払いになる)も、押す前と結果で確かめる。
|
||||
- 押した後の拒否の理由も確かめる。
|
||||
- 画面の比べは全て `var_to_bytes`。
|
||||
- 修正 2 で変えた・足した場面:
|
||||
- Main の画面の場面を、viewport の入力で操作する形に置き換えた(§4)。`Button.pressed.emit()` と `_unhandled_key_input` の直接の呼び出しは、この suite から無くした。
|
||||
- Main の画面で、裏金からの補填(表金 G100・退職金 G300・補填 G200)の押す前と結果の文を確かめる。資金の fixture は、解雇の後に元の残高に戻る形にした。
|
||||
- Main の画面で、救護室へ歩いて運ぶ搬送の段に、患者と運搬者の押す前の文と、運搬者の結果の文(取り消した救護)を確かめる。
|
||||
- 巻き戻しの枝(`_test_rollback_branches`): `remove_staff` の予約の解放の失敗、乗ったままの組の座席の解放の失敗、乗ったままの組で座席を解いた後の退職金の記帳の失敗。どれも 5.c と 5.d の比べ方で、組が乗ったままであることも確かめる。
|
||||
- 再開の2段 `tests/staff/test_general_dismissal_restart_1_write.gd`(唯一の生成ファイルは `artifacts/p16-01-dismiss/restart-handoff.json`)と `..._2_read.gd`。
|
||||
- 帳簿の6つの保存点を読む。
|
||||
- 次の命令の境目の保存を読む。修正 1 で、A6 の迎え・A6 と A7 の乗ったままの組・A8 の CONTINUE_WITHOUT を足した。
|
||||
|
||||
@@ -48,6 +48,7 @@ func _run() -> void:
|
||||
_test_defense_refusal()
|
||||
_test_commit_failure_rolls_back()
|
||||
_test_rescue_cleanup_failure_rolls_back()
|
||||
_test_rollback_branches()
|
||||
_test_old_save_gains_the_severance_account()
|
||||
await _test_dismiss_from_main()
|
||||
await process_frame
|
||||
@@ -853,13 +854,74 @@ func _test_rescue_cleanup_failure_rolls_back() -> void:
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## F9: the game's own Runtime and Main. The staff panel shows the severance, the
|
||||
## reopened Task and the held wages before the press, dismisses through its buttons,
|
||||
## shows the result and a refusal that arrives after the press, and for every refusal
|
||||
## row (R1, R2, R3, R5, R7, R8, R9) keeps the button disabled with the reason and sends
|
||||
## nothing on a forced press (5.a, whole save bytes). Input route: the Buttons' pressed
|
||||
## signals and Main's key handler, as the other Main suites drive them; no OS or
|
||||
## viewport input is claimed.
|
||||
## SPEC 4.1 d, the guarded branches the other scenes do not fail. Each changes nothing
|
||||
## but the command record (5.c).
|
||||
## (1) The removal contract cannot release the held Task's Reservation although the
|
||||
## pre-check passes. Disclosed fixture: the release operation id is already bound
|
||||
## to another inventory reservation, so InventoryService refuses the release.
|
||||
## (2) The seat of a boarded rescue pair cannot be released. Disclosed fixture: the
|
||||
## medic's Path request was completed beforehand through the public PathService
|
||||
## call, a state the save check refuses.
|
||||
## (3) The severance cannot be posted after the dismissed carrier's seat was released,
|
||||
## so the Path request, the cabin seat and the pairing are restored with the rest.
|
||||
func _test_rollback_branches() -> void:
|
||||
var fixture := _scenes.new_fixture(16_150)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var minute: int = fixture[&"clock"].absolute_minute
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker", &"role_id": &"worker", &"options": {&"state": StaffRuntime.State.AVAILABLE,
|
||||
&"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"role_tags": [&"worker"], &"permitted_task_tags": [&"work"], &"daily_wage": 100}}).is_success()
|
||||
and _scenes.command(fixture, &"add_inventory_stack", {&"owner_inventory_id": &"staff.supplies", &"item_id": &"tool/snapshot_kit", &"quantity": 2}).is_success()
|
||||
and _scenes.command(fixture, &"create_staff_task", {&"task_id": &"tool_task", &"task_type": &"inspect", &"priority": 90, &"options": {
|
||||
&"target_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"required_role_tags": [&"worker"], &"required_items": {&"tool/snapshot_kit": 1},
|
||||
&"task_tags": [&"work"], &"estimated_minutes": 10}}).is_success(), "a worker, two tools and a Task that needs one")
|
||||
fixture[&"clock"].advance_one_minute()
|
||||
minute = fixture[&"clock"].absolute_minute
|
||||
var task: StaffTask = runtime.staff_service.get_task(&"tool_task")
|
||||
_expect(task.state == StaffTask.State.WORKING and task.reservation_ids.size() == 1, "the worker holds the Task and its Reservation")
|
||||
var held: StringName = task.reservation_ids[0] if task.reservation_ids.size() == 1 else &""
|
||||
var bound: Dictionary = runtime.inventory_service.reserve_item(&"staff.supplies", &"tool/snapshot_kit", 1, &"p16.fixture", &"staff_task", minute, minute + 60,
|
||||
StringName("staff.task.release.%s.%s" % [task.task_id, held]))
|
||||
_expect(bool(bound.get(&"ok", false)) and bool(runtime.get_staff_dismissal_preview(&"tool_worker")[&"can_dismiss"]),
|
||||
"disclosed fixture: the release operation id is bound to another reservation; the pre-check still passes")
|
||||
var refused := _expect_executor_refusal(fixture, &"tool_worker", &"RESERVATION_RELEASE_FAILED", "(1) the removal contract's release fails")
|
||||
_expect(refused.message == "Staff removal could not release Task Reservations." and runtime.staff_service.has_staff(&"tool_worker")
|
||||
and runtime.inventory_service.get_reservation(held).is_active() and runtime.staff_service.get_task(&"tool_task").assigned_staff_id == &"tool_worker",
|
||||
"(1) the existing contract's text; the employee, the Task and its Reservation are as before")
|
||||
_scenes.free_fixture(fixture)
|
||||
for scene: Array in [["(2) the seat release fails", &"RESCUE_SEAT_RELEASE_FAILED"], ["(3) the severance fails after the seat release", &"SEVERANCE_POST_FAILED"]]:
|
||||
var rescue := _scenes.new_rescue_fixture()
|
||||
var rescue_runtime: Variant = rescue[&"runtime"]
|
||||
_expect(_scenes.advance_to_rescue_phase(rescue, "rescue_transport/MOVING/BOARDED/pair"), "%s: the pair rides the elevator" % scene[0])
|
||||
var trip: Dictionary = rescue_runtime.elevators.get_actor_trip(&"staff:medic")
|
||||
if scene[1] == &"RESCUE_SEAT_RELEASE_FAILED":
|
||||
_expect(rescue_runtime.path_service.complete_elevator_request(StringName(trip.get(&"connector_id", &"")), StringName(trip.get(&"request_id", &""))),
|
||||
"%s: disclosed fixture: the medic's Path request is completed beforehand" % scene[0])
|
||||
else:
|
||||
var next_id := int(rescue_runtime.create_snapshot()[&"systems"][&"runtime"][&"next_command_id"])
|
||||
_expect(rescue_runtime.economy_service.transfer(Scenes.PAYER, Scenes.FRONT_SALES, 1, rescue[&"clock"].absolute_minute, &"TRANSFER", &"p16.fixture",
|
||||
StringName("staff.severance.%d" % next_id)).is_success(), "%s: disclosed fixture: the dismissal's posting key is already used" % scene[0])
|
||||
_expect_executor_refusal(rescue, &"medic", scene[1], scene[0])
|
||||
var medic_trip: Dictionary = rescue_runtime.elevators.get_actor_trip(&"staff:medic")
|
||||
var patient_trip: Dictionary = rescue_runtime.elevators.get_actor_trip(&"staff:patient")
|
||||
_expect(rescue_runtime.staff_service.has_staff(&"medic") and medic_trip == trip and StringName(medic_trip.get(&"companion_id", &"")) == &"staff:patient"
|
||||
and StringName(patient_trip.get(&"companion_id", &"")) == &"staff:medic", "%s: the medic is still aboard with the patient" % scene[0])
|
||||
if scene[1] == &"SEVERANCE_POST_FAILED":
|
||||
var saved: Dictionary = rescue_runtime.create_snapshot()
|
||||
_expect(rescue_runtime.restore_snapshot(saved) and rescue_runtime.create_snapshot() == saved, "%s: the restored game saves and restores exactly" % scene[0])
|
||||
_scenes.free_fixture(rescue)
|
||||
|
||||
|
||||
## F9: the game's own Runtime and Main, driven by viewport input. Main's key and every
|
||||
## button of the dismissal flow (the hiring buttons, 雇用済み, the roster rows, 解雇する…,
|
||||
## やめる, 解雇を確定) are pressed through root.push_input in the viewport's own
|
||||
## coordinates after the control is scrolled into view (_click, the form of
|
||||
## tests/ui/test_business_management.gd _click_principal_button). The panel shows the
|
||||
## severance, the hidden-fund top-up, the reopened Task, the rescue the command
|
||||
## interrupts and the held wages before the press, dismisses, shows the result and a
|
||||
## refusal that arrives after the press, and for every refusal row (R1, R2, R3, R5, R7,
|
||||
## R8, R9) keeps the button disabled with the reason and sends nothing when it is
|
||||
## clicked (5.a, whole save bytes). This is viewport input, not OS input; the Windows
|
||||
## device, IME, OS scaling and the same-candidate device check are later (SPEC 10).
|
||||
func _test_dismiss_from_main() -> void:
|
||||
var runtime: Variant = root.get_node("SimulationRuntime")
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
@@ -876,15 +938,14 @@ func _test_dismiss_from_main() -> void:
|
||||
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
clock.set_paused(true)
|
||||
var fixture := {&"runtime": runtime, &"clock": clock, &"state": state}
|
||||
var key := InputEventKey.new()
|
||||
key.keycode = KEY_H
|
||||
key.pressed = true
|
||||
main.call("_unhandled_key_input", key)
|
||||
await _press_key(KEY_H)
|
||||
_expect((main.get("staff_candidate_panel") as Control).visible, "the H key opens the hiring panel")
|
||||
for candidate: StringName in [&"staff_akane", &"staff_eri", &"heroine_anri"]:
|
||||
var hire := (main.get("staff_candidate_buttons") as Dictionary).get(candidate) as Button
|
||||
_expect(hire != null, "the hiring panel lists %s" % candidate)
|
||||
if hire != null:
|
||||
hire.pressed.emit()
|
||||
await _click(hire)
|
||||
_expect(runtime.staff_service.has_staff(candidate), "a click hires %s" % candidate)
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"adult_contract", &"role_id": &"attendant",
|
||||
&"options": {&"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 100}}).is_success(), "an ADULT_CONTRACT fixture row")
|
||||
_expect(_scenes.command(fixture, &"create_staff_task", {&"task_id": &"ui_task", &"task_type": &"inspect", &"priority": 90, &"options": {
|
||||
@@ -893,35 +954,65 @@ func _test_dismiss_from_main() -> void:
|
||||
clock.advance_minutes(1)
|
||||
var eri_task: StringName = runtime.staff_service.get_staff(&"staff_eri").current_task_id
|
||||
_expect(eri_task == &"ui_task", "the cleaner holds the Task (%s)" % eri_task)
|
||||
(main.get("staff_management_toggle_button") as Button).pressed.emit()
|
||||
await _click(main.get("staff_management_toggle_button") as Button)
|
||||
var panel: StaffManagementPanel = main.get("staff_management_panel")
|
||||
_expect(panel.visible and panel.roster_buttons.size() == 4, "the hired-staff panel lists four employees")
|
||||
(panel.roster_buttons[&"staff_akane"] as Button).pressed.emit()
|
||||
_expect(not panel.dismiss_button.disabled and panel.dismiss_button.tooltip_text.contains("退職金 G360"), "the receptionist row offers the dismissal with severance G360")
|
||||
_expect(panel.visible and panel.roster_buttons.size() == 4, "a click on 雇用済み shows the four employees")
|
||||
await _click(panel.roster_buttons[&"staff_akane"])
|
||||
_expect(panel.selected_staff_id == &"staff_akane" and not panel.dismiss_button.disabled and panel.dismiss_button.tooltip_text.contains("退職金 G360"),
|
||||
"a click on her row offers the dismissal with severance G360")
|
||||
var untouched := var_to_bytes(runtime.create_snapshot())
|
||||
panel.dismiss_button.pressed.emit()
|
||||
await _click(panel.dismiss_button)
|
||||
_expect(panel.dismiss_confirm_box.visible and panel.dismiss_confirm_label.text.contains("退職金 G360(日給の3日分)") and var_to_bytes(runtime.create_snapshot()) == untouched,
|
||||
"pressing 解雇する… shows the severance and sends nothing (5.a, bytes)")
|
||||
panel.dismiss_cancel_button.pressed.emit()
|
||||
_expect(not panel.dismiss_confirm_box.visible and var_to_bytes(runtime.create_snapshot()) == untouched, "やめる closes the confirmation without a command (bytes)")
|
||||
panel.dismiss_button.pressed.emit()
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
"a click on 解雇する… shows the severance and sends nothing (5.a, bytes)")
|
||||
await _click(panel.dismiss_cancel_button)
|
||||
_expect(not panel.dismiss_confirm_box.visible and var_to_bytes(runtime.create_snapshot()) == untouched, "a click on やめる closes the confirmation without a command (bytes)")
|
||||
await _click(panel.dismiss_button)
|
||||
await _click(panel.dismiss_confirm_button)
|
||||
_expect(not runtime.staff_service.has_staff(&"staff_akane") and not panel.roster_buttons.has(&"staff_akane") and panel.selected_staff_id != &"staff_akane",
|
||||
"解雇を確定 dismisses her; the roster and the selection follow")
|
||||
"a click on 解雇を確定 dismisses her; the roster and the selection follow")
|
||||
_expect(panel.result_label.text.contains("解雇しました") and panel.result_label.text.contains("G360"), "the result shows the paid severance: %s" % panel.result_label.text)
|
||||
_expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY, &"staff_akane") == 1, "the panel's command paid the severance once")
|
||||
(panel.roster_buttons[&"staff_eri"] as Button).pressed.emit()
|
||||
panel.dismiss_button.pressed.emit()
|
||||
await _click(panel.roster_buttons[&"staff_eri"])
|
||||
await _click(panel.dismiss_button)
|
||||
_expect(panel.dismiss_confirm_label.text.contains("持っている作業は開き直して") and panel.dismiss_confirm_label.text.contains("G270"), "the cleaner's confirmation names the reopened Task and G270")
|
||||
clock.set_paused(false)
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
await _click(panel.dismiss_confirm_button, true)
|
||||
_expect(runtime.staff_service.has_staff(&"staff_eri") and panel.dismiss_button.disabled and panel.result_label.text.contains("受理しました"), "with the clock running the dismissal waits for the next minute")
|
||||
clock.advance_minutes(1)
|
||||
clock.set_paused(true)
|
||||
_expect(not runtime.staff_service.has_staff(&"staff_eri") and runtime.staff_service.get_task(&"ui_task").state == StaffTask.State.OPEN
|
||||
and panel.result_label.text.contains("解雇しました") and panel.result_label.text.contains("持っていた作業は開き直しました"), "the queued dismissal completes and reopens her Task")
|
||||
# The rescue the command interrupts (SPEC 6.2.1), on one floor: a collapsed medic (the
|
||||
# patient) and a medic who carries her to the infirmary on foot.
|
||||
var infirmary := _scenes.command(fixture, &"build_facility", {&"definition_id": &"infirmary", &"origin": Vector3i(8, 0, 1)})
|
||||
_expect(infirmary.is_success(), "build the infirmary (%s %s)" % [infirmary.message, infirmary.details])
|
||||
clock.advance_minutes(runtime.definition_registry.get_facility(&"infirmary").build_minutes)
|
||||
for id: StringName in [&"patient", &"medic"]:
|
||||
var options := {
|
||||
&"current_cell": Vector3i(-2, 0, 1) if id == &"patient" else Vector3i(-10, 0, 1),
|
||||
&"health": 0 if id == &"patient" else 100, &"state": StaffRuntime.State.AVAILABLE,
|
||||
&"role_tags": [&"medic"], &"permitted_task_tags": [&"rescue"], &"daily_wage": 100,
|
||||
}
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": id, &"role_id": &"medic", &"options": options}).is_success(), "register rescue employee %s" % id)
|
||||
_expect(_scenes.advance_to_rescue_phase(fixture, "rescue_transport/MOVING/-/single"), "the medic carries the patient on foot (%s)" % _scenes.rescue_phase(runtime))
|
||||
panel.refresh()
|
||||
await _click(panel.roster_buttons[&"patient"])
|
||||
var carrying := var_to_bytes(runtime.create_snapshot())
|
||||
await _click(panel.dismiss_button)
|
||||
_expect(panel.dismiss_confirm_label.text.contains("この従業員への救護は取り消し、救護係を外します。") and var_to_bytes(runtime.create_snapshot()) == carrying,
|
||||
"the patient's confirmation says her rescue is cancelled: %s" % panel.dismiss_confirm_label.text)
|
||||
await _click(panel.dismiss_cancel_button)
|
||||
await _click(panel.roster_buttons[&"medic"])
|
||||
await _click(panel.dismiss_button)
|
||||
_expect(panel.dismiss_confirm_label.text.contains("運んでいる救護の搬送は取り消し、患者の救護をやり直します。") and not panel.dismiss_confirm_label.text.contains("開き直して"),
|
||||
"the carrier's confirmation says the transport is cancelled, not reopened: %s" % panel.dismiss_confirm_label.text)
|
||||
await _click(panel.dismiss_confirm_button)
|
||||
_expect(not runtime.staff_service.has_staff(&"medic") and panel.result_label.text.contains("関わっていた救護の作業は取り消しました。")
|
||||
and not panel.result_label.text.contains("開き直しました"), "the carrier's result names the cancelled rescue: %s" % panel.result_label.text)
|
||||
await _click(panel.roster_buttons[&"patient"])
|
||||
await _click(panel.dismiss_button)
|
||||
await _click(panel.dismiss_confirm_button)
|
||||
_expect(not runtime.staff_service.has_staff(&"patient"), "the patient is dismissed too, so the later rows are as before")
|
||||
for row: Array in [[&"heroine_anri", "ユニーク:雇用・維持費無料 / 解雇・売却不可", "R1"], [&"adult_contract", "ギルドで雇った一般従業員だけを解雇できます。", "R7"]]:
|
||||
_expect_refused_row(panel, runtime, row[0], row[1], row[2])
|
||||
await _expect_refused_row(panel, runtime, row[0], row[1], row[2])
|
||||
# R9: the defender of an unfinished raid.
|
||||
var objective_build := _scenes.command(fixture, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-8, 0, 0)})
|
||||
_expect(objective_build.is_success(), "build the raid objective (%s %s)" % [objective_build.message, objective_build.details])
|
||||
@@ -935,7 +1026,7 @@ func _test_dismiss_from_main() -> void:
|
||||
&"waves": [{&"wave_id": &"wave.one", &"threat_strength": 15, &"retreat_threshold": 0, &"max_rounds": 1, &"entry_points": [&"gate.north"]}]}}, &"defense_controller").is_success()
|
||||
and runtime.submit_command(&"register_defense_combatant", {&"raid_id": &"ui.raid", &"actor_id": &"staff_klaus", &"side": &"DEFENDER", &"combat": 90,
|
||||
&"max_health": 100, &"options": {}}, &"defense_controller").is_success(), "the guard defends an unfinished raid")
|
||||
_expect_refused_row(panel, runtime, &"staff_klaus", "襲撃の防衛に参加中は解雇できません。", "R9")
|
||||
await _expect_refused_row(panel, runtime, &"staff_klaus", "襲撃の防衛に参加中は解雇できません。", "R9")
|
||||
# R2: the porter carrying supply logistics.
|
||||
for spec: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(4, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||||
var built := _scenes.command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]})
|
||||
@@ -961,7 +1052,7 @@ func _test_dismiss_from_main() -> void:
|
||||
for minute: int in 40:
|
||||
if runtime.staff_service.is_externally_driven_task(runtime.staff_service.get_staff(&"staff_gina").current_task_id): break
|
||||
clock.advance_minutes(1)
|
||||
_expect_refused_row(panel, runtime, &"staff_gina", "物流の受け取り・配達・返送の途中です。", "R2")
|
||||
await _expect_refused_row(panel, runtime, &"staff_gina", "物流の受け取り・配達・返送の途中です。", "R2")
|
||||
# R3: a Task Reservation that cannot be released (disclosed fixture: the tool is consumed).
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_tool_worker", &"role_id": &"worker", &"options": {&"state": StaffRuntime.State.AVAILABLE,
|
||||
&"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"role_tags": [&"worker"], &"permitted_task_tags": [&"work"], &"daily_wage": 100}}).is_success()
|
||||
@@ -974,34 +1065,52 @@ func _test_dismiss_from_main() -> void:
|
||||
var before_consume: Dictionary = runtime.create_snapshot()
|
||||
_expect(tool_task.assigned_staff_id == &"ui_tool_worker" and tool_task.reservation_ids.size() == 1
|
||||
and bool(runtime.inventory_service.consume_reservation(tool_task.reservation_ids[0], &"p16.ui.consume", clock.absolute_minute).get(&"ok", false)), "fixture consumes the reserved tool")
|
||||
_expect_refused_row(panel, runtime, &"ui_tool_worker", "作業の予約を解けないため解雇できません。", "R3")
|
||||
await _expect_refused_row(panel, runtime, &"ui_tool_worker", "作業の予約を解けないため解雇できません。", "R3")
|
||||
_expect(runtime.restore_snapshot(before_consume), "the fixture's consumption is undone after the check")
|
||||
panel.refresh()
|
||||
# The hidden fund covers the part of the severance the front fund lacks (A14).
|
||||
# Disclosed balance fixtures: the front fund holds G100 and a hidden-fund sale adds
|
||||
# G200, so the funds return to the earlier balances after the dismissal.
|
||||
for account: StringName in [Scenes.FRONT_SALES, Scenes.BACK_SALES, Scenes.PAYER]:
|
||||
runtime.economy_service.register_account(account)
|
||||
_expect(runtime.economy_service.initialize_account(Scenes.PAYER, 100000, clock.absolute_minute, &"p16.ui.payer").is_success(), "disclosed fixture: a payer for the balance fixtures")
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_top_up", &"role_id": &"cleaner", &"options": {&"daily_wage": 100}}).is_success(), "an employee whose severance needs the hidden fund")
|
||||
var front_before: int = runtime.economy_service.get_balance(FRONT)
|
||||
var back_before: int = runtime.economy_service.get_balance(BACK)
|
||||
_expect(front_before > 0 and _scenes.drain_front(fixture, "ui.top_up") and _scenes.sale(fixture, FRONT, 100, "ui.top_up.front")
|
||||
and _scenes.sale(fixture, BACK, 200, "ui.top_up.back"), "disclosed fixture: front G100, hidden G%d" % (back_before + 200))
|
||||
panel.refresh()
|
||||
await _click(panel.roster_buttons[&"ui_top_up"])
|
||||
await _click(panel.dismiss_button)
|
||||
_expect(panel.dismiss_confirm_label.text.contains("退職金 G300(日給の3日分)を表金から払います。不足 G200 は裏金から補います。"),
|
||||
"the confirmation shows the hidden-fund top-up: %s" % panel.dismiss_confirm_label.text)
|
||||
await _click(panel.dismiss_confirm_button)
|
||||
_expect(not runtime.staff_service.has_staff(&"ui_top_up") and panel.result_label.text.contains("退職金 G300 を払いました(不足 G200 は裏金から補填)"),
|
||||
"the result shows the top-up: %s" % panel.result_label.text)
|
||||
_expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == back_before
|
||||
and _scenes.sale(fixture, FRONT, front_before, "ui.top_up.restore") and runtime.economy_service.get_balance(FRONT) == front_before,
|
||||
"the funds are back at G%d and G%d" % [front_before, back_before])
|
||||
# A refusal that arrives after the press: the funds are spent before the queued command runs.
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_late", &"role_id": &"cleaner", &"options": {&"daily_wage": 100}}).is_success(), "an employee to dismiss")
|
||||
panel.refresh()
|
||||
(panel.roster_buttons[&"ui_late"] as Button).pressed.emit()
|
||||
panel.dismiss_button.pressed.emit()
|
||||
clock.set_paused(false)
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
await _click(panel.roster_buttons[&"ui_late"])
|
||||
await _click(panel.dismiss_button)
|
||||
await _click(panel.dismiss_confirm_button, true)
|
||||
_expect(runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("受理しました"), "the dismissal is accepted for the next minute")
|
||||
_expect(_scenes.drain_front(fixture, "ui.late") and runtime.economy_service.get_balance(BACK) < 300, "disclosed fixture: the funds are spent before the command runs")
|
||||
clock.advance_minutes(1)
|
||||
clock.set_paused(true)
|
||||
_expect(runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("退職金 G300 を払えません"), "the result shows the refusal after the press: %s" % panel.result_label.text)
|
||||
_expect_refused_row(panel, runtime, &"ui_late", "退職金 G300 を払えません", "R8")
|
||||
await _expect_refused_row(panel, runtime, &"ui_late", "退職金 G300 を払えません", "R8")
|
||||
# The held unpaid wages: the first 00:00 close finds no funds, so the day's wage stays unpaid (A09).
|
||||
clock.advance_minutes(1441 - int(clock.absolute_minute))
|
||||
_expect(runtime.staff_service.get_staff(&"ui_late").unpaid_wages == 100, "the first close leaves her daily wage 100 unpaid")
|
||||
for account: StringName in [Scenes.FRONT_SALES, Scenes.PAYER]:
|
||||
runtime.economy_service.register_account(account)
|
||||
_expect(runtime.economy_service.initialize_account(Scenes.PAYER, 100000, clock.absolute_minute, &"p16.ui.payer").is_success()
|
||||
and _scenes.sale(fixture, FRONT, 1000, "ui.sale"), "disclosed fixture: a sale refills the front fund")
|
||||
_expect(_scenes.sale(fixture, FRONT, 1000, "ui.sale"), "disclosed fixture: a sale refills the front fund")
|
||||
await _show_staff_panel(main, panel)
|
||||
panel.refresh()
|
||||
(panel.roster_buttons[&"ui_late"] as Button).pressed.emit()
|
||||
panel.dismiss_button.pressed.emit()
|
||||
await _click(panel.roster_buttons[&"ui_late"])
|
||||
await _click(panel.dismiss_button)
|
||||
_expect(panel.dismiss_confirm_label.text.contains("未払い G100 は退職金と別に次の0時に精算します"), "the confirmation shows the held wages: %s" % panel.dismiss_confirm_label.text)
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
await _click(panel.dismiss_confirm_button)
|
||||
_expect(not runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("未払い G100 は次の0時に精算します"), "the result shows the held wages: %s" % panel.result_label.text)
|
||||
# R5: after the game ends (three actual operating losses with no funds).
|
||||
if not bool(runtime.get(&"_operations_enabled")):
|
||||
@@ -1012,20 +1121,79 @@ func _test_dismiss_from_main() -> void:
|
||||
_scenes.drain_front(fixture, "ui.bankrupt.%d" % clock.absolute_minute)
|
||||
clock.advance_minutes(1)
|
||||
_expect(runtime.operations_coordinator.is_terminal(), "the game ends (BANKRUPT) after three actual operating losses")
|
||||
_expect_refused_row(panel, runtime, &"ui_tool_worker", "運営終了後は解雇できません。", "R5")
|
||||
await _show_staff_panel(main, panel)
|
||||
await _expect_refused_row(panel, runtime, &"ui_tool_worker", "運営終了後は解雇できません。", "R5")
|
||||
var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
|
||||
_expect(bool(cleanup.get(&"passed", false)), "ordinary Main release with audio cleanup")
|
||||
|
||||
|
||||
## A refusal row: a click selects the row, the button is disabled with the reason, and a
|
||||
## click on the disabled button sends nothing (5.a, whole save bytes).
|
||||
func _expect_refused_row(panel: StaffManagementPanel, runtime: Variant, staff_id: StringName, text: String, label: String) -> void:
|
||||
panel.refresh()
|
||||
_expect(panel.roster_buttons.has(staff_id), "%s: the row is listed" % label)
|
||||
if not panel.roster_buttons.has(staff_id):
|
||||
return
|
||||
(panel.roster_buttons[staff_id] as Button).pressed.emit()
|
||||
await _click(panel.roster_buttons[staff_id])
|
||||
var before := var_to_bytes(runtime.create_snapshot())
|
||||
_expect(panel.dismiss_button.disabled and panel.staff_detail_label.text.contains(text) and panel.dismiss_button.tooltip_text.contains(text),
|
||||
_expect(panel.selected_staff_id == staff_id and panel.dismiss_button.disabled and panel.staff_detail_label.text.contains(text) and panel.dismiss_button.tooltip_text.contains(text),
|
||||
"%s: the button is disabled and the reason is shown (%s)" % [label, panel.dismiss_button.tooltip_text])
|
||||
panel.dismiss_button.pressed.emit()
|
||||
await _click(panel.dismiss_button)
|
||||
_expect(var_to_bytes(runtime.create_snapshot()) == before and not panel.dismiss_confirm_box.visible and runtime.staff_service.has_staff(staff_id),
|
||||
"%s: a forced press sends nothing (5.a, whole save bytes)" % label)
|
||||
"%s: a click on the disabled button sends nothing (5.a, whole save bytes)" % label)
|
||||
|
||||
|
||||
## Viewport input (the _click_principal_button form of tests/ui/test_business_management.gd):
|
||||
## the control is scrolled into view, the mouse moves to its centre and presses and
|
||||
## releases there, in the viewport's own coordinates (Main is scaled from 1440x810).
|
||||
## A roster row rebuilt by the panel in this frame scrolls only from the next frame
|
||||
## (ScrollContainer.ensure_control_visible), and the roster's own scroll sits inside
|
||||
## the side panel's scroll, so each is asked again after a frame.
|
||||
## `running`: the clock runs only for the release, so a press while time runs sends
|
||||
## the command for the next minute and no frame advances a minute.
|
||||
func _click(control: Control, running := false) -> void:
|
||||
for pass_index: int in 2:
|
||||
await process_frame
|
||||
var ancestor: Node = control.get_parent()
|
||||
while ancestor != null:
|
||||
if ancestor is ScrollContainer: ancestor.ensure_control_visible(control)
|
||||
ancestor = ancestor.get_parent()
|
||||
await process_frame
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
var point := control.get_global_rect().get_center()
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = point
|
||||
motion.global_position = point
|
||||
root.push_input(motion, true)
|
||||
for pressed: bool in [true, false]:
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.pressed = pressed
|
||||
event.position = point
|
||||
event.global_position = point
|
||||
event.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0
|
||||
if running and not pressed:
|
||||
clock.set_paused(false)
|
||||
root.push_input(event, true)
|
||||
if running and not pressed:
|
||||
clock.set_paused(true)
|
||||
await process_frame
|
||||
|
||||
|
||||
## After a 00:00 close Main shows the ledger in the side panel (_on_snapshot_published)
|
||||
## and hides the staff controls; the player returns with the 従業員 navigation button.
|
||||
func _show_staff_panel(main: Control, panel: StaffManagementPanel) -> void:
|
||||
_expect(not panel.is_visible_in_tree(), "after the close Main shows the ledger instead of the staff panel")
|
||||
await _click((main.get("navigation_buttons") as Dictionary)[&"staff"] as Button)
|
||||
_expect(panel.is_visible_in_tree(), "a click on 従業員 brings the hired-staff panel back")
|
||||
|
||||
|
||||
## A key press and release through the viewport (Main's _unhandled_key_input).
|
||||
func _press_key(keycode: Key) -> void:
|
||||
for pressed: bool in [true, false]:
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = keycode
|
||||
event.physical_keycode = keycode
|
||||
event.pressed = pressed
|
||||
root.push_input(event, true)
|
||||
await process_frame
|
||||
|
||||
@@ -461,7 +461,7 @@
|
||||
"new_audio_QA": false
|
||||
},
|
||||
"tests/staff/test_general_dismissal.gd": {
|
||||
"source_sha256": "e325c7ac24f979bc8ac3f4222279c317b2603ef20cae046475647dd90d423123",
|
||||
"source_sha256": "f1f77bfd6320a3c8e0851915b150e95fc40f4a352876358393572a6d3f7c79ce",
|
||||
"ordinary_cleanup_prepared": true,
|
||||
"ordinary_control_labels": ["single"],
|
||||
"old_bounded_teardown": false,
|
||||
|
||||
Reference in New Issue
Block a user