Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""Z01 마스터 데이터 표 칸 — 숨김 열 · 열 제목(한글 label + 단위) · 칸 글자 · 쪽 수 (2026-09-15 브레인 Z01).
|
|
|
|
① 한글 이름은 API 가 `label` 로 줌 — 화면은 사전을 안 가짐(받은 대로 보임)
|
|
② `hidden` 열(내부 id · sha · 생성시각)은 기본 숨김 · 「숨긴 열 보기」면 보임
|
|
③ 단위가 있는 열의 수는 천 단위 쉼표(소수 자리는 자르지 않음) · 단위 없는 수(연도 등)는 그대로
|
|
④ 쪽 수는 줄이 없어도 1
|
|
⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데
|
|
줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인)
|
|
⑥ 큰 표(40,000줄 = 800쪽) — 쪽 번호 칸에 적은 값은 1~끝 쪽으로 조임(끝 쪽으로 바로 감)
|
|
⑦ 검색·쪽 요청이 겹치면 최신 응답만 — 늦게 온 옛 응답·옛 오류는 버림 (2026-09-15 브레인)
|
|
TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식).
|
|
"""
|
|
|
|
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_Cells.ts"
|
|
|
|
_RUNNER = """
|
|
import { writeFileSync } from "node:fs";
|
|
import {
|
|
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells,
|
|
outdatedBanner, shownColumns, valueWithUnit,
|
|
} from "./Z01_MasterData_UI_Cells.js";
|
|
|
|
// 뒤처짐 띠 — 판정은 서버 outdated 만(화면이 날짜를 안 견줌) · 받는 자리를 띠에 적음(2026-09-15 브레인)
|
|
// ⚠ 한 표가 원본 여럿을 모은 것이라 출처는 목록으로 옴(노임 = 건설업 + 제조업).
|
|
const laborSources = [
|
|
{
|
|
source_id: "labor_const",
|
|
name: "건설업 시중노임단가",
|
|
publisher: "대한건설협회",
|
|
where: "cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]",
|
|
cycle: "해마다 두 번",
|
|
latest_published: "2026-09-01",
|
|
our_edition: "2026-01-01",
|
|
checked_at: "2026-09-15",
|
|
outdated: true,
|
|
},
|
|
{
|
|
source_id: "labor_mfg",
|
|
name: "중소제조업 직종별 임금",
|
|
publisher: "중소기업중앙회",
|
|
where: "",
|
|
latest_published: null,
|
|
our_edition: "2026-07-01",
|
|
outdated: null,
|
|
},
|
|
];
|
|
const banners = [
|
|
outdatedBanner(laborSources),
|
|
outdatedBanner([{ ...laborSources[0], outdated: false }]),
|
|
outdatedBanner(undefined),
|
|
];
|
|
|
|
// 기준일 — 한 표에 시기가 다른 판이 섞이니 줄마다 보임 · 없으면 「판 모름」(2026-09-15 브레인 ①)
|
|
const dated = [
|
|
{ key: "occupation_name", label: "직종명", unit: null, hidden: false },
|
|
{ key: "effective_date", label: "기준일", unit: null, hidden: false },
|
|
];
|
|
const editions = [
|
|
rowCells({ occupation_name: "보통인부", effective_date: "2026-01-01" }, dated),
|
|
rowCells({ occupation_name: "기계운전", effective_date: null }, dated),
|
|
rowCells({ occupation_name: "굴착기" }, dated),
|
|
];
|
|
|
|
// 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인)
|
|
const meta = {
|
|
editable: ["daily_wage_krw"],
|
|
locked: { hourly_krw: "계산으로 나옴" },
|
|
formula: { hourly_krw: "기계경비 = 손료 + 연료 + 노무" },
|
|
};
|
|
const machineRow = {
|
|
"@id": "M-001",
|
|
daily_wage_krw: 300000,
|
|
hourly_krw: 12340,
|
|
"@overrides": { daily_wage_krw: { original: 250000, value: 300000 } },
|
|
"@formula": { hourly_krw: "12,340 = 8,200 + 2,140 + 2,000" },
|
|
};
|
|
// 판정은 서버 state 만 — edited · source_changed · orphan (화면이 original≠current 로 따로 안 셈)
|
|
const withState = (state, current) => ({
|
|
"@id": "M-002",
|
|
daily_wage_krw: 300000,
|
|
"@overrides": { daily_wage_krw: { original: 250000, value: 300000, current, state } },
|
|
});
|
|
// 값은 같아도 서버가 바뀜이라 하면 바뀜 · 값이 달라도 서버가 edited 라 하면 edited
|
|
const changedRow = withState("source_changed", 250000);
|
|
const sameRow = withState("edited", 270000);
|
|
const orphanRow = withState("orphan", undefined);
|
|
|
|
const wait = (ms, value, fail) =>
|
|
new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms));
|
|
const fetchLatest = latestOnly(wait);
|
|
// 옛 검색(느림) → 새 검색(빠름) · 옛 오류(느림) → 새 성공(빠름)
|
|
const race = await Promise.all([fetchLatest(60, "old"), fetchLatest(10, "new")]);
|
|
const raceError = await Promise.all([
|
|
fetchLatest(60, "old error", true).catch((e) => "thrown " + e.message),
|
|
fetchLatest(10, "new"),
|
|
]);
|
|
const latestError = await fetchLatest(5, "boom", true).catch((e) => "thrown " + e.message);
|
|
|
|
const columns = [
|
|
{ key: "id", label: "id", unit: null, hidden: true },
|
|
{ key: "occupation_name", label: "직종명", unit: null, hidden: false },
|
|
{ key: "daily_wage_krw", label: "일 노임", unit: "원", hidden: false },
|
|
];
|
|
writeFileSync(process.argv[2], JSON.stringify({
|
|
shown: shownColumns(columns, false).map((c) => c.key),
|
|
shownAll: shownColumns(columns, true).map((c) => c.key),
|
|
titles: columns.map(columnTitle),
|
|
cells: [
|
|
cellText(null, null),
|
|
cellText(250000, "원"),
|
|
cellText(1.23456, "㎥"),
|
|
cellText(2026, null),
|
|
cellText({ a: 1 }, null),
|
|
cellText(true, null),
|
|
cellText("보통인부", null),
|
|
// 소수 끝 떠돌이 자리(부동소수) — 기계 손료계수 0.0001811 이 0.00018109999999999998 로 보이던 것
|
|
cellText(0.00018109999999999998, null),
|
|
],
|
|
pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)],
|
|
row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns),
|
|
clamp: [clampPage(801, 800), clampPage(0, 800), clampPage(NaN, 800), clampPage(12.6, 800)],
|
|
race: race.map((v) => v ?? null),
|
|
raceError: raceError.map((v) => v ?? null),
|
|
latestError,
|
|
info: [
|
|
cellInfo(meta, machineRow, "daily_wage_krw"),
|
|
cellInfo(meta, machineRow, "hourly_krw"),
|
|
cellInfo({}, { name: "x" }, "name"),
|
|
cellInfo(meta, changedRow, "daily_wage_krw"),
|
|
cellInfo(meta, sameRow, "daily_wage_krw"),
|
|
cellInfo(meta, orphanRow, "daily_wage_krw"),
|
|
],
|
|
parsed: [
|
|
parseCellInput("12,340", 100),
|
|
parseCellInput("abc", 100),
|
|
parseCellInput("", 100),
|
|
parseCellInput("보통인부2", "보통인부"),
|
|
parseCellInput("3.5", null),
|
|
].map((v) => (v === undefined ? "invalid" : v)),
|
|
editions,
|
|
withUnit: [
|
|
valueWithUnit(17, "원"),
|
|
valueWithUnit(41500000, "원"),
|
|
valueWithUnit(0.17, ""),
|
|
valueWithUnit(12345.678, null),
|
|
valueWithUnit(null, "원"),
|
|
valueWithUnit("5억 미만", ""),
|
|
],
|
|
banners,
|
|
}));
|
|
"""
|
|
|
|
|
|
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"))
|
|
|
|
assert got["shown"] == ["occupation_name", "daily_wage_krw"]
|
|
assert got["shownAll"] == ["id", "occupation_name", "daily_wage_krw"]
|
|
assert got["titles"] == ["id", "직종명", "일 노임 (원)"]
|
|
assert got["cells"] == [
|
|
"",
|
|
"250,000",
|
|
"1.23456",
|
|
"2026",
|
|
'{"a":1}',
|
|
"예",
|
|
"보통인부",
|
|
"0.0001811",
|
|
]
|
|
assert got["pages"] == [1, 140, 1]
|
|
# columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림
|
|
assert got["row"] == ["—", "", "250,000"]
|
|
assert got["clamp"] == [800, 1, 1, 13]
|
|
# 옛 응답은 늦게 와도 버림(null) · 옛 오류도 안 던짐 · 최신 오류는 던짐
|
|
assert got["race"] == [None, "new"]
|
|
assert got["raceError"] == [None, "new"]
|
|
assert got["latestError"] == "thrown boom"
|
|
# 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식
|
|
wage, hourly, plain, changed, same, orphan = got["info"]
|
|
# state 가 안 온 고친 칸은 화면이 때우지 않고 「missing」으로 드러냄(서버 버그)
|
|
assert wage == {
|
|
"editable": True,
|
|
"reason": None,
|
|
"overridden": True,
|
|
"original": 250000,
|
|
"state": "missing",
|
|
"rule": None,
|
|
"filled": None,
|
|
}
|
|
# 원본이 바뀐 고친 칸 — 고친 값은 그대로 쓰되 또렷이 알림 · 지금 원본 값을 곁들임(브레인)
|
|
assert changed["state"] == "source_changed" and changed["current"] == 250000
|
|
assert same["state"] == "edited" and "current" not in same
|
|
assert orphan["state"] == "orphan"
|
|
assert hourly["state"] is None and plain["state"] is None
|
|
assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴"
|
|
assert hourly["overridden"] is False and "original" not in hourly
|
|
assert hourly["rule"] == "기계경비 = 손료 + 연료 + 노무"
|
|
assert hourly["filled"] == "12,340 = 8,200 + 2,140 + 2,000"
|
|
assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None
|
|
# 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수
|
|
assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5]
|
|
# 기준일이 없으면 빈칸·「—」가 아니라 「판 모름」
|
|
assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"]
|
|
# 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만
|
|
assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"]
|
|
# 뒤처진 원본만 한 줄씩 · 판정을 모르는 것(outdated null)은 안 세움 · 없으면 빈 목록
|
|
assert got["banners"] == [
|
|
[
|
|
"건설업 시중노임단가 — 이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · "
|
|
"대한건설협회 cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]"
|
|
],
|
|
[],
|
|
[],
|
|
]
|