- 요율 화면 오른쪽 위 보기 단추 둘(기본 시트 · 세션 기억) — 한 번에 하나만 보임 - 우클릭: 리스트 줄 「시트에서 보기」 · 시트 칸 「리스트에서 보기」 · 대응 없으면 흐림 + 대응 요율 없음(메뉴 부품에 disabled 항목 · 시트 부품에 cellMenu 옵션) - 시트 칸 → RT 줄 찾기 순수 함수(UI_RateSheet_Map.ts) · 병합 칸 보정 · 겹치면 좁은 범위 - 책 대응 범위를 상자 표제 칸까지 넓힘(RT000004 값 칸 BR51 누락 바로잡음) — 값 · RT 정본 그대로 - 대조 스크립트 audit_요율대조.py(RT 잎 216 · 시트 칸 없음 8 · 시트에만 있는 칸 1 · B09 사용 표시) - 시험: test_805_1_rate_views 신설 · test_798_1 대응표 전수 추가 · 옛 「줄 누르면 이동」은 우클릭 메뉴로 대체 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
188 lines
7.4 KiB
Python
188 lines
7.4 KiB
Python
"""805-1 M01 요율 「시트 | 리스트」 보기 · 우클릭 이동 · 시트 ↔ RT 전수 대조.
|
|
|
|
서버 · 화면 없이 도는 지킴 시험.
|
|
① 화면 소스: 기본 보기 = 시트 · 보기 단추 둘 · 세션 기억 · 우클릭 양방향 · 흐림 줄
|
|
② 시트 칸 → RT 줄 찾기(`M01_MasterData_UI_RateSheet_Map.ts`)를 node 로 직접 돌려 대응표 전수 확인
|
|
③ 대조 스크립트(`audit_요율대조.py`): 값 다른 RT 잎 0 · 칸 없는 잎 · 시트에만 있는 칸 = 알려진 목록
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
|
import audit_요율대조 as audit # noqa: E402
|
|
|
|
PAGE = ROOT / "M01_MasterData/M01_MasterData_UI_RateSheet.ts"
|
|
MAP_TS = ROOT / "M01_MasterData/M01_MasterData_UI_RateSheet_Map.ts"
|
|
|
|
|
|
def _src(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_default_view_is_sheet_and_remembered():
|
|
src = _src(PAGE)
|
|
assert 'getItem(VIEW_KEY) === "list" ? "list" : "sheet"' in src # 기본 = 시트
|
|
assert "setItem(VIEW_KEY, next)" in src # 고른 보기 세션에 기억
|
|
assert 'value: "sheet", label: L("M01_RateViewSheet")' in src
|
|
assert 'value: "list", label: L("M01_RateViewList")' in src
|
|
# 한 번에 하나만 보임
|
|
assert 'sheetHost.hidden = next !== "sheet"' in src
|
|
assert 'rowsHost.hidden = next !== "list"' in src
|
|
|
|
|
|
def test_context_menus_both_ways_with_dimmed_fallback():
|
|
src = _src(PAGE)
|
|
assert 'L("M01_RateGoList")' in src and 'L("M01_RateGoSheet")' in src
|
|
assert src.count("{ disabled: true }") == 2 # 시트 칸 · 리스트 줄 둘 다 대응 없으면 흐림
|
|
assert "M01_RateNoRate" in src and "M01_RateNoCellShort" in src
|
|
# 옛 「줄 누르면 이동」은 오른쪽 클릭 메뉴로 대체
|
|
assert 'rowsHost.addEventListener("click"' not in src
|
|
assert 'rowsHost.addEventListener("contextmenu"' in src
|
|
|
|
|
|
def _node_keys(tmp: Path, cells: list[list], merges: list[str], spots: dict) -> list:
|
|
node = shutil.which("node")
|
|
if not node:
|
|
pytest.skip("node 없음")
|
|
script = tmp / "run.mjs"
|
|
script.write_text(
|
|
f"""
|
|
import {{ readFileSync }} from "node:fs";
|
|
import {{ keyAt }} from {json.dumps(MAP_TS.as_uri())};
|
|
const cin = JSON.parse(readFileSync(process.argv[2], "utf-8"));
|
|
const parse = (a1) => {{
|
|
const [p, q = p] = a1.split(":");
|
|
const one = (s) => {{
|
|
const m = /^([A-Z]+)(\d+)$/.exec(s);
|
|
if (!m) return null;
|
|
let c = 0;
|
|
for (const ch of m[1]) c = c * 26 + ch.charCodeAt(0) - 64;
|
|
return [Number(m[2]) - 1, c - 1];
|
|
}};
|
|
const a = one(p);
|
|
const b = one(q);
|
|
if (!a || !b) return null;
|
|
const [r0, r1] = [Math.min(a[0], b[0]), Math.max(a[0], b[0])];
|
|
const [c0, c1] = [Math.min(a[1], b[1]), Math.max(a[1], b[1])];
|
|
return {{ r0, c0, r1, c1 }};
|
|
}};
|
|
const out = cin.cells.map(([sheet, r, c]) => keyAt(cin.spots, parse, cin.merges, sheet, r, c));
|
|
console.log(JSON.stringify(out));
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
data = tmp / "in.json"
|
|
data.write_text(
|
|
json.dumps({"cells": cells, "merges": merges, "spots": spots}), encoding="utf-8"
|
|
)
|
|
run = subprocess.run( # noqa: S603 — 고정 node 실행
|
|
[node, "--experimental-strip-types", "--no-warnings", str(script), str(data)],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=ROOT,
|
|
check=False,
|
|
)
|
|
assert run.returncode == 0, run.stderr[-800:]
|
|
return json.loads(run.stdout)
|
|
|
|
|
|
def _cells_of(a1_range: str) -> list[tuple[int, int]]:
|
|
a, _, b = a1_range.partition(":")
|
|
(ca, ra), (cb, rb) = audit.split(a), audit.split(b or a)
|
|
return [
|
|
(r - 1, c - 1)
|
|
for r in range(ra, rb + 1)
|
|
for c in range(audit.col_index(ca), audit.col_index(cb) + 1)
|
|
]
|
|
|
|
|
|
def test_every_mapped_cell_finds_its_rt_row(tmp_path):
|
|
book, sheet, rates = audit.load()
|
|
spots = book["대응"]
|
|
# 대응표 전수 — 부가세(RT000019)만 시트 밖
|
|
assert set(spots) == {r["키"] for r in rates} - {"RT000019"}
|
|
cells, expect = [], []
|
|
for key, spot in spots.items():
|
|
for r, c in _cells_of(spot["범위"]):
|
|
cells.append([spot["시트"], r, c])
|
|
expect.append(key)
|
|
got = _node_keys(tmp_path, cells, sheet["병합"], spots)
|
|
# 겹침: RT000006 범위 안의 기초액 칸(FA27:FA33)은 더 좁은 RT000007 이 가져감
|
|
for (_s, r, c), want, have in zip(cells, expect, got):
|
|
if want == "RT000006" and have == "RT000007":
|
|
assert 26 <= r <= 33 and c == audit.col_index("FA") - 1
|
|
else:
|
|
assert have == want, (audit.col_name(c + 1), r + 1, want, have)
|
|
|
|
|
|
def test_merged_inside_and_outside_cells(tmp_path):
|
|
book, sheet, _ = audit.load()
|
|
probe = {
|
|
"AI16": "RT000008", # 간접노무비 값 칸 병합(AI15:AK16) 안쪽
|
|
"AT68": "RT000002", # 고용보험 1등급 병합(AH67:AT68) 오른쪽 끝
|
|
"AT82": "RT000002", # 고용보험 7등급 병합(AH79:AT82) 아래 끝
|
|
"BR20": "RT000017", # 일반관리비 전문 병합 안쪽
|
|
"B2": None, # 머리글
|
|
"M15": None, # 규모 · 기간 글 칸
|
|
"BZ47": "RT000001", # 상자 표제 칸([산재보험료])
|
|
"BD47": "RT000005", # 상자 표제 칸([노인장기요양보험료])
|
|
"DK100": None, # 산업안전보건 주석 글(어느 상자도 아님)
|
|
"BR51": "RT000004", # 연금 2026 율
|
|
}
|
|
cells = []
|
|
for a1 in probe:
|
|
col, row = audit.split(a1)
|
|
cells.append([MAIN, row - 1, audit.col_index(col) - 1])
|
|
got = _node_keys(tmp_path, cells, sheet["병합"], book["대응"])
|
|
for (a1, want), have in zip(probe.items(), got):
|
|
assert have == want, (a1, want, have)
|
|
|
|
|
|
MAIN = audit.MAIN
|
|
|
|
|
|
def test_audit_known_gaps_only():
|
|
result = audit.audit()
|
|
leaves = result["leaves"]
|
|
assert [e for e in leaves if e["판정"] == "값 다름"] == [] # 시트와 RT 값이 다른 잎 없음
|
|
no_cell = [e for e in leaves if e["판정"] == "시트 칸 없음"]
|
|
assert {e["RT"] for e in no_cell} == {"RT000002", "RT000004", "RT000019"}
|
|
assert [e["잎"] for e in no_cell if e["RT"] == "RT000002"] == ["7등급 미만"]
|
|
assert len([e for e in no_cell if e["RT"] == "RT000004"]) == 6 # 2027 ~ 2032 (법령 부칙)
|
|
# 시트에만 있는 값 칸 — 산업안전보건관리비 「도급자관급 포함」 식의 ×1.2
|
|
assert [o["시트칸"] for o in result["orphans"]] == ["EB13"]
|
|
# B09 가 읽는 길 표시 — 자동 / 후보 글만 / 안 씀
|
|
by_key = {r["RT"]: r["B09"] for r in result["rows"]}
|
|
assert by_key["RT000008"] == "자동 셈에 씀"
|
|
assert by_key["RT000016"] == "후보 글만(값 직접 입력)"
|
|
assert by_key["RT000007"] == "안 씀"
|
|
|
|
|
|
def test_audit_b09_flags_match_rate_table():
|
|
"""대조표의 「자동 셈에 씀」 줄 = B09 `rate_table()` 이 실제로 읽는 RT 줄."""
|
|
sys.path.insert(0, str(ROOT))
|
|
from B09_Estimation import B09_Estimation_Rates as rates
|
|
|
|
used = {v["rt"] for v in rates.rate_table().values()}
|
|
assert used == set(audit.B09_AUTO)
|
|
|
|
|
|
def test_xlsx_writes_four_sheets(tmp_path):
|
|
import openpyxl
|
|
|
|
out = tmp_path / "list.xlsx"
|
|
audit.write_xlsx(out, audit.audit())
|
|
wb = openpyxl.load_workbook(out)
|
|
assert len(wb.sheetnames) == 4
|
|
gap = wb["RT에 있고 시트 칸 없음 · 다름"]
|
|
assert gap.max_row == 1 + 8 # 머리 + 시트 칸 없음 8 (파생은 칸 없음 아님)
|