글감 1,001개 중 제1부(§3~§11) 앵커를 하나라도 가진 것은 112개뿐이었다. 나머지 889개는
제2부 모듈 분석 65편의 절 제목에서 나온 것이고, 그것이 재판정이 필요했던 이유다.
주제 44 → 16 (43개가 독자 질문 없이 있었다. 지금은 전부 있다)
글감 1,001 → 123 (제1부 앵커 112 + 제1부가 채택했는데 비어 있던 자리 11)
후보 965 → 1,088 · PENDING 905 → 0
error 3,042 → 0
내려온 889개는 후보 대장에 KEEP_IN_SSOT 로 남는다 — 버린 것이 아니라 분석에 남기고 독립
기록으로 만들지 않기로 한 것이다. 그 글감을 받치던 기록 파일 828개는 지웠다. 계약이 정본이고,
파일이 남아 있다는 이유로 계약에서 뺀 주제가 되살아나면 안 된다. 이력에는 그대로 있다 —
git checkout a0ca2bb -- <경로>.
제1부가 채택했는데 글감이 없던 자리 열하나를 채웠다: mongo high-water mark 가 재전달 이벤트를
삼킨 P1, admin plane 이 가드만 켜고 서비스는 켜지 않은 것과 그 짝인 결정, 실패 어휘 세 층과
SQLState 매트릭스 병합 규칙, 부하 아래에서만 새는 admission 경계, 발행 증거와 완료 판정의
분리, keyset·JSONB 결정 둘.
Concept 17개에 basis-version 을 채우고, 계약 제목과 기록 제목이 갈라져 있던 23건을 기록 쪽에
맞췄다. candidateScope 에 excludedAnchorPattern 을 적어 제2부 앵커만 가진 글감이 다시 올라올
수 없게 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
20 KiB
Python
441 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""글감 계약과 폴더 배치를 작은 픽스처로 확인한다.
|
|
|
|
python3 -m unittest discover -s scripts/tests
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import copy
|
|
import hashlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
SCRIPTS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, SCRIPTS)
|
|
import techlog # noqa: E402
|
|
|
|
|
|
def _load(name: str, filename: str):
|
|
spec = importlib.util.spec_from_file_location(name, os.path.join(SCRIPTS, filename))
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
verifier = _load("verify_tech_log_tree", "verify-tech-log-tree.py")
|
|
builder = _load("build_tech_log_tree", "build-tech-log-tree.py")
|
|
layout = _load("verify_project_layout", "verify-project-layout.py")
|
|
|
|
CASE = {
|
|
"title": "세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다",
|
|
"kind": "case", "slug": "session-split-across-nodes", "readiness": "READY",
|
|
"source": ["final/document.md#4-2"], "code": ["SessionStore.java:41"],
|
|
"evidence": ["evidence/raw/session-probe.txt"],
|
|
"classification": "두 노드에 요청을 번갈아 보내 재현했고 로그로 확인했다",
|
|
"missing-verification": "없음",
|
|
"relations": ["concept:authorization-code-exchange"],
|
|
}
|
|
CONCEPT = {
|
|
"title": "Authorization Code 교환이 한 번 더 일어나는 자리",
|
|
"kind": "concept", "slug": "authorization-code-exchange", "readiness": "READY",
|
|
"source": ["final/document.md#3-1"], "basis-version": "Keycloak 26.7.0",
|
|
"classification": "이 교환을 알아야 아래 Case 의 관측을 읽을 수 있다",
|
|
"relations": ["case:session-split-across-nodes"],
|
|
}
|
|
INDEX = {
|
|
"schemaVersion": 4, "project": "fixture", "ssot": "final/document.md",
|
|
"sourceRevision": "abc1234", "generatedAt": "2026-09-05",
|
|
"candidateScope": {"document": "final/document.md", "sections": ["§3", "§11"]},
|
|
"sourceRepository": {"path": "https://example.invalid/fixture.git",
|
|
"revision": "0" * 40, "verified": "픽스처"},
|
|
"contract": {"readinessValues": techlog.READINESS},
|
|
"topics": {
|
|
"session-custody": {
|
|
"topic": "session-custody", "title": "세션을 누가 보관하는가",
|
|
"readerQuestion": "자격증명과 세션을 누가 보관하고 보호 자원은 무엇을 신뢰하는가?",
|
|
"kinds": {"case": [CASE], "concept": [CONCEPT],
|
|
"reference": [], "question": [], "decision": []},
|
|
}
|
|
},
|
|
"candidates": [
|
|
{"id": "F001", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
|
|
"target": "case:session-split-across-nodes"},
|
|
{"id": "F002", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
|
|
"target": "concept:authorization-code-exchange"},
|
|
{"id": "F003", "disposition": "KEEP_IN_SSOT", "dispositionReview": "CONFIRMED"},
|
|
],
|
|
}
|
|
RECORD = """\
|
|
---
|
|
kind: CASE
|
|
slug: session-split-across-nodes
|
|
title: 세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다
|
|
topic: session-custody
|
|
project: fixture
|
|
status: 게시 전
|
|
---
|
|
"""
|
|
|
|
|
|
class Fixture:
|
|
def __init__(self, index: dict | None = None, with_record: bool = True) -> None:
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
self.root = self.dir.name
|
|
self.base = os.path.join(self.root, "docs/fixture")
|
|
self.studio = os.path.join(self.base, "tech-log-studio")
|
|
os.makedirs(os.path.join(self.studio, "session-custody/case"))
|
|
os.makedirs(os.path.join(self.base, "final"))
|
|
|
|
ssot = os.path.join(self.base, "final/document.md")
|
|
open(ssot, "w", encoding="utf-8").write("# fixture\n")
|
|
data = copy.deepcopy(index if index is not None else INDEX)
|
|
data["ssotSha256"] = hashlib.sha256(open(ssot, "rb").read()).hexdigest()
|
|
self.index_path = os.path.join(self.studio, "tech-log-tree.json")
|
|
self.write(data)
|
|
if with_record:
|
|
open(os.path.join(self.studio, "session-custody/case/case-session-split.md"),
|
|
"w", encoding="utf-8").write(RECORD)
|
|
|
|
def write(self, data: dict) -> None:
|
|
with open(self.index_path, "w", encoding="utf-8") as fh:
|
|
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
|
|
def read(self) -> dict:
|
|
return json.load(open(self.index_path, encoding="utf-8"))
|
|
|
|
def diagram(self, name: str, *, bundled: bool = True, with_source: bool = True,
|
|
cited: bool = False) -> None:
|
|
assets = os.path.join(self.base, "final/assets")
|
|
target = os.path.join(assets, name) if bundled else assets
|
|
os.makedirs(target, exist_ok=True)
|
|
open(os.path.join(target, f"{name}.svg"), "w", encoding="utf-8").write("<svg/>")
|
|
if with_source:
|
|
src = os.path.join(self.base, "final/.techviz", name)
|
|
os.makedirs(src, exist_ok=True)
|
|
open(os.path.join(src, "spec.json"), "w", encoding="utf-8").write("{}")
|
|
if cited:
|
|
self.cite(name)
|
|
|
|
def cite(self, name: str) -> None:
|
|
"""그림을 Studio 자리로 복사하고 기록이 그것을 가리키게 한다 — 실제 작업 순서다."""
|
|
studio_assets = os.path.join(self.base, "final/assets/tech-log-studio")
|
|
os.makedirs(studio_assets, exist_ok=True)
|
|
open(os.path.join(studio_assets, f"{name}.svg"), "w", encoding="utf-8").write("<svg/>")
|
|
record = os.path.join(self.studio, "session-custody/case/case-session-split.md")
|
|
open(record, "w", encoding="utf-8").write(RECORD.replace(
|
|
"status: 게시 전\n",
|
|
f"status: 게시 전\nassets:\n - key: {name}\n"
|
|
f" file: ../../../final/assets/tech-log-studio/{name}.svg\n"))
|
|
|
|
def __enter__(self):
|
|
self._saved = (verifier.ROOT, builder.ROOT, layout.ROOT)
|
|
verifier.ROOT = builder.ROOT = layout.ROOT = self.root
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
verifier.ROOT, builder.ROOT, layout.ROOT = self._saved
|
|
self.dir.cleanup()
|
|
|
|
|
|
def mutate(**changes):
|
|
"""글감 하나의 칸을 바꾼 색인을 만든다."""
|
|
index = copy.deepcopy(INDEX)
|
|
case = index["topics"]["session-custody"]["kinds"]["case"][0]
|
|
for key, value in changes.items():
|
|
if value is None:
|
|
case.pop(key, None)
|
|
else:
|
|
case[key] = value
|
|
return index
|
|
|
|
|
|
class ContractTest(unittest.TestCase):
|
|
def test_clean_fixture_has_no_errors(self):
|
|
with Fixture():
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
builder.main(["build", "fixture"])
|
|
report = verifier.verify("fixture")
|
|
self.assertEqual(report.errors, {}, report.errors)
|
|
|
|
def test_missing_reader_question_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["topics"]["session-custody"]["readerQuestion"] = ""
|
|
with Fixture(index):
|
|
self.assertIn("Topic 에 독자 질문이 없다", verifier.verify("fixture").errors)
|
|
|
|
def test_concept_without_basis_version_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
del index["topics"]["session-custody"]["kinds"]["concept"][0]["basis-version"]
|
|
with Fixture(index):
|
|
self.assertIn("CONCEPT 노드에 `basis-version` 가 없다",
|
|
verifier.verify("fixture").errors)
|
|
|
|
def test_case_without_classification_is_an_error(self):
|
|
with Fixture(mutate(classification=None)):
|
|
self.assertIn("CASE 노드에 `classification` 가 없다",
|
|
verifier.verify("fixture").errors)
|
|
|
|
def test_source_anchored_outside_the_ssot_is_flagged(self):
|
|
with Fixture(mutate(source=["analysis/05-persistence.md §3.5"])):
|
|
self.assertIn("근거가 SSOT 밖에만 있다", verifier.verify("fixture").warns)
|
|
|
|
def test_readiness_is_not_publication(self):
|
|
with Fixture(mutate(readiness="NEEDS_EVIDENCE")):
|
|
self.assertIn("글을 쓰면 안 되는 readiness 인데 기록이 있다",
|
|
verifier.verify("fixture").errors)
|
|
|
|
def test_unknown_readiness_is_an_error(self):
|
|
with Fixture(mutate(readiness="REJECTED")):
|
|
self.assertIn("readiness 값이 계약에 없다", verifier.verify("fixture").errors)
|
|
|
|
def test_record_outside_the_contract_is_an_error(self):
|
|
with Fixture() as fx:
|
|
os.makedirs(os.path.join(fx.studio, "orphan-topic/concept"))
|
|
open(os.path.join(fx.studio, "orphan-topic/concept/c.md"), "w",
|
|
encoding="utf-8").write("---\nkind: CONCEPT\nslug: nobody-listed-me\n---\n")
|
|
self.assertIn("계약에 없는 기록", verifier.verify("fixture").errors)
|
|
|
|
def test_stale_ssot_hash_is_an_error(self):
|
|
with Fixture() as fx:
|
|
open(os.path.join(fx.base, "final/document.md"), "a",
|
|
encoding="utf-8").write("바뀌었다\n")
|
|
self.assertIn("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
|
|
verifier.verify("fixture").errors)
|
|
|
|
def test_pending_disposition_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["candidates"][0]["dispositionReview"] = "PENDING"
|
|
with Fixture(index):
|
|
report = verifier.verify("fixture")
|
|
self.assertIn("disposition 을 다시 판정하지 않은 후보", report.errors)
|
|
self.assertNotIn("disposition 을 다시 판정하지 않은 후보", report.warns)
|
|
|
|
def test_promote_without_a_node_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["candidates"][0]["target"] = "case:never-written"
|
|
with Fixture(index):
|
|
self.assertIn("PROMOTE 후보가 글감에 없다", verifier.verify("fixture").errors)
|
|
|
|
def test_node_without_a_promote_candidate_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["candidates"][0]["disposition"] = "KEEP_IN_SSOT"
|
|
index["candidates"][0]["target"] = None
|
|
with Fixture(index):
|
|
self.assertIn("글감을 낳은 PROMOTE 후보가 없다",
|
|
verifier.verify("fixture").errors)
|
|
|
|
def test_missing_candidate_scope_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
del index["candidateScope"]
|
|
with Fixture(index):
|
|
self.assertIn("candidateScope 가 없다", verifier.verify("fixture").errors)
|
|
|
|
def test_candidate_scope_pointing_at_another_document_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["candidateScope"]["document"] = "analysis/05-persistence.md"
|
|
with Fixture(index):
|
|
self.assertIn("candidateScope.document 가 ssot 과 다르다",
|
|
verifier.verify("fixture").errors)
|
|
|
|
def test_a_node_sourced_only_outside_the_candidate_scope_is_an_error(self):
|
|
index = mutate(source=["final/document.md#a19-messaging-runtime-core"])
|
|
index["candidateScope"]["excludedAnchorPattern"] = r"#a\d+-"
|
|
with Fixture(index):
|
|
self.assertIn("후보를 찾는 범위 밖에서만 나온 글감",
|
|
verifier.verify("fixture").errors)
|
|
|
|
def test_a_project_without_the_contract_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["schemaVersion"] = 2
|
|
del index["contract"]
|
|
del index["candidateScope"]
|
|
with Fixture(index):
|
|
report = verifier.verify("fixture")
|
|
self.assertIn("글감 계약을 아직 쓰지 않았다", report.errors)
|
|
self.assertNotIn("글감 계약을 아직 쓰지 않았다", report.warns)
|
|
|
|
def test_an_old_index_is_not_flooded_with_per_field_errors(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["schemaVersion"] = 2
|
|
del index["contract"]
|
|
del index["candidateScope"]
|
|
index["topics"]["session-custody"]["readerQuestion"] = ""
|
|
for kind in ("case", "concept"):
|
|
for node in index["topics"]["session-custody"]["kinds"][kind]:
|
|
node.pop("classification", None)
|
|
node.pop("basis-version", None)
|
|
with Fixture(index):
|
|
rules = set(verifier.verify("fixture").errors)
|
|
self.assertEqual(
|
|
{r for r in rules if "노드에" in r or "독자 질문" in r}, set(),
|
|
"계약을 안 쓴 프로젝트에 칸마다 error 를 내면 안 된다")
|
|
|
|
def test_a_tree_without_the_source_repository_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
del index["sourceRepository"]
|
|
with Fixture(index):
|
|
self.assertIn("sourceRepository.path 가 없다", verifier.verify("fixture").errors)
|
|
|
|
def test_a_repository_without_a_pinned_revision_is_a_warning(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["sourceRepository"]["revision"] = None
|
|
with Fixture(index):
|
|
report = verifier.verify("fixture")
|
|
self.assertIn("sourceRepository 에 리비전이 없다", report.warns)
|
|
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
|
|
|
|
def test_branch_tips_count_as_a_pinned_revision(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["sourceRepository"]["revision"] = None
|
|
index["sourceRepository"]["revisions"] = {"pattern1": "0" * 40, "pattern2": "1" * 40}
|
|
with Fixture(index):
|
|
report = verifier.verify("fixture")
|
|
self.assertNotIn("sourceRepository 에 리비전이 없다", report.warns)
|
|
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
|
|
|
|
def test_duplicate_slug_is_an_error(self):
|
|
index = copy.deepcopy(INDEX)
|
|
index["topics"]["session-custody"]["kinds"]["concept"][0]["slug"] = \
|
|
"session-split-across-nodes"
|
|
with Fixture(index):
|
|
self.assertIn("slug 가 두 글감에 있다", verifier.verify("fixture").errors)
|
|
|
|
|
|
class SsotAssetTest(unittest.TestCase):
|
|
"""SSOT 가 이미 그린 그림을 글감에 배정하고, 기록이 그것을 쓰는지 본다."""
|
|
|
|
def _verify(self, fx):
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
builder.main(["build", "fixture"])
|
|
return verifier.verify("fixture")
|
|
|
|
def test_assigned_diagram_the_record_uses_is_clean(self):
|
|
with Fixture(mutate(**{"ssot-assets": ["session-custody-map"]})) as fx:
|
|
fx.diagram("session-custody-map", cited=True)
|
|
self.assertEqual(self._verify(fx).errors, {})
|
|
|
|
def test_assigned_diagram_the_record_never_uses_is_an_error(self):
|
|
with Fixture(mutate(**{"ssot-assets": ["session-custody-map"]})) as fx:
|
|
fx.diagram("session-custody-map")
|
|
self.assertIn("배정한 SSOT 그림을 기록이 쓰지 않는다", self._verify(fx).errors)
|
|
|
|
def test_assigned_diagram_that_does_not_exist_is_an_error(self):
|
|
with Fixture(mutate(**{"ssot-assets": ["never-drawn"]})) as fx:
|
|
self.assertIn("배정한 SSOT 그림이 final/assets 에 없다", self._verify(fx).errors)
|
|
|
|
def test_assigned_evidence_the_record_never_cites_is_an_error(self):
|
|
with Fixture(mutate(**{"ssot-evidence": ["raw/explain/plan-a.txt"]})) as fx:
|
|
raw = os.path.join(fx.base, "final/evidence/raw/explain")
|
|
os.makedirs(raw)
|
|
open(os.path.join(raw, "plan-a.txt"), "w", encoding="utf-8").write("EXPLAIN\n")
|
|
self.assertIn("배정한 SSOT 증거를 기록이 쓰지 않는다", self._verify(fx).errors)
|
|
|
|
|
|
class BuildTest(unittest.TestCase):
|
|
def test_derived_fields_come_from_the_record_file(self):
|
|
with Fixture():
|
|
index, warnings = builder.build("fixture")
|
|
case = index["topics"]["session-custody"]["kinds"]["case"][0]
|
|
self.assertEqual(case["file"], "session-custody/case/case-session-split.md")
|
|
self.assertEqual(case["publication"], "초안")
|
|
self.assertEqual(case["readiness"], "READY", "readiness 는 사람이 적는다")
|
|
concept = index["topics"]["session-custody"]["kinds"]["concept"][0]
|
|
self.assertEqual(concept["publication"], "미작성")
|
|
self.assertNotIn("file", concept)
|
|
self.assertEqual(index["counts"]["written"], 1)
|
|
self.assertEqual(warnings, [])
|
|
|
|
def test_human_written_fields_survive_a_rebuild(self):
|
|
with Fixture() as fx:
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
builder.main(["build", "fixture"])
|
|
builder.main(["build", "fixture"])
|
|
case = fx.read()["topics"]["session-custody"]["kinds"]["case"][0]
|
|
self.assertEqual(case["classification"], CASE["classification"])
|
|
self.assertEqual(case["relations"], CASE["relations"])
|
|
|
|
def test_directory_left_behind_does_not_become_a_topic(self):
|
|
with Fixture() as fx:
|
|
os.makedirs(os.path.join(fx.studio, "deleted-from-the-contract/case"))
|
|
open(os.path.join(fx.studio, "deleted-from-the-contract/case/x.md"), "w",
|
|
encoding="utf-8").write("---\nkind: CASE\nslug: revived-by-its-folder\n---\n")
|
|
index, warnings = builder.build("fixture")
|
|
self.assertEqual(set(index["topics"]), {"session-custody"})
|
|
self.assertEqual(index["unlisted"], ["deleted-from-the-contract/case/x.md"])
|
|
self.assertTrue(warnings)
|
|
|
|
|
|
class LayoutTest(unittest.TestCase):
|
|
def test_a_diagram_with_its_source_is_clean(self):
|
|
with Fixture() as fx:
|
|
fx.diagram("session-custody-map", cited=True)
|
|
report = layout.verify("fixture")
|
|
self.assertEqual(report.errors, {}, report.errors)
|
|
self.assertEqual(report.warns, {}, report.warns)
|
|
|
|
def test_ssot_diagram_no_record_cites_is_counted(self):
|
|
# 배정하지 않은 그림은 글을 쓸 때 새로 그리게 된다. keycloak 이 그렇게 됐다
|
|
with Fixture() as fx:
|
|
fx.diagram("session-custody-map")
|
|
self.assertIn("기록이 쓰지 않는 SSOT 그림", layout.verify("fixture").warns)
|
|
|
|
def test_a_project_with_no_record_yet_is_not_counted(self):
|
|
# 아직 안 쓴 것이지 안 쓰기로 한 것이 아니다
|
|
with Fixture(with_record=False) as fx:
|
|
fx.diagram("session-custody-map")
|
|
self.assertNotIn("기록이 쓰지 않는 SSOT 그림", layout.verify("fixture").warns)
|
|
|
|
def test_svg_without_a_techviz_source_is_counted(self):
|
|
with Fixture() as fx:
|
|
fx.diagram("hand-drawn", with_source=False)
|
|
self.assertIn("techviz 정본이 없는 그림", layout.verify("fixture").warns)
|
|
|
|
def test_studio_presentation_copies_need_no_source(self):
|
|
with Fixture() as fx:
|
|
assets = os.path.join(fx.base, "final/assets/tech-log-studio")
|
|
os.makedirs(assets)
|
|
open(os.path.join(assets, "custody.svg"), "w", encoding="utf-8").write("<svg/>")
|
|
self.assertEqual(layout.verify("fixture").warns, {})
|
|
|
|
def test_evidence_folder_outside_the_convention_is_an_error(self):
|
|
with Fixture() as fx:
|
|
os.makedirs(os.path.join(fx.base, "final/evidence/screenshots"))
|
|
self.assertIn("evidence 하위 폴더 이름이 규약 밖이다",
|
|
layout.verify("fixture").errors)
|
|
|
|
def test_finished_analysis_must_not_leave_working_material(self):
|
|
with Fixture() as fx:
|
|
os.makedirs(os.path.join(fx.base, "analysis"))
|
|
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
|
|
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
|
|
json.dump({"analysisStatus": "COMPLETE"}, fh)
|
|
self.assertIn("분석이 끝났는데 작업 재료가 남아 있다",
|
|
layout.verify("fixture").warns)
|
|
|
|
def test_analysis_in_progress_is_not_debt(self):
|
|
with Fixture() as fx:
|
|
os.makedirs(os.path.join(fx.base, "analysis"))
|
|
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
|
|
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
|
|
json.dump({"analysisStatus": "IN_PROGRESS"}, fh)
|
|
report = layout.verify("fixture")
|
|
self.assertEqual(report.errors, {}, report.errors)
|
|
self.assertEqual(report.warns, {}, report.warns)
|
|
|
|
def test_import_source_left_behind_is_counted(self):
|
|
with Fixture() as fx:
|
|
os.makedirs(os.path.join(fx.base, "source/docs"))
|
|
open(os.path.join(fx.base, "source/docs/lab.md"), "w",
|
|
encoding="utf-8").write("원본\n")
|
|
self.assertIn("반입 원본이 남아 있다", layout.verify("fixture").warns)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|