"""826-2 기계 = 항목 하나 파일 하나(M01 기계 · 내 기계) + M01 저장 길 줄이기 — 지킴 시험(임시 사본만). ① 폴더 → 옛 한 파일 꼴(머리 · 줄 · 키 차례) · 옮기는 빌더 왕복 같음 · 두 번 써도 안 바뀜 ② 한 대 고침 = 파일 하나만 · 새 기계 = 파일 하나 더함(끝) · 지움 = 파일 하나 지움 · 판 충돌 409 ③ 내 기계 — 옛 `내기계.json` 은 읽을 때 폴더로 한 번 옮김(내용 · 다음 번호 같음 · 옛 파일 지움) ④ 저장 검사 좁히기 — 옛 길(폴더 두 번 읽고 전체 검사 두 번)과 같은 오류 목록(견본 고침 스물) """ from __future__ import annotations import copy import json import shutil import sys from pathlib import Path import pytest from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Cache as cache from M02_MasterTemplete import M02_MyMachine as mym ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "resources" / "master_data" / "scripts")) import master_machine_files as mmf # noqa: E402 REAL = store.FOLDER cm, mk = store.cm, store.mk M, MT, LB, MO = mmf.NAME, "재료_자재품목.json", "인력.json", "재료_유가전력.json" @pytest.fixture def master(tmp_path: Path, monkeypatch) -> Path: for path in REAL.glob("*.json"): if not path.name.startswith("_") or path.name == mk.BOOK.name: shutil.copy(path, tmp_path / path.name) shutil.copytree(REAL / mmf.DIR, tmp_path / mmf.DIR) monkeypatch.setattr(store, "FOLDER", tmp_path) return tmp_path def _snap(folder: Path) -> dict[str, bytes]: return {p.name: p.read_bytes() for p in (folder / mmf.DIR).glob("*.json")} def _rows(file: str) -> list[dict]: data = store.read(file)[0] return data[store.items_key(data)] def _row(file: str, key: str) -> dict: return copy.deepcopy(next(r for r in _rows(file) if str(r["키"]) == key)) def _part(file: str, *changes: dict) -> dict: return {"file": file, "version": store.read(file)[1], "changes": list(changes)} # ① 폴더 = 옛 한 파일 꼴 ───────────────────────────────────────────── def test_folder_reads_as_one_table(tmp_path: Path) -> None: data, version = mmf.table(REAL) assert list(data) == ["그룹", "원문", "구분", "줄"] and data["그룹"] == "기계" keys = [r["키"] for r in data["줄"]] assert len(keys) >= 655 and keys == sorted(keys) == [ p.stem for p in sorted((REAL / mmf.DIR).glob("EQ*.json")) ] assert not (REAL / M).exists() # 같은 내용 두 벌 없음 assert cm.load([M])[M] == data and store.read(M) == (data, version) assert cm.load()[M] == data # 마스터 전부 읽기에도 같은 이름으로 # 옮기는 빌더 — 한 파일 → 폴더 → 다시 읽어 같음 · 옛 파일 지움 · 두 번 돌려도 같음 import importlib build = importlib.import_module("build_기계폴더_826_2") (tmp_path / M).write_text(store.dump(data), encoding="utf-8") assert build.move(tmp_path) == len(keys) and not (tmp_path / M).exists() assert mmf.table(tmp_path) == (data, version) # 같은 글 → 같은 판 assert build.move(tmp_path) == 0 and mmf.write(tmp_path, data) == [] # ② 고침 · 새 기계 · 지움 · 판 ───────────────────────────────────────── def test_one_machine_one_file(master: Path) -> None: first = _row(M, "EQ000001") before = _snap(master) first["값"]["가격"] += 1 got = store.save([_part(M, {"op": "edit", "key": "EQ000001", "row": first})]) after = _snap(master) assert {n for n in after if after[n] != before.get(n)} == {"EQ000001.json"} assert got == [{"file": M, "version": store.read(M)[1]}] assert _row(M, "EQ000001") == first and store.loaded()[0][M]["줄"][0] == first new = {**_row(M, "EQ000001"), "이름": "sub1임시기계", "원문번호": ""} new.pop("키") store.save([_part(M, {"op": "add", "row": new})]) added = set(_snap(master)) - set(after) assert len(added) == 1 and _rows(M)[-1]["이름"] == "sub1임시기계" # 끝(다음 키) key = _rows(M)[-1]["키"] assert added == {f"{key}.json"} and store.loaded()[1].get(key)["이름"] == "sub1임시기계" stale = _part(M, {"op": "delete", "key": key}) store.save([_part(M, {"op": "delete", "key": key})]) assert set(_snap(master)) == set(after) and key not in [r["키"] for r in _rows(M)] with pytest.raises(store.StoreError) as e: # 지우기 전 판으로 또 저장 store.save([stale]) assert e.value.status == 409 and e.value.detail == {"stale": [M]} # ③ 내 기계 옮김 ─────────────────────────────────────────────────────── def test_my_machine_moves_once(tmp_path: Path) -> None: rows = [{"키": f"EQP42-{n:04d}", "이름": f"sub1임시{n}", "값": {"가격": n}} for n in (1, 2, 10)] old = {"줄": rows, "다음": 11} (tmp_path / mym.FILE).write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8") got = mym._read(tmp_path) assert got == old and not (tmp_path / mym.FILE).exists() # 내용 · 차례 같음 · 옛 파일 지움 assert sorted(p.name for p in (tmp_path / mym.DIR).iterdir()) == [ "EQP42-0001.json", "EQP42-0002.json", "EQP42-0010.json", mym.NEXT, ] # fmt: skip assert mym._read(tmp_path) == old and mym.rows(tmp_path) == rows assert mym._read(tmp_path / "없음") == {"줄": [], "다음": 1} (tmp_path / mym.FILE).write_text(json.dumps({"줄": [], "다음": 5}), encoding="utf-8") assert mym._read(tmp_path)["다음"] == 11 # 옛 코드가 다시 쓴 옛 파일 — 번호 뒤로 안 감 # ④ 저장 검사 좁히기 = 옛 길과 같은 오류 ───────────────────────────────── def _old_check(files: dict, changed: list[str], book: dict, narrowed) -> list[str]: """826-2 전 `Store_Cache.check_saved` 그대로 — 폴더 전부 색인 · 틀 · 조합 · 모든 층 문서.""" whole = cm.mf.Master(files) out = [x for name in changed for x in cm.check_form(name, files[name])] 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) + cache.labor_errors(whole) def _old(batch: list[dict]) -> list[str]: """826-2 전 저장 검사 길 — 폴더 통째 두 번 읽음 · 전 · 후 전체 검사(쓰지 않음).""" before, after = cm.load(folder=store.FOLDER), cm.load(folder=store.FOLDER) path = store.FOLDER / mk.BOOK.name book = mk.load_book(path) for part in batch: store._apply(after[part["file"]], copy.deepcopy(part["changes"]), part["file"], book) mk.finish(book) names = [part["file"] for part in batch] narrowed = cache.narrow(store.FOLDER, batch, before, after) old = set(_old_check(before, names, mk.load_book(path), narrowed)) return [x for x in _old_check(after, names, book, narrowed) if x not in old] def _new(batch: list[dict]) -> list[str]: try: store.save(copy.deepcopy(batch)) except store.StoreError as e: assert e.status == 422, e.detail return e.detail["errors"] return [] def _edit(file: str, key: str, fix) -> list[dict]: row = _row(file, key) fix(row) return [_part(file, {"op": "edit", "key": key, "row": row})] def _delete(file: str, key: str) -> list[dict]: return [_part(file, {"op": "delete", "key": key})] def _add(file: str, like: str, **cols) -> list[dict]: row = {**_row(file, like), "원문번호": "", **cols} row.pop("키") return [_part(file, {"op": "add", "row": row})] def _logic_with_ref() -> tuple[str, str]: for name, data in store._logic_files(store.loaded()[0]): for row in data.get("줄") or []: for item in row.get("호표") or []: if isinstance(item.get("요소"), str) and item["요소"][:2] == "LB": return name, str(row["키"]) raise AssertionError("요소 줄 있는 로직 없음") def _leaf() -> tuple[str, str]: """아무도 안 부르고 조합에도 없는 로직 — 일위대가 문서만 쓰게 둠.""" files = store.loaded()[0] called = {k for _, calls, _ in cache.counted(store.FOLDER).values() for k in calls} held = {k for _, row in cm.mcb.rows(files) for k in cm.mcb.logic_keys(row)} for name, data in store._logic_files(files): for row in data.get("줄") or []: if str(row["키"]) not in called | held: return name, str(row["키"]) raise AssertionError("잎 로직 없음") def test_narrow_check_same_as_before(master: Path, tmp_path: Path, monkeypatch) -> None: leaf_file, leaf = _leaf() doc = tmp_path / "labor.json" doc.write_text(json.dumps({"담은로직": [{"로직": leaf}]}), encoding="utf-8") monkeypatch.setattr(cache, "_labor_files", lambda: [("일위대가 시험", doc)]) used = next(r["키"] for r in _rows(M) if r.get("원문번호") == "0201-0100") ref_file, ref_logic = _logic_with_ref() combo = [r["키"] for r in _rows(cm.mcb.FILE)] table = next(n for n in store.loaded()[0] if n.startswith("계수_")) cell = _rows(table)[0]["키"] called = "GC000268" # 317 줄이 부름 called_file = next( n for n, d in store._logic_files(store.loaded()[0]) if any(r["키"] == called for r in d["줄"]) ) def bump(r): r["값"]["가격"] += 1 cases = [ # (이름, 할 일, 오류가 나야 함) ("기계 값", lambda: _edit(M, "EQ000001", bump), False), ("기계 별칭", lambda: _edit(M, "EQ000002", lambda r: r.update(별칭="sub1임시")), False), ("기계 별칭 빈 글", lambda: _edit(M, "EQ000002", lambda r: r.update(별칭=" ")), True), ("기계 지움(로직이 씀)", lambda: _delete(M, used), True), ("기계 원문번호", lambda: _edit(M, used, lambda r: r.update(원문번호="9999-9999")), True), ("새 기계", lambda: _add(M, "EQ000003", 이름="sub1임시기계"), False), ("자재 값", lambda: _edit(MT, "MT003175", lambda r: r.update(비고="sub1")), False), ("자재 지움(로직이 씀)", lambda: _delete(MT, "MT003175"), True), ("기계 + 자재 이름(고르기 후보 0)", lambda: _edit(M, "EQ000004", bump) + _edit(MT, "MT000008", lambda r: r.update(이름="sub1임시자재")), True), ("인력 값", lambda: _edit(LB, "LB000003", lambda r: r.update(값=r["값"] + 1)), False), ("인력 지움(로직이 씀)", lambda: _delete(LB, "LB000002"), True), ("인력 준용 끊김", lambda: _edit(LB, "LB000006", lambda r: r.update(준용="LB999999")), True), ("유가 값", lambda: _edit(MO, "MO000001", lambda r: r.update(값=r["값"] + 1)), False), ("표 조건 틀림", lambda: _edit(table, cell, lambda r: r["조건"].update(sub1="모름")), True), ("로직 요소 끊김", lambda: _edit(ref_file, ref_logic, lambda r: next(i for i in r["호표"] if str(i.get("요소", ""))[:2] == "LB").update(요소="LB999999")), True), ("로직 지움(부름 받음)", lambda: _delete(called_file, called), True), ("로직 지움(일위대가 문서가 씀)", lambda: _delete(leaf_file, leaf), True), ("조합 이름 겹침", lambda: _edit(cm.mcb.FILE, combo[0], lambda r: r.update(이름=_rows(cm.mcb.FILE)[1]["이름"])), True), ("조합 지움", lambda: _delete(cm.mcb.FILE, combo[-1]), False), ("새 자재", lambda: _add(MT, "MT000008", 이름="sub1임시새자재"), False), ] # fmt: skip for name, make, bad in cases: batch = make() want = _old(batch) got = _new(batch) assert sorted(got) == sorted(want), name assert bool(want) == bad, (name, want[:3])