From 3a4823b6da58d87e30ef09e1eaa98ad16e8e6c9b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:59:52 +0900 Subject: [PATCH] =?UTF-8?q?perf(M01):=20=EC=9A=94=EC=86=8C=C2=B7=ED=91=9C?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EB=8F=84=20=ED=9D=94=EB=93=A4=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=EB=A7=8C=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=E2=80=94=20=E3=80=8C=EC=96=B4=EB=8A=90=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=B4=20=EB=AC=B4=EC=97=87=EC=9D=84=20=EB=B3=B4=EB=8A=94?= =?UTF-8?q?=EC=A7=80=E3=80=8D=20=EC=A7=80=EB=8F=84=20(=EC=9D=BC=EA=B0=90?= =?UTF-8?q?=2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인력 한 줄 저장 7.0초 → 0.9초 · 자재품목 7.2초 → 2.0초 · 소요량 표 1.7초 · 자재품목 저장 뒤 목록 2.5초 → 0.45초 · 켠 뒤 첫 목록 3.4초 → 1.6초 - 로직 줄을 셀 때 `Master.get` 을 덧옷(`_Watch`)으로 감싸 그 줄이 본 자리를 모음 — 키 · 「ID:원문번호」 · 없는 키를 물은 것까지 · 재료 고르기 줄은 자재품목 묶음 통째 - 저장은 그 지도로 흔들리는 줄만 검사(고친 키 · 고치기 전후 원문번호 · 지운 줄) · 요소를 고친 저장은 연결·준용 검사를 그대로 돌림 - 저장 뒤 목록은 흔들린 자리를 알려 받아(`stale(names, marks)`) 그 줄만 다시 셈 · 다른 창이 고친 파일이면 예전처럼 통째로 다시 셈 - 자재품목을 「구분」 으로 미리 갈라 둠(`master_material.by_class`) — 고르기 후보를 3만 줄에서 훑지 않음 · 가른 차례는 파일 차례 그대로라 고르는 줄은 안 바뀜 - 전수 확인 — 요소·표 파일 90 개마다 줄을 통째로 비우고 로직 1,354 줄을 다시 검사, 달라진 줄이 모두 지도 안(지도 밖 0) - 시험 `test_m01_cache.py` 에 셋 더함(열둘) — 쓰이는 요소를 지우면 저장이 막힘 · 요소를 고쳐 저장한 뒤 막힘 표시가 줄마다 검사한 것과 같음 · 인력 줄을 비운 전수 대조 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Store.py | 6 +- M01_MasterData/M01_MasterData_Store_Cache.py | 123 ++++++++++++++---- resources/master_data/ref/_검증_화면늦음.md | 21 +++ resources/master_data/scripts/check_master.py | 2 +- .../master_data/scripts/master_material.py | 17 ++- resources/tester/test_m01_cache.py | 58 +++++++++ 6 files changed, 193 insertions(+), 34 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 0bef24c9..cf8d28e0 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -665,7 +665,8 @@ def save(batch: list[dict]) -> list[dict]: for part in batch: _apply(after[part["file"]], part.get("changes") or [], part["file"], book) mk.finish(book) - # 고친 줄과 그 줄을 부르던 줄만 검사 — 마스터 전체 검사는 `check_master` 몫 + # 저장이 흔드는 줄만 검사(고친 줄 · 그 줄을 부르던 줄 · 그 요소를 보던 줄) — + # 마스터 전체 검사는 `check_master` 몫 narrowed = cache.narrow(FOLDER, batch, before, after) old = set(cache.check_saved(before, names, mk.load_book(book_path), narrowed)) new = [x for x in cache.check_saved(after, names, book, narrowed) if x not in old] @@ -683,5 +684,6 @@ def save(batch: list[dict]) -> list[dict]: tmp.write_text(text, encoding="utf-8", newline="\n") os.replace(tmp, FOLDER / name) out.append({"file": name, "version": version_of(text.encode("utf-8"))}) - cache.stale(dict.fromkeys(names)) # 저장 뒤에는 그 파일을 바로 다시 읽게 + # 저장 뒤에는 그 파일을 바로 다시 읽게 · 흔들린 자리를 알려 줘 그 줄만 다시 세게 + cache.stale(dict.fromkeys(names), narrowed[2]) return out diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index a2fa27a3..c704de76 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -4,7 +4,8 @@ 자국(파일 시각·크기)이 바뀐 파일만 다시 읽고, 로직 막힘도 미리 세어 둠 — 목록은 검사를 돌리지 않고 세어 둔 값을 씀. 고치는 길(`calc` · `text` · `save`)은 사본을 만지므로 그대로 `cm.load` 로 새로 읽음. -저장 전 검사(`check_saved`)도 여기 — 세어 둔 부르기 그림으로 고친 줄 둘레만 봄(`narrow`). +저장 전 검사(`check_saved`)도 여기 — 어느 로직이 어느 요소·표·로직을 보는지 세어 두고 +그것으로 저장이 흔드는 줄만 검사함(`narrow`). """ from __future__ import annotations @@ -19,12 +20,14 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts" sys.path.insert(0, str(SCRIPTS)) import check_master as cm # noqa: E402 +import master_material as mm # noqa: E402 (자재품목 테이블ID — 재료 고르기는 그 묶음을 훑음) _FRESH = threading.RLock() _READ: dict[str, tuple[tuple, dict]] = {} # 파일 이름 → (자국, 읽은 내용) _INDEX: dict = {} # 폴더 · 자국 · files · whole -_BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: (막힘 까닭, 부르는 로직)}) +_BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: `_check` 가 센 것}) _BASE: dict[str, tuple] = {} # 막힘을 셀 때 바탕이던, 로직 아닌 파일의 자국 +_MARKED: dict[str, set] = {} # 우리가 고친 요소·표 파일 → 흔들린 자리(`narrow` 가 고른 것) def _stamps(folder: Path) -> dict[str, tuple]: @@ -43,6 +46,36 @@ def _logic_files(files: dict[str, dict]): yield name, data +class _Watch: + """`Master.get` 을 감싸 그 로직 줄이 본 요소·표를 적어 둠 — 저장 때 검사를 좁히는 지도.""" + + def __init__(self, whole): + self._whole, self.seen = whole, set() + + def get(self, ref): + self.seen.add(str(ref)) + return self._whole.get(ref) + + def __getattr__(self, name): + return getattr(self._whole, name) + + def __setattr__(self, name, value): # 속에 적어 둠 — 미리 갈라 둔 것을 줄마다 다시 안 짜게 + if name in ("_whole", "seen"): + super().__setattr__(name, value) + else: + setattr(self._whole, name, value) + + +def _check(whole, row: dict) -> tuple[list[str], set, set]: + """(막힘 까닭, 부르는 로직, 본 요소·표) — 본 것은 키 · 「ID:원문번호」 · + 재료 고르기가 있으면 자재품목 묶음 통째(「#MT」 · 후보를 그 안에서 고름).""" + watch = _Watch(whole) + why, calls = cm.mf.check_logic(watch, row) + if any(isinstance(h.get("요소"), dict) for h in row.get("호표") or []): + watch.seen.add("#" + mm.MARKET) + return why, calls, watch.seen + + def forget() -> None: """읽어 둔 것을 버림 — 파일을 고친 자리(저장)에서 부름.""" with _FRESH: @@ -50,17 +83,19 @@ def forget() -> None: _INDEX.clear() _BLOCK.clear() _BASE.clear() + _MARKED.clear() -def stale(names) -> None: +def stale(names, marks=()) -> None: """그 파일만 다시 읽게 함 — 저장처럼 스스로 고친 자리에서 부름 - (자국은 시각이 같은 틱이면 못 가릴 수 있으므로 고친 쪽이 알려 줌).""" + (자국은 시각이 같은 틱이면 못 가릴 수 있으므로 고친 쪽이 알려 줌). + `marks` = 요소·표에서 흔들린 자리 — 그 자리를 보던 로직 줄만 다시 셈.""" with _FRESH: for name in names: was = _READ.pop(name, (None, {}))[1] _BLOCK.pop(name, None) if was.get("그룹") != "로직": - _BASE.clear() # 요소·표가 바뀌면 모든 로직을 다시 셈 + _MARKED.setdefault(name, set()).update(marks) _INDEX.pop("자국", None) @@ -90,10 +125,20 @@ def blocks(folder: Path) -> dict[str, list[str]]: files, whole = loaded(folder) stamps = _INDEX["자국"] base = {n: s for n, s in stamps.items() if files[n].get("그룹") != "로직"} + marks = None # 요소·표에서 흔들린 자리 — None 이면 무엇이 바뀐지 모름(통째로 다시) if _BASE != base: # 인력 · 재료 · 표가 바뀌면 바탕이 달라짐 - _BLOCK.clear() + off = {n for n in set(_BASE) | set(base) if _BASE.get(n) != base.get(n)} + marks = set() + for name in off: + if name not in _MARKED: # 다른 창이 고친 파일 — 어디가 흔들렸는지 모름 + marks = None + break + marks |= _MARKED[name] + if marks is None: + _BLOCK.clear() _BASE.clear() _BASE.update(base) + _MARKED.clear() moved, fresh = set(), set() # 바뀐 로직 파일의 키(옛것 · 새것) names = {name for name, _ in _logic_files(files)} for gone in [name for name in _BLOCK if name not in names]: # 파일째 사라진 것 @@ -101,45 +146,64 @@ def blocks(folder: Path) -> dict[str, list[str]]: for name, data in _logic_files(files): if _BLOCK.get(name, (None,))[0] == stamps[name]: continue - got = {str(r.get("키")): cm.mf.check_logic(whole, r) for r in data.get("줄") or []} + got = {str(r.get("키")): _check(whole, r) for r in data.get("줄") or []} moved |= set(_BLOCK.get(name, (None, {}))[1]) | set(got) _BLOCK[name] = (stamps[name], got) fresh.add(name) - for name, data in _logic_files(files): # 다른 파일에서 그 로직을 부르던 줄 + for name, data in _logic_files(files): # 그 로직을 부르던 줄 · 바뀐 요소를 보던 줄 if name in fresh: continue got = _BLOCK[name][1] rows = {str(r.get("키")): r for r in data.get("줄") or []} - for key in [k for k, (_, calls) in got.items() if calls & moved]: - got[key] = cm.mf.check_logic(whole, rows[key]) - return {key: why for _, got in _BLOCK.values() for key, (why, _) in got.items()} + shaken = [ + k + for k, (_, calls, seen) in got.items() + if calls & moved or (marks and seen & marks) + ] + for key in shaken: + got[key] = _check(whole, rows[key]) + return {key: why for _, got in _BLOCK.values() for key, (why, *_) in got.items()} -def graph(folder: Path) -> dict[str, set]: - """로직 키마다 부르는 로직 키 — 미리 세어 둔 것(저장 때 검사를 좁히는 데 씀).""" +def counted(folder: Path) -> dict[str, tuple]: + """로직 키마다 (막힘 까닭, 부르는 로직, 본 요소·표) — 미리 세어 둔 것.""" with _FRESH: blocks(folder) - return {key: calls for _, got in _BLOCK.values() for key, (_, calls) in got.items()} + return {key: got for _, rows in _BLOCK.values() for key, got in rows.items()} + + +def _rows_of(data: dict) -> list: + return data.get("표") or data.get("줄") or [] def narrow(folder: Path, batch: list[dict], before: dict, after: dict): - """(검사할 로직 키, 안 보는 줄이 부르는 키) — 저장이 건드린 줄과 그 줄을 부르던 줄만. + """(검사할 로직 키, 안 보는 줄이 부르는 키, 요소도 고쳤는지) — 저장이 흔드는 줄만. - 로직 밖 파일(요소 · 표)이 섞이면 어느 로직이 흔들릴지 모르므로 None — 전부 검사. + 로직 줄은 고친 줄과 **그 줄을 부르던 줄** · 요소·표 줄은 그 줄을 **보던 로직** + (키 · 「ID:원문번호」 · 자재품목은 묶음 통째 — `_Watch` 가 적어 둔 지도). """ - if any(after[part["file"]].get("그룹") != "로직" for part in batch): - return None - calls, touched, gone = graph(folder), set(), set() + counts = counted(folder) + calls = {key: got[1] for key, got in counts.items()} + touched, gone, marks = set(), set(), set() for part in batch: - was, now = [ - {str(r.get("키")) for r in side[part["file"]].get("줄") or []} - for side in (before, after) - ] - touched |= now - was # 더한 줄(키는 저장 때 대장이 냄) - gone |= was - now - touched |= {str(c.get("key")) for c in part.get("changes") or [] if c.get("op") != "add"} + name = part["file"] + was, now = ({str(r.get("키")): r for r in _rows_of(side[name])} for side in (before, after)) + keys = (set(now) - set(was)) | { + str(c.get("key")) for c in part.get("changes") or [] if c.get("op") != "add" + } + if after[name].get("그룹") == "로직": + touched |= keys + gone |= set(was) - set(now) + continue + for key in keys | (set(was) - set(now)): # 지운 줄도 — 가리키던 자리가 끊김 + marks |= {key, "#" + key[:2]} + for side in (was, now): + number = (side.get(key) or {}).get("원문번호") + if number: + marks.add(f"{key[:2]}:{number}") only = touched | {key for key, called in calls.items() if called & touched} - return only, {key: called for key, called in calls.items() if key not in gone} + only |= {key for key, got in counts.items() if got[2] & marks} + return only, {key: called for key, called in calls.items() if key not in gone}, marks def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> list[str]: @@ -150,7 +214,10 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l """ whole = cm.mf.Master(files) out = [x for name in changed for x in cm.check_form(name, files[name])] - return out + cm.check_logics(files, whole, book, narrowed) + cm.mcb.check_all(files, whole) + out += cm.check_logics(files, whole, book, narrowed and narrowed[:2]) + if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로) + out += cm.check_links(whole) + return out + cm.mcb.check_all(files, whole) def warm(folder: Path) -> threading.Thread: diff --git a/resources/master_data/ref/_검증_화면늦음.md b/resources/master_data/ref/_검증_화면늦음.md index a93f925b..9301bdf9 100644 --- a/resources/master_data/ref/_검증_화면늦음.md +++ b/resources/master_data/ref/_검증_화면늦음.md @@ -63,3 +63,24 @@ - 저장 뒤에도 잡히는지 — 견본 넷이 모두 422 로 막힘: 없는 로직 키(GC999999) · 없는 인력 요소 키(LB999999) · 돌고 도는 참조 · 고친 줄을 **다른 파일에서** 부르던 줄이 깨짐(입력 하나 더함). 시험 `resources/tester/test_m01_cache.py` 아홉(넷이 이번 것). - 단위 경고(`check_units`)는 애초부터 저장 검사가 아니라 `check_master 단위` 몫 — 14건 그대로. - 남은 늦은 자리 — 저장 0.8초 가운데 0.6초는 마스터를 두 번(고치기 전·후) 새로 읽는 것. **요소 파일 저장은 그대로 7.0초**(인력 한 줄 실측): 요소를 가리키는 로직을 키 글자로만 찾으면 원문번호로 가리키는 자리를 놓쳐 저장 검사에 구멍이 나므로 좁히지 않음 — 좁히려면 「요소를 부르는 로직」 지도부터 만들어야 함. + +## 고친 뒤 3 (일감 33 — 요소 저장 7초) + +요소·표를 한 줄 고쳐도 로직 1,354 줄을 다 검사하던 자리. 잰 자리는 위와 같음. + +| 길 | 앞 | 뒤 | +|---|---|---| +| 인력 한 줄 저장 | 7.0초 | **0.9초** | +| 자재품목 한 줄 저장 | 7.2초 | **2.0초** | +| 자재품목 저장 뒤 목록 | 2.5초 | **0.45초** | +| 소요량 표 한 줄 저장 | 7초쯤(인력과 같은 길 · 따로 안 쟀음) | **1.7초** | +| 로직 한 줄 저장 | 0.8초 | 0.7초 | +| 켠 뒤 첫 목록 | 3.4초 | **1.6초**(미리 읽기 1.0초 · 뒤에서 하므로 기다리지 않음) | + +- **어느 로직이 무엇을 보는지 지도** — 로직 줄을 셀 때 `Master.get` 을 덧옷(`_Watch`)으로 감싸 그 줄이 본 자리를 모아 둠(키 · 「ID:원문번호」 · 없는 키를 물은 것까지). 재료 고르기가 있는 줄은 자재품목 묶음 통째(「#MT」) — 후보를 그 안에서 고르므로. 지도는 막힘과 같은 자리에 같은 방식으로 갱신됨(파일 자국이 바뀐 것만). +- **저장** — 그 지도로 흔들리는 줄만 검사: 고친 요소의 키 · 원문번호(고치기 전·후 둘 다) · 지운 줄 · 자재품목이면 묶음 통째. 로직 줄 고침은 앞서대로 고친 줄과 그 줄을 부르던 줄. 요소를 고친 저장은 연결·준용 검사(`check_links`)를 그대로 돌림. +- **저장 뒤 목록** — 고친 쪽이 흔들린 자리를 알려 주므로(`stale(names, marks)`) 그 자리를 보던 줄만 다시 셈. 다른 창이 고친 파일이면 무엇이 흔들렸는지 모르므로 예전처럼 통째로 다시 셈. +- **자재품목을 「구분」 으로 미리 갈라 둠**(`master_material.by_class`) — 고르기 후보를 3만 줄에서 훑던 것을 그 구분 안에서만. 저장·목록·`check_master 로직`(2.1초)·`단위`(1.5초)가 함께 빨라짐. 가른 차례는 파일 차례 그대로라 고르는 줄은 바뀌지 않음. +- **전수 확인** — 요소·표 파일 90 개마다 그 파일 줄을 통째로 비운 뒤 로직 1,354 줄을 모두 다시 검사해, 결과가 달라진 줄이 지도가 고른 줄 안에 드는지 봄: **지도 밖 0**(모든 파일에서 「지도 = 달라진 줄」 이 정확히 같음). 지도 한 줄이 보는 자리는 가운데 4 곳 · 가장 많은 170 곳 · 고르기를 쓰는 줄 189. +- 시험 — `resources/tester/test_m01_cache.py` 열둘(셋이 이번 것: 쓰이는 요소를 지우면 저장이 막힘 · 요소를 고쳐 저장한 뒤 막힘 표시가 줄마다 검사한 것과 같음 · 인력 줄을 비운 전수 대조가 지도 안에 듦). +- 남은 늦은 자리 — 자재품목 저장 2.0초의 대부분은 고르기 쓰는 로직 189 줄을 저장 앞뒤로 두 번 검사하는 것 · 마스터를 두 번 새로 읽는 0.6초. diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 0b74792b..6f2193b2 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -570,7 +570,7 @@ def check_logics( out += [f"{name} · {x}" for x in found] graph[key] = calls out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)] - return out + duplicate_keys(files, book) + ([] if only else check_links(whole)) + return out + duplicate_keys(files, book) + ([] if only is not None else check_links(whole)) def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]: diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index d88a77cd..8f30ab58 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -116,6 +116,18 @@ def cond_text(cond: dict) -> str: return "|".join(str(cond.get(k) or "") for k in PICK_KEYS) +def by_class(master: mf.Master) -> dict[str, dict]: + """자재품목을 「구분」 으로 미리 갈라 둠(마스터마다 한 번) — 고르기 후보를 찾을 때 + 3만 줄을 다 훑지 않게. 가른 차례는 파일 차례 그대로.""" + got = getattr(master, "_by_class", None) + if got is None: + got = {} + for key, row in master.index.get(MARKET, {}).items(): + got.setdefault(row.get("구분"), {})[key] = row + master._by_class = got + return got + + def candidates( master: mf.Master, cond: dict, env: dict | None = None, unit: str | None = None ) -> list[str]: @@ -127,9 +139,8 @@ def candidates( need = plain_unit(unit) if unit else "" hits = [ key - for key, row in master.index.get(MARKET, {}).items() - if row.get("구분") == cond.get("구분") - and (not detail or row.get("상세구분") == detail) + for key, row in by_class(master).get(cond.get("구분"), {}).items() + if (not detail or row.get("상세구분") == detail) and all(w in plain_spec(row.get("규격")) for w in want) and (not need or plain_unit(row.get("단위")) == need) ] diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py index b28139b7..64f3e6f1 100644 --- a/resources/tester/test_m01_cache.py +++ b/resources/tester/test_m01_cache.py @@ -189,3 +189,61 @@ def test_미리_읽어_두면_첫_목록이_기다리지_않는다(client: TestC start = time.perf_counter() assert _logics(client) assert time.perf_counter() - start < 1.0 # 미리 안 읽으면 2~3초 + + +def _plain(file: str) -> dict: + """정본 파일 그대로(수는 글이 아닌 그대로) — 저장에 실어 보낼 줄을 꺼내려고.""" + return json.loads((store.FOLDER / file).read_text(encoding="utf-8")) + + +def _save_file(client: TestClient, file: str, changes: list[dict]): + """정본 파일을 저장 길로 고침 — 요소·표 줄도 같은 길.""" + return client.post( + "/api/m01/save", + json={"files": [{"file": file, "version": store.read(file)[1], "changes": changes}]}, + ) + + +def test_쓰이는_요소를_지우면_저장이_막힌다(client: TestClient) -> None: + """요소 저장도 좁혀 검사하지만 그 요소를 쓰던 로직은 그대로 걸림.""" + _logics(client) # 미리 세어 둔 값이 선 평소 모양 + res = _save_file(client, "인력.json", [{"op": "delete", "key": "LB000014"}]) + assert any("LB000014" in x for x in _errors(res)) + + +def test_요소를_고쳐_저장해도_막힘_표시가_전수와_같다(client: TestClient) -> None: + """요소를 고친 뒤 다시 센 막힘이 줄마다 검사한 것과 같은지 — 좁혀 세는 길.""" + was = _logics(client) + raw = _plain("인력.json") + row = {**next(r for r in raw["줄"] if str(r.get("키")) == "LB000014"), "비고": "저장 검사 시험"} + res = _save_file(client, "인력.json", [{"op": "edit", "key": "LB000014", "row": row}]) + assert res.status_code == 200, res.text + listed = _logics(client) + files, whole = store.loaded() + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + assert [x["키"] for x in listed] == [x["키"] for x in was] + for one in listed: + assert one["reasons"] == store.cm.mf.check_logic(whole, rows[one["키"]])[0] + + +def test_요소_지도가_전수_검사와_같다(client: TestClient) -> None: + """「어느 로직이 어느 요소를 보는지」 지도가 놓치는 자리가 없는지 — + 인력 줄을 통째로 비우고 1,354 줄을 다 검사해, 달라진 줄이 지도 안에 드는지 봄.""" + counts = store.cache.counted(store.FOLDER) + files, _ = store.loaded() + marks = set() + for row in files["인력.json"]["줄"]: + key = str(row.get("키")) + marks |= {key, "#" + key[:2]} + if row.get("원문번호"): + marks.add(f"{key[:2]}:{row['원문번호']}") + marked = {key for key, got in counts.items() if got[2] & marks} + empty = {**files, "인력.json": {**files["인력.json"], "줄": []}} + other = store.cm.mf.Master(empty) + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + changed = {k for k, r in rows.items() if store.cm.mf.check_logic(other, r)[0] != counts[k][0]} + assert changed and changed <= marked