feat(511-A ③ 토량 · 구조물 셈 열기): 계산 자리 판 quantity · carry · fixed_browser 바꿀 수 있음(기본 both · 판 줄 차례 고정) · quantity = server 면 F7 저장 노선 면적 · C4 소단 사본 = 잠잠 뒤 서버 값만 · E1 유토곡선 = 서버 값으로 다시 그림(그 사이 앞 곡선 · 「서버 셈 중」) · F7 초안 면적 · C4 초안 소단 = 브라우저 값 + 서버 대조(다르면 얹음) · F8 = 브라우저 부름 없음 · 거울 시험(서버 답 = 브라우저 TS 답) — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:45:47 +09:00
co-authored by Claude Opus 5.5
parent 5df40ccc47
commit e71efdfeb5
13 changed files with 467 additions and 68 deletions
+4 -1
View File
@@ -2,6 +2,9 @@
"version": 1,
"sites": {
"ground_sample": "both",
"corridor_mesh": "both"
"corridor_mesh": "both",
"quantity": "both",
"carry": "both",
"fixed_browser": "both"
}
}
+90
View File
@@ -0,0 +1,90 @@
/* PLAN 511-A 브라우저 길 헬퍼 — `design_calc_server.ts` 를 그 자리에서 트랜스파일 · 판(`design_calc_sites`)과
* fetch 를 가짜로 끼워 판 따라 길 고름 · 잠잠 · 끊음 · 대조 · 기록을 잼. 결과 = 표준출력 JSON 한 벌.
* 짝 = `test_511_a_calc_browser.py`. */
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const board = {};
const fetched = [];
let answer = (body) => ({ [body.run[0]]: body.inputs.args?.[body.run[0]]?.[0] ?? null });
globalThis.fetch = async (url, init) => {
const body = JSON.parse(init.body);
fetched.push({ url, run: body.run, at: Date.now() });
if (init.signal?.aborted) throw new DOMException("끊김", "AbortError");
return {
ok: true,
status: 200,
json: async () => ({ results: answer(body), ms: 1, wall_ms: 2 }),
};
};
const plain = (value) => JSON.parse(JSON.stringify(value));
const { loadTs } = makeLoader({
root: ROOT,
stubs: {
design_calc_sites: {
calcOnServerNow: (key) => board[key] === "server",
calcProjectId: () => "p1",
},
design_calc_node_trial: { plain },
},
});
const mod = loadTs(path.join(ROOT, "A00_Common", "design", "design_calc_server.ts"));
const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const echo = (value) => value;
(async () => {
const out = {};
// 1 판 both — 브라우저 값 · 서버 부름 0 · 브라우저 ms 기록
out.both = mod.calcChecked("quantity", "cross.fit", echo, [5]);
await tick(500);
out.bothFetched = fetched.length;
out.local = globalThis.__aisloCalcServer.local["cross.fit"].length;
// 2 판 server — 브라우저 값 바로 · 잠잠 뒤 한 번(빠른 둘째 부름이 첫째를 끊음) · 같음
board.quantity = "server";
let applied = 0;
mod.calcChecked("quantity", "cross.fit", echo, [6], { apply: () => (applied += 1) });
out.server = mod.calcChecked("quantity", "cross.fit", echo, [7], { apply: () => (applied += 1) });
out.beforeQuiet = fetched.length;
await tick(500);
out.afterQuiet = fetched.length;
const calls = globalThis.__aisloCalcServer.calls;
out.same = calls[calls.length - 1].same;
out.applied = applied;
// 3 서버 값이 다름 — apply(서버 값)
answer = () => ({ "cross.fit": 99 });
let got = null;
mod.calcChecked("quantity", "cross.fit", echo, [8], { apply: (v) => (got = v) });
await tick(500);
out.diffApplied = got;
out.diffSame = calls[calls.length - 1].same;
// 4 그리기 자리 — 처음 undefined · 답 오면 redraw · 같은 입력 = fresh
answer = (body) => ({ "masshaul.series": ["s", body.inputs.args["masshaul.series"][0]] });
let redraws = 0;
const first = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1));
await tick(500);
const second = mod.calcFromServer("quantity", "masshaul.series", [1], () => (redraws += 1));
const third = mod.calcFromServer("quantity", "masshaul.series", [2], () => (redraws += 1));
out.draw = { first, second, third, redraws };
await tick(500);
// 5 비동기 자리 — outer 끊음 = undefined · 부름 0
const before = fetched.length;
const outer = new AbortController();
const pending = mod.calcOnServerOnly("quantity", "slot", mod.areasRequest([]), outer.signal);
outer.abort();
out.aborted = (await pending) === undefined && fetched.length === before;
// 6 왕복 흉내 — delayMs 만큼 늦음
globalThis.__aisloCalcServer.delayMs = 80;
answer = () => ({ "structure.layouts": { areas: [1] } });
const t0 = Date.now();
out.onlyValue = await mod.calcOnServerOnly("quantity", "slot2", mod.areasRequest([]));
out.delayed = Date.now() - t0 >= 400 + 80;
out.roundMs = calls[calls.length - 1].roundMs;
out.url = fetched[fetched.length - 1].url;
process.stdout.write(JSON.stringify(out));
})();
+21
View File
@@ -0,0 +1,21 @@
/* PLAN 511-A 거울 헬퍼 — 브라우저 자리가 부르는 TS 함수를 그 자리에서 트랜스파일해 Node 로 바로 부름.
* 입력 = argv[2] JSON `[{ mod: 뿌리 기준 경로, fn: export 이름, args: [...] }]` · 출력 = 표준출력 JSON 목록
* (Map · Set → 목록 = 서버 `plain` 과 같은 꼴). 짝 = `test_511_a_calc_server.py`(서버 창구 답과 견줌). */
const fs = require("fs");
const path = require("path");
const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() };
const { loadTs } = makeLoader({ root: ROOT });
const plain = (value) =>
JSON.parse(
JSON.stringify(value, (_key, item) =>
item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item,
) ?? "null",
);
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = job.map(({ mod, fn, args }) => plain(loadTs(path.join(ROOT, mod))[fn](...args)));
process.stdout.write(JSON.stringify(out));
+14 -7
View File
@@ -58,27 +58,33 @@ def test_묶음_여덟_줄_등록부_전부():
nos = [no for group in calc_sites.GROUPS for no in group["nos"]]
assert len(calc_sites.GROUPS) == 8
assert len(nos) == len(set(nos)) and set(nos) == set(sites)
assert calc_sites.SWITCHABLE == {"ground_sample", "corridor_mesh"}
assert calc_sites.SWITCHABLE == {
"ground_sample",
"corridor_mesh",
"quantity",
"carry",
"fixed_browser",
} # 511-A 셋 더
for group in calc_sites.GROUPS:
got = {sites[no] for no in group["nos"]}
if group["switchable"] or group["fixed"] == "both":
assert got == {"browser_now"}, group["key"]
if group["fixed"] == "browser":
assert got == {"browser_only"}, group["key"]
elif group["fixed"] == "server":
assert got <= {"server_canon", "server_only"}, group["key"]
else:
assert got == {"browser_only"}, group["key"]
assert got == {"browser_now"}, group["key"]
by_key = {g["key"]: g["nos"] for g in calc_sites.GROUPS}
assert by_key["ground_sample"] == ["G1"] and by_key["corridor_mesh"] == ["D3"]
def test_커밋한_판_기본값_없음_깨짐(tmp_path, monkeypatch):
committed = json.loads(calc_sites.PATH.read_text(encoding="utf-8"))
assert committed["sites"] == {"ground_sample": "both", "corridor_mesh": "both"}
assert committed["sites"] == dict.fromkeys(calc_sites.SWITCHABLE, "both")
assert set(calc_sites.read_sites()["sites"].values()) == {"both"}
monkeypatch.setattr(calc_sites, "PATH", tmp_path / "none.json")
assert calc_sites.read_sites() == {
"version": 0,
"sites": {"ground_sample": "both", "corridor_mesh": "both"},
"sites": dict.fromkeys(calc_sites.SWITCHABLE, "both"),
}
broken = tmp_path / "broken.json"
broken.write_text('{"sites": {"ground_sample": "server", "corridor_mesh": "x"', "utf-8")
@@ -157,7 +163,8 @@ def test_잠금_모르는_줄_400_감사_한_줄(client):
done = http.put(url, json={"sites": {"ground_sample": "server"}})
assert done.status_code == 200 and done.json()["changed"] == ["ground_sample"]
saved = json.loads(board.read_text("utf-8"))
assert saved == {"version": 2, "sites": {"ground_sample": "server", "corridor_mesh": "both"}}
sites = {**dict.fromkeys(calc_sites.SWITCHABLE, "both"), "ground_sample": "server"}
assert saved == {"version": 2, "sites": sites}
inserts = [args for sql, args in log if "system_audit_logs" in sql]
assert len(inserts) == 1
assert inserts[0][:5] == (7, "CALC_SITE_CHANGE", "calc_site", None, "ground_sample=server")
@@ -0,0 +1,73 @@
"""PLAN 511-A (요구 320-4) — 브라우저 「서버」 길(`design_calc_server.ts`) · 판 따라 길 · 자리.
못박는 것
- 판 both = 브라우저 값 · 서버 부름 0(넣어도 바뀌는 것 0) · server = 잠잠 400 ms 뒤 한 번
(앞 부름 끊음).
- 대조 = 같으면 그대로 · 다르면 apply(서버 값) · 그리기 자리 = 답 오면 redraw · 같은 입력 = fresh.
- 끊긴 부름 = undefined · 왕복 흉내 delayMs · 창구 주소 = /projects/{id}/calc.
- 자리마다 판 열쇠 · [저장] · [확정] 길은 서버 길을 안 부름.
./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_browser.py -q
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def _text(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 없음")
def test_판_따라_길_고름_잠잠_대조_기록():
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_511_a_browser.cjs")],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert out["both"] == 5 and out["bothFetched"] == 0 and out["local"] == 1
assert out["server"] == 7 and out["beforeQuiet"] == 0 and out["afterQuiet"] == 1
assert out["same"] is True and out["applied"] == 0
assert out["diffApplied"] == 99 and out["diffSame"] is False
draw = out["draw"]
assert draw["first"] == {"fresh": False} and draw["redraws"] == 1
assert draw["second"] == {"value": ["s", 1], "fresh": True}
assert draw["third"] == {"value": ["s", 1], "fresh": False} # 새 입력 = 앞 답 + 다시 물음
assert out["aborted"] is True
assert out["onlyValue"] == [1] and out["delayed"] and out["roundMs"] >= 80
assert out["url"] == "http://fake/projects/p1/calc" # API_BASE_URL(가짜) + 창구
def test_자리마다_판_열쇠_저장_길_안_부름():
refresh = _text("B06_Section/B06_Section_Cross_Refresh.ts")
assert 'calcOnServerNow("quantity")' in refresh and '"F7.saved"' in refresh
shared = _text("A00_Common/design/design_shared.ts")
assert 'calcChecked("quantity", "structure.layouts"' in shared
assert 'calcChecked("quantity", "cross.berm_spans"' in shared
lane = _text("A00_Common/design/design_facility_lane.ts")
assert '"C4.copy", bermRequest(structures)' in lane
mass = _text("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts")
assert 'calcFromServer<MassHaulSeries[]>("quantity", "masshaul.series"' in mass
assert "openCalcSites(projectId)" in _text("A00_Common/design/design_page.ts")
for rel in (
"A00_Common/design/design_save.ts",
"A00_Common/design/design_save_api.ts",
"A00_Common/design/design_reset.ts",
):
assert "design_calc_server" not in _text(rel), rel
page = _text("A00_Common/design/design_pipeline_page.ts")
assert len(page.splitlines()) <= 699 and "design_calc_server" not in page
@@ -0,0 +1,94 @@
"""PLAN 511-A (요구 320-4) — 묶음마다 서버 창구 답 = 브라우저 자리 TS 답(거울).
브라우저 쪽 = `helper_511_a_mirror.cjs` 가 그 자리 함수를 바로 부름 · 서버 쪽 = 늘 떠 있는 Node
(`run_calc_served` · 창구와 같은 본문 꼴 — `design_calc_server.ts` `areasRequest` · `bermRequest` ·
시험 길 args).
고정 자료 = golden_472 · 272 소단 사례 · 손 자료(공유 DB · 실제 마스터 안 씀).
./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_mirror.py -q
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
from common_util import common_util_calc_runner as runner # noqa: E402
FIXTURE = HERE / "fixtures" / "golden_472"
pytestmark = pytest.mark.skipif(
shutil.which("node") is None or not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="node · 프론트엔드 의존성 없음",
)
def _fixture(name: str):
return json.loads((FIXTURE / name).read_text(encoding="utf-8"))
def _browser(tmp_path: Path, calls: list[dict]) -> list:
job = tmp_path / "job.json"
job.write_text(json.dumps(calls, ensure_ascii=False), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_511_a_mirror.cjs"), str(job)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _served(run: list[str], inputs: dict) -> dict:
answer = runner.run_calc_served(run, inputs)
assert answer and "results" in answer, answer
return answer["results"]
def _trial(id_: str, args: list):
return _served([id_], {"args": {id_: args}})[id_]
# ── ③ 토량 · 구조물 셈(E1 · F7 · C4 · F8 = 브라우저 부름 없음) ───────────────────────
def test_quantity_F7_C4_E1_서버_브라우저_같음(tmp_path):
from test_272_berm_source import STRUCTURE_CASES
detail = _fixture("detail.json")
sections = detail["cross_sections"]
conv = _fixture("context.json")["earthwork_conversion"]
long_ = detail["longitudinal"]
calls = [
{"mod": "B06_Section/B06_Section_Structure_Layouts.ts", "fn": "structureAreaRows",
"args": [sections]},
{"mod": "common_util/common_util_mass_haul.ts", "fn": "computeMassHaulSeries",
"args": [long_, sections, conv, None, ["cross"]]},
] + [
{"mod": "common_util/common_util_berm_spans.ts", "fn": "bermSpansFromStructures",
"args": [case]}
for case in STRUCTURE_CASES
] # fmt: skip
browser = _browser(tmp_path, calls)
areas = _served(["structure.layouts"], {"detail": {"cross_sections": sections}})
assert areas["structure.layouts"]["areas"] == browser[0] and browser[0]
series = _trial("masshaul.series", [long_, sections, conv, None, ["cross"]])
assert series == browser[1] and series[0]["result"]["final_cumulative_m3"]
for index, case in enumerate(STRUCTURE_CASES):
got = _served(["cross.berm_spans"], {"berm": {"structures": case, "chainages": []}})
assert got["cross.berm_spans"]["spans"] == browser[2 + index], index