Files
Aislo/resources/tester/test_z01_master_cells.py
T

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 [지원·사업] > [건설적산기준] > [건설임금]"
],
[],
[],
]