"""Z01 고친 것 초안 — [저장] 전까지 sessionStorage 에만 · 판이 다르면 버림 (PLAN 1-0 · 1-4 · 2026-09-18). ① 기초단가 — 서버 값과 같아지면 고침에서 빠짐 · 지울 줄의 칸 고침은 안 보냄 · 새 줄은 data 만 보냄 ② 같은 판이면 탭을 다시 열어도 초안이 살아남 · 판이 다르면 비고 저장소에서도 지움 ③ 공종 — 보낼 몸은 지울 줄을 빼고 화면 표시 칸(tmp·deleted)을 뗌(빠지면 삭제 = 전체 줄 보내기) ④ 고친 것이 없으면 dirty 아님 · 고치면 dirty · 새 줄은 「added」 · 고친 칸만 짚음 ⑤ 갈래는 처음 나온 차례로 묶음 · 새 줄은 제 갈래 끝 바로 뒤(보낸 차례 = sort_order) ⑥ 보낼 줄은 id + 계약 칸 열하나만 — GET 전용 칸(sort_order·is_added·changed_columns)이 실리면 422 TS 를 실제로 돌린다(`test_z01_master_cells` 와 같은 방식). """ from __future__ import annotations import json import subprocess from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Draft.ts" _RUNNER = """ import { writeFileSync } from "node:fs"; // 브라우저 sessionStorage 대역 const store = new Map(); globalThis.sessionStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k), }; const { BaseDraft, addRow, changedFields, freshBody, groupByVariant, isDirty, workItemPayload, } = await import("./Z01_MasterData_UI_Draft.js"); // ① 기초단가 const base = new BaseDraft("base:labor", 7); base.setEdit("labor_const/1001", "daily_wage_krw", 250000, 215907); base.setEdit("labor_const/1002", "daily_wage_krw", 100, 100); // 서버 값과 같음 — 안 쌓임 base.setEdit("labor_const/1003", "daily_wage_krw", 1, 2); base.setEdit("labor_const/1003", "daily_wage_krw", 2, 2); // 되돌림 — 빠짐 base.setEdit("labor_const/9999", "daily_wage_krw", 5, 4); base.toggleDelete("labor_const/9999"); const added = base.addRow(); base.setAdded(added.tmp, "occupation_name", "새 직종"); base.setAdded(added.tmp, "daily_wage_krw", 300000); base.setAdded(added.tmp, "daily_wage_krw", null); // 비우면 칸을 뺌 const payload = base.payload(); const count = base.count; const stored = JSON.parse(store.get("z01-draft:base:labor")); // ② 같은 판이면 살아남 · 다른 판이면 비고 저장소에서 지움 const again = new BaseDraft("base:labor", 7); const restored = again.payload(); const newer = new BaseDraft("base:labor", 8); const dropped = { count: newer.count, left: store.has("z01-draft:base:labor") }; const toggled = new BaseDraft("base:oil", 1); toggled.toggleDelete("x"); toggled.toggleDelete("x"); const emptied = { count: toggled.count, left: store.has("z01-draft:base:oil") }; // ③④⑤ 공종 const row = (id, variant, extra = {}) => ({ id, variant, resource_kind: "labor", resource_key: "labor_const/1001", resource_name: "보통인부", resource_spec: "", amount: 0.5, amount_unit: "인", alternative_amount: null, group_ratio_pct: null, raw_cell: null, pum_table_id: "T-1", // GET 전용 칸 — PUT 에 실리면 422(extra=forbid) sort_order: id, is_added: false, changed_columns: [], ...extra, }); const detail = { item: { work_item_key: "FW-00001", axis: "forest", code: null, number: "1-1", name: "벌목", path_name: "벌목", parent_key: null, parent_mode: null, basis_quantity: 1, basis_unit: "ha", status: "needs_input", edition: "2026", revision: 3 }, rows: [row(1, "갑"), row(2, "을"), row(3, "갑")], source: [], notes: [], }; const body = freshBody(detail); const clean = isDirty(detail, body); body.rows[0].amount = 0.7; body.rows[1].deleted = true; addRow(body, "을", "new-1"); body.item.status = "reviewed"; const sent = workItemPayload(detail.item.revision, body); writeFileSync(process.argv[2], JSON.stringify({ payload, count, stored, restored, dropped, emptied, clean, dirty: isDirty(detail, body), sent, changed: [...changedFields(body.rows[0], detail.rows)], addedRow: changedFields(body.rows[2], detail.rows), untouched: [...changedFields(body.rows[3], detail.rows)], groups: groupByVariant(body.rows), freshUntouched: freshBody(detail).rows[0].amount, })); """ def test_초안_쌓기와_보낼_몸(tmp_path: Path) -> None: out = tmp_path / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(SOURCE), "--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig", "--noCheck", "--noResolve", ], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") result = tmp_path / "output.json" subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs"), str(result)], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) got = json.loads(result.read_text(encoding="utf-8")) # ① 서버 값과 같은 고침은 안 쌓임 · 지울 줄(9999)의 칸 고침은 안 보냄 · 새 줄은 data 만 assert got["payload"] == { "base_revision": 7, "edits": [ {"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": 250000}, ], "added": [{"data": {"occupation_name": "새 직종"}}], "deleted": ["labor_const/9999"], } # 고친 칸 2(1001·9999) + 새 줄 1 + 지울 줄 1 assert got["count"] == 4 assert got["stored"]["revision"] == 7 # ② 같은 판이면 그대로 살아남 · 판이 바뀌면 비고 저장소에서도 지움 · 다 되돌리면 저장소에서 빠짐 assert got["restored"] == got["payload"] assert got["dropped"] == {"count": 0, "left": False} assert got["emptied"] == {"count": 0, "left": False} # ③ 지울 줄(id 2) 빠짐 · 새 줄은 id 없이 · 화면 표시 칸 없음 · 머리 상태 실림 assert got["clean"] is False assert got["dirty"] is True sent = got["sent"] assert sent["base_revision"] == 3 assert sent["item"] == { "name": "벌목", "basis_quantity": 1, "basis_unit": "ha", "status": "reviewed", } assert [r.get("id") for r in sent["rows"]] == [1, None, 3] # ⑥ id + 계약 칸만(새 줄은 id 없음) · 글자 칸은 null 아닌 빈 글자 fields = { "variant", "resource_kind", "resource_key", "resource_name", "resource_spec", "amount", "amount_unit", "alternative_amount", "group_ratio_pct", "raw_cell", "pum_table_id", } assert [set(r) for r in sent["rows"]] == [fields | {"id"}, fields, fields | {"id"}] assert sent["rows"][1]["variant"] == "을" and sent["rows"][1]["pum_table_id"] == "" # ④ 고친 칸만 · 새 줄은 줄째 · 안 고친 줄은 빈 목록 · 서버 값(detail)은 안 건드림 assert got["changed"] == ["amount"] assert got["addedRow"] == "added" assert got["untouched"] == [] assert got["freshUntouched"] == 0.5 # ⑤ 갈래는 처음 나온 차례(갑 → 을) · 새 줄은 제 갈래로 assert got["groups"] == [ {"variant": "갑", "indexes": [0, 3]}, {"variant": "을", "indexes": [1, 2]}, ]