fix(M01): 시험 계산 수 고르기 글로 보냄 · 저장이 파일 글 모양을 지킴

- 화면 [계산] — 고르기 값은 원래 값(수면 수) 그대로 보냄 · 고르기 칸 고칠 때 모두 수면 수로 저장
- 엔진 입력 — 글로 온 수를 고르기 원래 값·Decimal 로 받음
- 저장 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소 한 줄 · 들여쓰기 · 줄바꿈) · 고친 요소만 새로 씀
- 시험 — 13-4-1 글 입력 계산 · 저장 차이가 고친 줄만 · 3-3-1 시험에 지역·보정작업 입력

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-19 19:41:59 +09:00
co-authored by Claude Opus 5
parent 280d51879f
commit 43d72588f6
6 changed files with 126 additions and 16 deletions
+62 -9
View File
@@ -308,7 +308,8 @@ def save(batch: list[dict]) -> list[dict]:
raise StoreError(422, {"errors": new})
out = []
for name in dict.fromkeys(names):
text = dump(after[name])
old_text = (FOLDER / name).read_bytes().decode("utf-8")
text = keep_shape(old_text, before[name], after[name]) or dump(after[name])
tmp = FOLDER / f".{name}.tmp"
tmp.write_text(text, encoding="utf-8", newline="\n")
os.replace(tmp, FOLDER / name)
@@ -323,20 +324,72 @@ def _atom(v) -> str:
return json.dumps(v, ensure_ascii=False)
def _flat(v) -> str:
def _flat(v, tight: bool = False) -> str:
if isinstance(v, dict):
return (
"{ " + ", ".join(f"{_atom(k)}: {_flat(x)}" for k, x in v.items()) + " }" if v else "{}"
)
if not v:
return "{}"
body = ", ".join(f"{_atom(k)}: {_flat(x, tight)}" for k, x in v.items())
return "{" + body + "}" if tight else "{ " + body + " }"
if isinstance(v, list):
return "[" + ", ".join(_flat(x) for x in v) + "]"
return "[" + ", ".join(_flat(x, tight) for x in v) + "]"
return _atom(v)
def dump(v, indent: int = 0, width: int = 160) -> str:
"""UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로."""
def _skip(text: str, pos: int) -> int:
while text[pos] in " \t\r\n":
pos += 1
return pos
def keep_shape(old_text: str, before: dict, after: dict) -> str | None:
"""고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈).
머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump)."""
key = items_key(after)
head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731
old_items, new_items = before.get(key) or [], after.get(key) or []
if head(before) != head(after) or not old_items or not new_items:
return None
for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀
spans, pos, dec = [], m.end(), json.JSONDecoder()
try:
while old_text[pos := _skip(old_text, pos)] != "]":
_, end = dec.raw_decode(old_text, pos)
spans.append((pos, end))
pos = _skip(old_text, end)
pos += old_text[pos] == ","
except (IndexError, ValueError):
return None
if len(spans) == len(old_items):
break
else:
return None
nl = "\r\n" if "\r\n" in old_text else "\n"
lead = old_text[m.end() : spans[0][0]]
indent = len(lead) - len(lead.rstrip(" "))
olds = {str(r.get("열쇠")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)}
shape, parts = old_text[spans[0][0] : spans[0][1]], []
for row in new_items:
was, text = olds.get(str(row.get("열쇠")), (None, None))
shape = text or shape
if was == row:
parts.append(text)
elif "\n" not in shape:
parts.append(_flat(row, tight=shape.startswith('{"')))
else:
parts.append(dump(row, indent, expand=True).replace("\n", nl))
out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :]
same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after
return out if same else None
def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str:
"""UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침)."""
flat = _flat(v)
if indent and len(flat) + indent <= width or not isinstance(v, (dict, list)) or not v:
if (
not isinstance(v, (dict, list))
or not v
or (not expand and indent and len(flat) + indent <= width)
):
return flat + ("" if indent else "\n")
pad = " " * (indent + 2)
if isinstance(v, dict):