Files
Aislo/resources/tester/test_m02_area_table.py
T
eomsangdonandClaude Sonnet 5.5 5638bd5101 feat(M02): 면적 값 고침이 저장 전 도면 패널 표에 바로 비침 · 이름 · 보내기 취소는 [저장] 때 서버가 기록(112-1·112-2)
- 112-1 면적 표 변환을 공용 파일(spreadsheet_area_table.ts)로 빼 서버 Node 와 브라우저가 한 벌로 씀 · 도면 패널이 칸 고침마다(값 바뀐 표만 · 펼친 동안만) CAD 연결 표 갈기를 보기 전용 갈음(preview)으로 보냄
- 112-2 보낸 범위 이름은 도면 [저장] 요청 안에서 서버가 면적 문서에 둠(놓기만 하고 저장 안 하면 안 생김) · [보내기 취소]는 화면에서만 빼고 면적 [저장] 때 서버가 연결표 줄 · 이름을 뺌 · /name · /unlink 길 삭제

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-08 17:36:44 +09:00

373 lines
16 KiB
Python

"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환(Node 번들 `areaTable`) · 길 · 저장 때 도면 표 맞춤.
시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 마스터 양식 · storage 는 안 씀.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_spreadsheet as sheet_node
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Area_Table as area_table
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
SHEET = {
"id": "s1",
"이름": "면적산출",
"칸": {
"A1": {"값": "제목", "서식": 2},
"B1": {"값": 1234.5, "서식": 1},
"A2": {"값": "합"},
"B2": {"식": "B1*2", "서식": 1},
"A3": {"값": "숨김행"},
"C1": {"값": "x"},
},
"병합": ["A1:A2", "C1:E5"],
"행": {"3": {"숨김": True}},
"열": {"D": {"숨김": True}, "B": {"폭": 12}},
}
BOOK = {
"종류": "통합문서",
"판": 1,
"열": "rv",
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}],
"시트": [SHEET],
"활성": "s1",
}
def _one(**ask) -> dict:
return sheet_node.area_tables([{"book": BOOK, **ask}])[0]
def test_표시_글_형식_굵게_정렬_칸_key():
got = _one(시트="면적산출", 범위="A1:B2")
cells = got["표"]["shapeData"]["cells"]
assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"}
assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식
assert cells[0][0]["align"] == "center" # 가로 서식
assert (got["시트"], got["범위"]) == ("s1", "A1:B2")
assert got["값"]["s1!B2"] == "2,469.00"
assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""}
def test_병합_잘라냄_숨긴_행열_뺌_빈_칸():
shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"]
cells = shape["cells"]
assert len(cells) == 3 and all(len(row) == 3 for row in cells) # 행 3 · 열 D 뺌
assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None
assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김)
assert cells[1][2] is None and cells[2][2] is None
assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸
assert shape["style"]["fontSize"] == 5
assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율
assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3
@pytest.mark.parametrize(
"ask, why",
[
({"시트": "없음", "범위": "A1"}, "없는 시트"),
({"시트": "s1", "범위": "가나"}, "범위 꼴 아님"),
({"시트": "s1", "범위": "A3"}, "보이는 칸이 없음"),
({"시트": "s1", "범위": "A1:Z200", "한도": 100}, "칸이 너무 많음"),
({"시트": "s1", "범위": "A1:J20", "한도": 100}, "칸이 너무 많음 171"),
],
)
def test_못_바꾸는_범위는_까닭(ask, why):
got = _one(**ask)
assert not got["ok"] and why in got["까닭"]
# ── 길 · 저장 때 맞춤 ──────────────────────────────────────────────────
@pytest.fixture()
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
store.create_structure("rv")
got = store.read("area", "rv")
store.write("area", "rv", got["판"], {**got["문서"], "서식": BOOK["서식"], "시트": [SHEET]})
items_store.ensure(layers.personal_dir(7, 99))
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app = FastAPI()
app.include_router(items_router.router)
app.dependency_overrides[verify_session] = lambda: session
return {"client": TestClient(app), "session": session, "system": system}
URL = "/api/m02/layers/{}/area/{}/table"
def test_길_읽기_권한이면_표_저장_안_함_남의_층_403_없는_열_404(world):
client, system = world["client"], world["system"]
before = (system / "area" / "rv.json").read_bytes()
got = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "A1:B2"})
assert got.status_code == 200, got.text
assert got.json()["표"]["shapeData"]["cells"][0][1]["text"] == "1,234.50"
assert (system / "area" / "rv.json").read_bytes() == before # 저장 안 함
bad = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "가"})
assert bad.status_code == 400
other = client.post(
URL.format("personal", "rv") + "?owner=7/99", json={"시트": "s1", "범위": "A1"}
)
assert other.status_code == 403
assert (
client.post(URL.format("system", "없는열"), json={"시트": "s1", "범위": "A1"}).status_code
== 404
)
def _link_drawing(folder: Path) -> None:
"""도면에 연결표 둘(T1 = 표 있음 · T2 = 표 없음) + 낡은 칸 글의 T1 표 하나 · 다른 표 하나."""
with store.at(folder):
got = store.read("structure", "rv")
doc = got["문서"]
stale = {
"type": "Table",
"shapeData": {
"origin": {"x": 10, "y": 20},
"columnWidths": [9, 9],
"rowHeights": [3, 3],
"cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]],
"style": {"fontSize": 2.5, "연결": "T1"},
},
}
other = json.loads(json.dumps(stale))
other["shapeData"]["style"]["연결"] = "남의표"
doc["도면"]["entities"] = [stale, other]
doc["연결표"] = [
{"연결": "T1", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}},
{"연결": "T2", "시트": "s1", "범위": "B1", "값": {}},
]
store.write("structure", "rv", got["판"], doc)
def test_면적_저장_뒤_도면_표_글_바뀜_자리_크기_그대로_표_없는_기록은_둠(world):
system = world["system"]
_link_drawing(system)
with store.at(system):
got = store.read("area", "rv")
got["문서"]["시트"][0]["칸"]["B1"] = {"값": 10, "서식": 1}
store.write("area", "rv", got["판"], got["문서"])
doc = store.read("structure", "rv")["문서"]
t1, other = doc["도면"]["entities"]
shape = t1["shapeData"]
assert shape["cells"][0][1]["text"] == "10.00" and shape["cells"][1][1]["text"] == "20.00"
assert (shape["origin"], shape["columnWidths"], shape["rowHeights"]) == (
{"x": 10, "y": 20},
[9, 9],
[3, 3],
) # 칸 수 같으면 크기 그대로
assert other["shapeData"]["cells"][0][0]["text"] == "낡음" # 다른 연결 표는 안 건드림
t1_rec, t2_rec = doc["연결표"]
assert t1_rec["값"]["s1!B2"] == "20.00" and t2_rec["값"] == {"s1!B1": "10.00"} # 표 없어도 기록
NAMED_SHEET = {
**SHEET,
"이름정의": [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}],
}
NAMED_BOOK = {**BOOK, "시트": [NAMED_SHEET]}
def test_이름으로_범위를_풂_104_1():
got = sheet_node.area_tables(
[{"book": NAMED_BOOK, "시트": "s1", "범위": "", "이름": "도면_T1"}]
)[0]
assert (
got["ok"] and got["범위"] == "B1:C2"
) # 이름정의가 가리키는 지금 범위로(저장된 범위는 안 봄)
def test_이름이_지워졌으면_이름없음_104_1():
got = sheet_node.area_tables(
[{"book": BOOK, "시트": "s1", "범위": "B1:C2", "이름": "도면_없음"}]
)[0]
assert not got["ok"] and got["까닭"] == area_table.NAME_GONE
def test_define_names_있으면_갱신_없으면_더함_104_1():
added = sheet_node.define_names(
[{"book": BOOK, "시트": "s1", "범위": "B1:C2", "이름": "도면_T9"}]
)[0]
assert added["ok"]
names = added["book"]["시트"][0]["이름정의"]
assert {"이름": "도면_T9", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}} in names
again = sheet_node.define_names(
[{"book": added["book"], "시트": "s1", "범위": "A1:A1", "이름": "도면_T9"}]
)[0]
kept = [n for n in again["book"]["시트"][0]["이름정의"] if n["이름"] == "도면_T9"]
assert kept == [
{"이름": "도면_T9", "범위": {"r0": 0, "c0": 0, "r1": 0, "c1": 0}}
] # 갱신(둘 아님)
def _named_link_drawing(folder: Path) -> None:
"""연결표 T1 에 `이름`(104-1) · 이름정의가 가리키는 지금 범위는 B1:C2(저장된 범위는 A1:B2 — 낡음)."""
with store.at(folder):
got = store.read("structure", "rv")
doc = got["문서"]
doc["연결표"] = [{"연결": "T1", "시트": "s1", "범위": "A1:B2", "이름": "도면_T1", "값": {}}]
store.write("structure", "rv", got["판"], doc)
def test_저장_때_이름이_가리키는_범위로_갱신_104_1(world):
system = world["system"]
_named_link_drawing(system)
with store.at(system):
got = store.read("area", "rv")
doc = got["문서"]
doc["시트"][0]["이름정의"] = [
{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}
]
store.write("area", "rv", got["판"], doc)
drawing = store.read("structure", "rv")["문서"]
assert drawing["연결표"][0]["범위"] == "B1:C2" # 옛(A1:B2) 아님 — 이름이 가리키는 지금 범위
def test_이름_통째_지워지면_연결_끊고_지운연결_104_1(world):
system = world["system"]
_named_link_drawing(system) # 이름정의 없는 책 — 이름이 없는 것과 같음
with store.at(system):
got = store.read("area", "rv")
got["문서"]["시트"][0]["이름정의"] = [] # 면적에서 이름을 지움
store.write("area", "rv", got["판"], got["문서"]) # 면적 저장 → refresh_drawing
drawing = store.read("structure", "rv")["문서"]
assert drawing["연결표"] == [] # 연결 끊김(도면 표 엔티티는 안 지움)
assert drawing["지운연결"] == ["T1"]
def _area_names(folder: Path) -> list[dict]:
doc = json.loads((folder / "area" / "rv.json").read_bytes())
return doc["시트"][0].get("이름정의") or []
def _save_links(rows: list[dict], version: str | None = None, entities: list | None = None) -> None:
got = store.read("structure", "rv")
doc = got["문서"]
doc["연결표"] = rows
if entities is not None:
doc["도면"]["entities"] = entities
store.write("structure", "rv", got["판"] if version is None else version, doc)
T1_ROW = {"연결": "T1", "시트": "s1", "범위": "B1:C2", "이름": "도면_T1", "글자크기": 2.5, "값": {}}
T1_NAME = {"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}
def test_도면_저장_때_새_연결_줄_이름이_같은_요청_안에서_면적_문서에_생김_112_2(world):
system = world["system"]
before = (system / "area" / "rv.json").read_bytes()
with store.at(system):
assert (system / "area" / "rv.json").read_bytes() == before # 저장 전 = 서버 면적 그대로
_save_links([T1_ROW])
assert T1_NAME in _area_names(system) # 저장 뒤 이름
# 면적에서 이름을 지운 뒤 같은 줄로 다시 저장 — 새 줄만 이름을 둠(도로 만들지 않음)
path = system / "area" / "rv.json"
book = json.loads(path.read_bytes())
book["시트"][0]["이름정의"] = []
path.write_text(json.dumps(book, ensure_ascii=False), encoding="utf-8")
_save_links([T1_ROW, {**T1_ROW, "연결": "T2", "이름": "도면_T2"}])
names = [n["이름"] for n in _area_names(system)]
assert names == ["도면_T2"]
def test_판_충돌로_도면_저장이_막히면_이름도_안_생김_112_2(world):
system = world["system"]
before = (system / "area" / "rv.json").read_bytes()
with store.at(system), pytest.raises(store.StoreError) as stale:
_save_links([T1_ROW], version="낡은판")
assert stale.value.status == 409
assert (system / "area" / "rv.json").read_bytes() == before
def test_이름_없는_옛_연결_줄은_이름을_안_둠_112_2(world):
system = world["system"]
with store.at(system):
_save_links([{"연결": "T1", "시트": "s1", "범위": "A1:B2", "값": {}}])
assert _area_names(system) == []
def test_면적_저장_보내기취소_줄과_이름을_뺌_도면_표는_남음_112_2(world):
system = world["system"]
t2 = {**T1_ROW, "연결": "T2", "이름": "도면_T2"}
old = {"연결": "T3", "시트": "s1", "범위": "A1:A1", "값": {}} # 이름 없는 옛 연결
with store.at(system):
table = {
"type": "Table",
"shapeData": {"cells": [[{"text": "x"}]], "style": {"연결": "T1"}},
}
_save_links([T1_ROW, t2, old], entities=[table]) # 도면 표(T1) 있음
got = store.read("area", "rv")
# 저장 전 — 서버 도면 연결표는 그대로(취소는 화면에서만)
assert [x["연결"] for x in store.read("structure", "rv")["문서"]["연결표"]] == [
"T1",
"T2",
"T3",
]
doc = {**got["문서"], "보내기취소": ["T1", "T3"]}
store.write("area", "rv", got["판"], doc)
drawing = store.read("structure", "rv")["문서"]
area = store.read("area", "rv")["문서"]
assert [x["연결"] for x in drawing["연결표"]] == [
"T2"
] # T1 · T3 줄 빠짐(남은 줄은 지금 범위로)
assert "지운연결" not in drawing # 취소는 「범위가 지워져」 알림이 아님
assert drawing["도면"]["entities"] # 도면 표는 안 지움
assert [n["이름"] for n in area["시트"][0]["이름정의"]] == ["도면_T2"] # T1 이름 뺌
assert "보내기취소" not in area # 임시 칸은 저장 안 함
def test_프로젝트_층_저장도_보내기취소를_뗌_112_2(world, tmp_path):
import shutil
project = tmp_path / "project"
shutil.copytree(world["system"], project)
with store.at(project):
_save_links([T1_ROW])
book = json.loads((project / "area" / "rv.json").read_bytes())
book["시트"][0]["이름정의"] = [T1_NAME]
book["보내기취소"] = ["T1"]
cut, ids = layers_router._apply_cancels(project, "rv", book)
assert ids == ["T1"] and "보내기취소" not in cut and cut["시트"][0]["이름정의"] == []
layers_router._refresh_drawing(project, "rv", ids)
assert json.loads((project / "structure" / "rv.json").read_bytes())["연결표"] == []
def test_프로젝트_층_길도_같은_맞춤(world, tmp_path):
project = tmp_path / "project"
import shutil
shutil.copytree(world["system"], project) # 프로젝트 작업본 꼴(같은 종류 폴더)
_link_drawing(project)
area = json.loads((project / "area" / "rv.json").read_bytes())
area["시트"][0]["칸"]["B1"] = {"값": 7, "서식": 1}
(project / "area" / "rv.json").write_text(
json.dumps(area, ensure_ascii=False), encoding="utf-8"
)
layers_router._refresh_drawing(project, "rv")
doc = json.loads((project / "structure" / "rv.json").read_bytes())
assert doc["도면"]["entities"][0]["shapeData"]["cells"][0][1]["text"] == "7.00"
system_doc = json.loads((world["system"] / "structure" / "rv.json").read_bytes())
assert "연결표" not in system_doc # 시스템 층은 그대로
assert area_table.refresh_drawing("없는열") == []