From bb9d8a8101a4eaac26b6c0cd6cda0067ed24a56d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:57:32 +0900 Subject: [PATCH] =?UTF-8?q?perf(826-1):=20M02=20=EC=B8=B5=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9A=94=EC=95=BD=20=EC=83=89=EC=9D=B8=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=90=EA=B5=AD=20=EA=B0=99=EC=9C=BC=EB=A9=B4=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=ED=8C=8C=EC=9D=BC=20=EC=95=88=20=EC=97=B6=20=C2=B7?= =?UTF-8?q?=20=ED=8C=90=20=C2=B7=20=EC=9A=94=EC=95=BD=20=C2=B7=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=EA=B3=B3=EC=88=98=20=EA=B8=B0=EC=96=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Layer_Index — 항목마다 자국(고친 때 · 크기) · 판 · 서고 요약 기억(메모리 + storage _derived/m02_layer_index) · 바뀐 항목만 다시 읽어 셈 - 기준금액 기댐 = 셈 코드 내용 · M01 폴더 · 층 곁 파일 자국 + 셈 때 읽은 참조 파일 자국(refs.SEEN) - 쓰는곳수 = 요청마다 쓰는 곳 파일 한 번 훑어 거꾸로 셈 · 참조 이름표 기억도 파생 저장본(ids.json) - 판 확인(저장 · 지움 409)은 그대로 파일 내용 · 목록 답 내용 · 차례 그대로(8004 바이트 대조) - 개인 층(102 줄) 두 번째 3.0 → 0.14 초 · 다시 뜬 뒤 첫 9.9 → 0.24 초 · 마스터 2.9 → 0.10 초 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Ref.py | 10 +- M02_MasterTemplete/M02_Layer_Index.py | 221 ++++++++++++++++++ .../M02_MasterTemplete_Router_Layers.py | 5 +- M02_MasterTemplete/M02_Template_Layers.py | 8 +- resources/tester/test_826_1_layer_index.py | 208 +++++++++++++++++ 5 files changed, 446 insertions(+), 6 deletions(-) create mode 100644 M02_MasterTemplete/M02_Layer_Index.py create mode 100644 resources/tester/test_826_1_layer_index.py diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 6afc85727..1afa5198c 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -24,6 +24,7 @@ from __future__ import annotations import json import re from collections.abc import Callable +from contextvars import ContextVar from pathlib import Path from typing import TYPE_CHECKING, Any @@ -125,11 +126,18 @@ def copy_ref(body: dict[str, Any], key: str) -> Ref: } +#: 참조 파일을 읽기 직전에 부름(826-1 층 목록 색인 기댐 · `M02_Layer_Index`) — None = 안 부름. +SEEN: ContextVar[Callable[[Path], None] | None] = ContextVar("m02_refs_seen", default=None) + + def read_ref(ref: Ref) -> dict[str, Any] | None: if is_copy(ref): return ref["몸"] try: - got = json.loads(ref_path(ref).read_bytes()) + path = ref_path(ref) + if (note := SEEN.get()) is not None: + note(path) + got = json.loads(path.read_bytes()) except (OSError, ValueError): return None return got if isinstance(got, dict) else None diff --git a/M02_MasterTemplete/M02_Layer_Index.py b/M02_MasterTemplete/M02_Layer_Index.py new file mode 100644 index 000000000..9203e5d68 --- /dev/null +++ b/M02_MasterTemplete/M02_Layer_Index.py @@ -0,0 +1,221 @@ +"""M02 층 목록 요약 색인(826-1 · 요구 804 · 조사 804 안 A) — 층 목록이 항목 파일을 다시 열지 않게. + +항목 기록 `{자국, 판, 바탕?, 기댐?, 요약?}`(열쇠 「종류/이름」): + 자국 파일 고친 때 ns : 크기 — 같으면 기억한 판(목록 표시만 · 저장 · 지움 409 는 그대로 + `layers.version_of` 파일 내용) + 요약 서고 줄 `M02_Labor_Ref.summary`(쓰는곳수 뺌) — 자국 · 바탕 · 기댐이 모두 같을 때만 씀 + 바탕 셈 코드 내용 · M01 폴더 · 층 곁 파일(내 기계 등 — 항목 폴더 · manifest 뺌) 자국 지문 + 기댐 셈 때 읽은 참조 파일(SC · SK) 자국(`refs.SEEN`) +쓰는곳수 = 요청마다 쓰는 곳 파일을 한 번 훑어 거꾸로 셈(`refs._ids_of` 기억 · 파일당 stat 한 번). +자리 = 프로세스 메모리 + storage `_derived/m02_layer_index/`(파생 저장본 · 층마다 한 파일 · +참조 이름표 기억 `ids.json`) — 없거나 깨지면 다시 지음 · 지워도 됨 · 다른 PC · 옛 코드는 안 읽음. +""" + +from __future__ import annotations + +import hashlib +import threading +from functools import lru_cache +from pathlib import Path +from typing import Any + +from common_util.common_util_derived_store import derived_dir, derived_key, file_stamp, read_derived +from common_util.common_util_json import atomic_write_json +from config import config_system +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +KIND = "m02_layer_index" +SHAPE = 1 # 기록 꼴이 바뀌면 올림(옛 기록 = 다시 셈) +IDS = "ids" +CODE_DIRS = ("M02_MasterTemplete", "M01_MasterData", "resources/master_data/scripts", "common_util") +_LOCK = threading.RLock() +_MEM: dict[str, dict[str, Any]] = {} # 층 열쇠 → {항목: 기록} +_IDS_LOADED: list[bool] = [] + + +@lru_cache(maxsize=1) +def _code() -> str: + """셈 코드 내용 지문 — 프로세스에 한 번(코드가 바뀌면 서버가 다시 뜸 · 같은 코드 = 같은 값).""" + digest = hashlib.blake2b(digest_size=16) + for sub in CODE_DIRS: + for path in sorted((config_system.PROJECT_ROOT / sub).glob("*.py")): + digest.update(path.name.encode()) + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def _stamps(folder: Path, skip: tuple[str, ...] = ()) -> list[tuple[str, str | None]]: + """폴더 안 파일 자국 — 첫 단 이름이 `skip` · `_` · `.` 머리인 것은 뺌.""" + if not folder.is_dir(): + return [] + out = [] + for path in sorted(folder.rglob("*")): + rel = path.relative_to(folder).parts + if rel[0] in skip or any(p.startswith(("_", ".")) for p in rel) or not path.is_file(): + continue + out.append(("/".join(rel), file_stamp(path))) + return out + + +def _ground(folder: Path) -> str: + """바탕 지문 — 셈 코드 · M01 폴더 · 이 층 · 지금 층 기본 자리의 곁 파일.""" + from M01_MasterData import M01_MasterData_Store as m01 + + skip = (*layers.KINDS, layers.MANIFEST_NAME) + side = [_stamps(f, skip) for f in dict.fromkeys((folder, store.root()))] + return derived_key(SHAPE, _code(), str(m01.FOLDER), _stamps(Path(m01.FOLDER)), side) + + +# ── 기록 자리 ───────────────────────────────────────────────────────── +def _book(folder: Path) -> tuple[str, dict[str, Any]]: + key = derived_key(SHAPE, str(folder.resolve())) + if key not in _MEM: + got = read_derived(layers.storage_root(), KIND, key) + value = got.get("value") if got else None + _MEM[key] = value if isinstance(value, dict) else {} + return key, _MEM[key] + + +def _save(key: str, value: Any) -> None: + path = derived_dir(layers.storage_root(), KIND) / f"{key}.json" + try: + atomic_write_json(path, {"key": key, "value": value}) + except OSError: # 파생 저장본 — 못 써도 다음에 다시 셈 + pass + + +# ── 목록 · 판 ───────────────────────────────────────────────────────── +def list_rows(folder: str | Path) -> list[dict[str, Any]]: + """`layers.list_templates` 와 같은 줄 — 판은 자국이 같으면 기억한 것(파일 안 엶).""" + folder = Path(folder) + with _LOCK: + key, recs = _book(folder) + dirty: list[str] = [] + + def version(path: Path) -> str | None: + name = f"{path.parent.name}/{path.stem}" + stamp = file_stamp(path) # 읽기 전 자국 — 그새 바뀌면 다음에 다시 셈 + rec = recs.get(name) + if stamp and isinstance(rec, dict) and rec.get("자국") == stamp and "판" in rec: + return rec["판"] + recs[name] = {"자국": stamp, "판": layers.version_of(path)} + dirty.append(name) + return recs[name]["판"] + + rows = layers.list_templates(folder, version) + have = {f"{row['종류']}/{row['이름']}" for row in rows} + gone = [name for name in recs if name not in have] + for name in gone: + del recs[name] + if dirty or gone: + _save(key, recs) + return rows + + +# ── 서고 요약 ───────────────────────────────────────────────────────── +def _good(rec: Any, stamp: str | None, ground: str) -> bool: + if not (stamp and isinstance(rec, dict) and rec.get("자국") == stamp): + return False + deps = rec.get("기댐") + if rec.get("바탕") != ground or not isinstance(rec.get("요약"), dict): + return False + return isinstance(deps, dict) and all(file_stamp(Path(p)) == s for p, s in deps.items()) + + +def _fresh(folder: Path, kind: str, name: str, rec: Any, stamp: str | None, whole: Any) -> dict: + """그 항목만 다시 읽어 셈 — 참조는 읽기 직전 자국을 기댐에 적음.""" + from M02_MasterTemplete.M02_Labor_Expand import Expand + + path = folder / kind / f"{name}.json" + deps: dict[str, str | None] = {} + + def note(p: Path) -> None: + deps.setdefault(str(p), file_stamp(p)) + + token = refs.SEEN.set(note) + try: # 셈마다 새 펼침 — 다른 항목 셈 기억을 나눠 쓰면 기댐이 빠짐 + out = refs.summary(folder, kind, name, Expand(whole), []) + finally: + refs.SEEN.reset(token) + out.pop("쓰는곳수", None) + same = isinstance(rec, dict) and rec.get("자국") == stamp and "판" in rec + return { + "자국": stamp, + "판": rec["판"] if same else layers.version_of(path), + "기댐": deps, + "요약": out, + } + + +def summaries(folder: str | Path, rows: list[dict[str, Any]]) -> None: + """`refs.summaries` 와 같은 칸 · 차례 — 기록이 맞으면 파일 안 엶 · 다르면 그 항목만 셈.""" + folder = Path(folder) + lib = [row for row in rows if row["종류"] in layers.LIBRARY_KINDS] + if not lib: + return + with _LOCK: + key, recs = _book(folder) + ground, whole, dirty = _ground(folder), None, False + for row in lib: + name = f"{row['종류']}/{row['이름']}" + path = folder / row["종류"] / f"{row['이름']}.json" + stamp = file_stamp(path) + if not _good(recs.get(name), stamp, ground): + if whole is None: + from M01_MasterData import M01_MasterData_Store as m01 + + whole = m01.loaded()[1] + rec = _fresh(folder, row["종류"], row["이름"], recs.get(name), stamp, whole) + recs[name] = {**rec, "바탕": ground} + dirty = True + row.update(recs[name]["요약"]) + if dirty: + _save(key, recs) + sk = [row for row in lib if row["종류"] == "sangeun" and "고친줄수" in row] + if sk: + uses, (layer, owner) = _uses(), refs.where(folder) + for row in sk: + rid = refs.ref_id({"층": layer, "주인": owner, "키": row["이름"]}) + row["쓰는곳수"] = uses.get(rid, 0) + + +# ── 쓰는 곳 수 ──────────────────────────────────────────────────────── +def _uses() -> dict[str, int]: + """참조 이름표 → 담은 파일 수(`refs.users` 개수와 같음) — 쓰는 곳 파일 한 번 훑음.""" + _load_ids() + before = {path: hit[0] for path, hit in refs._IDS.items()} + files = [path for _what, _at, path in refs.use_files()] + count: dict[str, int] = {} + for path in files: + got = refs._ids_of(path) + for rid in got[1] if got else (): + count[rid] = count.get(rid, 0) + 1 + if any(before.get(path) != refs._IDS[path][0] for path in files if path in refs._IDS): + _save_ids() + return count + + +def _load_ids() -> None: + """참조 이름표 기억을 파생 저장본에서 채움 — 프로세스에 한 번 · 자국은 `_ids_of` 가 다시 봄.""" + if _IDS_LOADED: + return + _IDS_LOADED.append(True) + got = read_derived(layers.storage_root(), KIND, IDS) + for text, one in ((got or {}).get("value") or {}).items(): + try: + mtime, size, name, ids = one + refs._IDS.setdefault(Path(text), ((int(mtime), int(size)), (str(name), frozenset(ids)))) + except (TypeError, ValueError): + continue + + +def _save_ids() -> None: + """지금 기억을 디스크 것에 덧씀(다른 서버 몫 유지) · 없어진 파일 뺌.""" + got = read_derived(layers.storage_root(), KIND, IDS) + value = dict((got or {}).get("value") or {}) + for path, ((mtime, size), (name, ids)) in list(refs._IDS.items()): + value[str(path)] = [mtime, size, name, sorted(ids)] + value = {text: one for text, one in value.items() if Path(text).is_file()} + _save(IDS, value) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 4c1df9130..120f071cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_Layer_Index as index from M02_MasterTemplete import M02_Library_Flat as flat from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router @@ -275,14 +276,14 @@ async def list_layer( session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id, owner=owner) - rows = await asyncio.to_thread(layers.list_templates, folder) + rows = await asyncio.to_thread(index.list_rows, folder) # 판 = 자국 기억(826-1) manifest = await asyncio.to_thread(layers.read_manifest, folder) for row in rows: row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] # 서고(50-3) — 보이는 이름 · 단위 · 기준 금액(51-4) - await asyncio.to_thread(refs.summaries, folder, rows) + await asyncio.to_thread(index.summaries, folder, rows) # 요약 색인(826-1) return {"층": layer, "양식": rows} diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 48c858d5f..9e077af37 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -132,8 +132,10 @@ def _modified(path: Path) -> str: return utc_from_timestamp(path.stat().st_mtime) -def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]: - """층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례.""" +def list_templates(layer_dir: str | Path, version: Any = None) -> list[dict[str, Any]]: + """층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례 · + `version` = 판 셈(없으면 `version_of` · 층 목록은 자국 기억 `M02_Layer_Index`).""" + version = version or version_of base = Path(layer_dir) rows: list[dict[str, Any]] = [] for kind in KINDS: @@ -147,7 +149,7 @@ def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]: { "종류": kind, "이름": path.stem, - "판": version_of(path), + "판": version(path), "수정일": _modified(path), } ) diff --git a/resources/tester/test_826_1_layer_index.py b/resources/tester/test_826_1_layer_index.py new file mode 100644 index 000000000..f2298a3ab --- /dev/null +++ b/resources/tester/test_826_1_layer_index.py @@ -0,0 +1,208 @@ +"""826-1 층 목록 요약 색인 — 답 = 다시 셈한 것 · 자국 같으면 항목 파일 안 엶 · 고침 · 지움 · 더함 · +참조 고침 · M01 값 바뀜 · 색인 깨짐 뒤에도 맞음 · 판 충돌(409) 그대로.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Layer_Index as index +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_labor_ref import COMPANY, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘 + 덧줄 % +WAGE = "LB000001" +LIB = ("sangeun", "precombo", "labor") + + +@pytest.fixture() +def fresh(world, monkeypatch): # noqa: F811 + """색인 기억 비움 — 시험끼리 안 섞임.""" + monkeypatch.setattr(index, "_MEM", {}) + monkeypatch.setattr(index, "_IDS_LOADED", []) + monkeypatch.setattr(refs, "_IDS", {}) + return world + + +def _forget(): + index._MEM.clear() + index._IDS_LOADED.clear() + refs._IDS.clear() + + +def _sk(folder, name, extra=None): + doc = {"이름": name, "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": extra or []} + with store.at(folder): + return refs.create("sangeun", doc)["이름"] + + +def _wage_line(qty=1): + return {"종류": "인력", "요소": WAGE, "이름": "보통인부", "단위": "인", "수량": qty} + + +def _old(folder): + rows = layers.list_templates(folder) + refs.summaries(folder, rows) + return rows + + +def _new(folder): + rows = index.list_rows(folder) + index.summaries(folder, rows) + return rows + + +def _same(folder): + got = _new(folder) + assert json.dumps(got, ensure_ascii=False) == json.dumps(_old(folder), ensure_ascii=False) + return got + + +@pytest.fixture() +def reads(monkeypatch): + """항목 파일(서고 · 일위대가) 읽기 수.""" + seen: list[str] = [] + real = Path.read_bytes + + def counting(self): + if self.parent.name in LIB: + seen.append(f"{self.parent.name}/{self.stem}") + return real(self) + + monkeypatch.setattr(Path, "read_bytes", counting) + return seen + + +def _shelf(w): + up = _sk(w["company"], "회사 단가산출") + mine = w["personal"] + a = _sk(mine, "가", [_wage_line()]) + b = _sk(mine, "나", [{"참조": {**COMPANY, "키": up}, "수량": 2}]) + with store.at(mine): + sc = refs.create( + "precombo", + { + "이름": "일위대가", + "단위": "㎡", + "담은로직": [{"참조": {**COMPANY, "키": up}, "수량": {"손": 3}}], + }, + )["이름"] + return up, a, b, sc + + +def test_same_and_no_reads_when_stamps_same(fresh, reads): + up, a, b, sc = _shelf(fresh) + mine = fresh["personal"] + first = _same(mine) + assert {r["이름"] for r in first} >= {a, b, sc} and first[0]["기준금액"] is not None + reads.clear() + assert _new(mine) == first and reads == [] # 메모리 기억 + _forget() + reads.clear() + assert _new(mine) == first and reads == [] # 디스크 파생 저장본(서버 다시 뜸) + # 쓰는곳수 — 회사 SK 를 개인 SK 「나」 가 씀(사전조합은 사본으로 펼쳐 담음 · 740-1) + company = _same(fresh["company"]) + assert company[0]["쓰는곳수"] == 1 + + +def _unfreeze(folder, key): + """옛 꼴(사본 뗌) — M01 을 지금 값으로 셈(사본 있으면 만든 때 값 그대로).""" + doc = json.loads(layers.template_path(folder, "sangeun", key).read_bytes()) + old = {k: v for k, v in doc.items() if k not in ("사본", "수식", "M01판", "바탕판")} + layers.write_template(folder, "sangeun", key, old) + + +def test_edit_delete_add_and_dep_change(fresh, reads): + up, a, b, sc = _shelf(fresh) + mine = fresh["personal"] + gone = _sk(mine, "지울것") + _same(mine) + with store.at(mine): # 고침 · 지움 + got = store.read("sangeun", a) + store.write("sangeun", a, got["판"], {**got["문서"], "더한줄": [_wage_line(3)]}) + store.delete("sangeun", gone) + c = _sk(mine, "다") # 더함 + reads.clear() + rows = _new(mine) + assert set(reads) == {f"sangeun/{a}", f"sangeun/{c}"} # 바뀐 항목만 다시 읽음 + assert rows == _old(mine) + titles = {r["이름"]: r.get("제목") for r in rows if r["종류"] in index.layers.LIBRARY_KINDS} + assert gone == c and titles == { + a: "가", + b: "나", + c: "다", + sc: "일위대가", + } # 지운 키를 새 것이 받음 + reads.clear() + was = {r["이름"]: r.get("기준금액") for r in _new(mine)} + assert reads == [] + with store.at(fresh["company"]): # 사전조합이 담은 위 층 SK 고침 → 그 사전조합만 다시 + got = store.read("sangeun", up) + store.write("sangeun", up, got["판"], {**got["문서"], "더한줄": [_wage_line(5)]}) + reads.clear() + now = {r["이름"]: r.get("기준금액") for r in _new(mine)} + assert set(reads) == {f"precombo/{sc}", f"sangeun/{up}"} + assert now[sc] != was[sc] and {k: v for k, v in now.items() if k != sc} == { + k: v for k, v in was.items() if k != sc + } + assert _new(mine) == _old(mine) + + +def test_m01_change_recounts_amount(fresh, tmp_path, monkeypatch): + copy = tmp_path / "m01" + copy.mkdir() + for path in Path(m01.FOLDER).glob("*.json"): + shutil.copyfile(path, copy / path.name) + monkeypatch.setattr(m01, "FOLDER", copy) + mine = fresh["personal"] + a = _sk(mine, "가", [_wage_line()]) + _unfreeze(mine, a) + one = next(r for r in _same(mine) if r["이름"] == a)["기준금액"] + assert one is not None + wages = json.loads((copy / "인력.json").read_text(encoding="utf-8")) + for row in wages["줄"]: + if row.get("키") == WAGE: + row["값"] = float(row["값"]) * 2 + (copy / "인력.json").write_text(json.dumps(wages, ensure_ascii=False), encoding="utf-8") + two = next(r for r in _same(mine) if r["이름"] == a)["기준금액"] + assert two != one + + +def test_broken_index_rebuilds(fresh): + _up, _a, _b, _sc = _shelf(fresh) + mine = fresh["personal"] + first = _same(mine) + folder = index.derived_dir(layers.storage_root(), index.KIND) + for path in folder.glob("*.json"): + path.write_text("{깨짐", encoding="utf-8") + _forget() + assert _same(mine) == first + for path in folder.glob("*.json"): + path.write_text(json.dumps({"key": path.stem, "value": {"sangeun/x": 1}}), encoding="utf-8") + _forget() + assert _same(mine) == first + shutil.rmtree(folder) + _forget() + assert _same(mine) == first + + +def test_version_conflict_still_409(fresh): + mine = fresh["personal"] + a = _sk(mine, "가") + listed = next(r for r in index.list_rows(mine) if r["이름"] == a)["판"] + path = layers.template_path(mine, "sangeun", a) + assert listed == layers.version_of(path) + doc = json.loads(path.read_bytes()) + layers.write_template( + mine, "sangeun", a, {**doc, "단위": "m"}, version=listed, check_version=True + ) + with pytest.raises(layers.StaleTemplate): # 옛 판으로 또 저장 + layers.write_template(mine, "sangeun", a, doc, version=listed, check_version=True) + fresh_ver = next(r for r in index.list_rows(mine) if r["이름"] == a)["판"] + assert fresh_ver == layers.version_of(path) != listed