Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
43 changed files with 4769 additions and 161 deletions
@@ -46,11 +46,14 @@ if (process.argv[2] === "road") {
same: spec === road.PAVEMENT_SPEC,
containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]),
choices: road.PAVE_SPEC_CHOICES.map((c) => c.value),
items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]),
state,
edited: s.writeOptions(spec, edited),
cleared: s.writeOptions(spec, cleared),
keys: s.specKeys(spec),
retired: spec.retired,
dropped: spec.dropped,
byRange: spec.byRange,
}),
);
process.exit(0);
+9
View File
@@ -65,6 +65,8 @@ function pipeFit(edit) {
const { ford, culvert } = computeStoredLayouts(section, list);
const [right, left] = ford.pipe.outer.slice(2);
const ends = { 1: left.offset, [-1]: right.offset };
const [topLeft, topRight] = ford.pipe.outer;
const tops = { 1: topLeft, [-1]: topRight };
const slab = ford.slabBridge.map((p) => p.offset);
const slabEnd = { 1: Math.max(...slab), [-1]: Math.min(...slab) };
return {
@@ -80,6 +82,13 @@ function pipeFit(edit) {
role: side.role,
guard: true,
pipeGap: r3(ends[side.outward] - front),
// 관 윗모서리 ↔ 그 높이의 흙막이 전면(사면 이용 · 89-1)
topGap: r3(
tops[side.outward].offset -
(wall.points[2].offset +
wall.outward * wall.lean * (wall.topJoint.elevation - tops[side.outward].elevation)),
),
lean: wall.lean,
slabGap: r3(slabEnd[side.outward] - wall.bottomBack.offset),
};
}),
@@ -36,7 +36,15 @@ def test_명세_등록_필수_컨테이너_하나(out):
def test_포장사양_선택지는_명세_한_곳(out):
assert out["choices"] == ["콘크리트"]
assert out["choices"] == ["콘크리트", "아스콘", "쇄석"]
def test_상세내용_차례_포장사양_길이_출력_높이(out):
assert out["items"] == [
["spec", "포장사양", "select"],
["length", "길이 (m)", "auto"],
["height", "높이 (m)", "number"],
]
def test_저장_키_왕복_옛_칸은_명세_밖(out):
@@ -44,7 +52,10 @@ def test_저장_키_왕복_옛_칸은_명세_밖(out):
assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35}
assert out["cleared"] == {"pave_spec": "콘크리트"}
assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []}
assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로
# 길이 · 기준측점 전/후는 입력 없음 — 시작 · 종료만 받고 옛 값은 저장 때 뺌(87-1)
assert out["byRange"] is True
assert out["dropped"] == ["length_m", "before_m", "after_m"]
assert "length_m" not in out["retired"]
assert set(out["retired"]) == {
"thickness_cm",
"width_m",
@@ -62,4 +73,7 @@ def test_기타_상세_종류_이름_글_칸():
name = next(o for o in etc["options"] if o["key"] == "name")
assert name["label"] == "상세 종류 이름" and name["input"] == "text"
pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete")
assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"]
keys = [o["key"] for o in pave["options"]]
assert not {"length_m", "before_m", "after_m"} & set(keys) # 87-1 시작 · 종료 직접 입력
height = next(o for o in pave["options"] if o["key"] == "height_m")
assert height["default"] == 0.2
+11
View File
@@ -47,6 +47,17 @@ def test_관은_흙막이_전면까지_바닥판은_배면까지(fit: dict) -> N
assert side["slabGap"] == pytest.approx(0, abs=1e-3)
def test_관_윗모서리도_흙막이_전면_사면에(fit: dict) -> None:
"""89-1 — 흙막이 사면 이용: 윗모서리가 그 높이 전면 자리라 벽 밖으로 안 튀어나감."""
leaned = 0
for case in (fit[k] for k in ("both", "inletPlus1", "outletPlus2", "inletOnly")):
for side in case["sides"]:
if side["guard"]:
assert side["topGap"] == pytest.approx(0, abs=1e-3)
leaned += side["lean"] > 0
assert leaned
def test_흙막이를_옮기면_관이_그만큼_따라감(fit: dict) -> None:
base = fit["both"]["lengthM"]
assert fit["inletPlus1"]["lengthM"] == pytest.approx(base + 1, abs=1e-3)
@@ -0,0 +1,131 @@
"""노면공 포장 제원(88) — 파이썬·TS 짝 거울 시험.
`common_util/common_util_structure_pavement.py`(서버 · 저장분) 와 `.ts`(브라우저 · 초안)가
같은 입력에 같은 `section.pavement` 를 내는지 대조. TS 는 프로젝트 tsc 로 옮겨 실제 코드 그대로 돌림.
"""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
from common_util.common_util_structure_pavement import ( # noqa: E402
attach_pavement_specs,
pavement_specs_from,
)
STRUCTURES = [
{
"type_id": "pavement_concrete",
"placement": "interval",
"chainage_m": None,
"start_m": 100.0,
"end_m": 160.0,
"options": {"pave_spec": "콘크리트", "height_m": 0.25, "widening_off": "120-140, x-1,,"},
},
{
# 뒤집힌 구간 · 두께 빔 · 사양 빔 — 두 벌 다 작은 값을 시작으로 · null 로.
"type_id": "gravel_surfacing",
"placement": "interval",
"start_m": 150.0,
"end_m": 130.0,
"options": {"pave_spec": "", "height_m": "0.3"},
},
{"type_id": "pavement_concrete", "start_m": None, "end_m": None, "options": {}},
{"type_id": "masonry_wet", "start_m": 0.0, "end_m": 500.0, "options": {}},
{
"type_id": "pavement_concrete",
"chainage_m": 300.0,
"options": {"pave_spec": "아스콘", "height_m": 0.05, "widening_off": "290-310"},
},
]
TYPE_IDS = ["pavement_concrete", "gravel_surfacing"]
CHAINAGES = [99.97, 100.0, 119.99, 130.0, 145.0, 160.02, 160.05, 300.0, 400.0]
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { attachPavementSpecs, pavementSpecsFrom } from "./common_util_structure_pavement.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const sections = input.chainages.map((chainage_m) => ({ chainage_m }));
attachPavementSpecs(sections, pavementSpecsFrom(input.structures, new Set(input.type_ids)));
writeFileSync(process.argv[3], JSON.stringify(sections));
"""
def _python_result() -> list[dict]:
sections = [{"chainage_m": value} for value in CHAINAGES]
attach_pavement_specs(sections, pavement_specs_from(STRUCTURES, set(TYPE_IDS)))
return sections
def _ts_result(tmp_path: Path) -> list[dict]:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_structure_pavement.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(
json.dumps({"structures": STRUCTURES, "type_ids": TYPE_IDS, "chainages": CHAINAGES}),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_pavement_specs_mirror():
with tempfile.TemporaryDirectory() as workdir:
ts = _ts_result(Path(workdir))
py = _python_result()
assert ts == py
by = {section["chainage_m"]: section.get("pavement") for section in py}
# 범위 밖 · 경계 오차(0.02 m) 안
assert by[99.97] is None and by[160.05] is None and by[400.0] is None
assert [entry["type_id"] for entry in by[100.0]] == ["pavement_concrete"]
assert by[160.02][0]["thickness_m"] == 0.25
# 확폭 끈 구간 120~140 (±0.02) — 끔 · 못 읽는 조각은 버림
assert by[119.99][0]["widening_on"] is False
assert by[100.0][0]["widening_on"] is True
# 겹친 범위 = 둘 다 · 뒤집힌 구간 정렬 · 문자 두께 · 빈 사양 = null
assert [entry["type_id"] for entry in by[145.0]] == ["pavement_concrete", "gravel_surfacing"]
gravel = by[145.0][1]
assert (gravel["start_m"], gravel["end_m"]) == (130.0, 150.0)
assert gravel["thickness_m"] is None and gravel["spec"] is None
# 기준 측점만 있는 옛 항목 = 그 측점 한 점
assert by[300.0] == [
{
"type_id": "pavement_concrete",
"spec": "아스콘",
"thickness_m": 0.05,
"start_m": 300.0,
"end_m": 300.0,
"widening_on": False,
}
]
+5 -2
View File
@@ -66,11 +66,14 @@ def test_알약은_전체_편집기와_같은_부품():
def test_저장_안_한_카드_고침_물음():
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
show = struct.split("const show = ")[1].split("const pickPrice")[0]
assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show
assert "cards.dirty()" in show and "leaveOrStay(TEXT.discardCards," in show
assert "opts.keepColumn?.(from)" in show
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
assert main.count("busy()") >= 2 # 떠나기 · 새로고침 막기
assert (
'askUnsaved(L("M02_TreeUnsaved"))' in main
) # 나무 고침(settle)도 같은 물음 · 카드 저장 실패까지 봄
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
+1 -1
View File
@@ -46,7 +46,7 @@ def test_메뉴는_숨기고_저장은_도면_칸만():
def test_저장_안_한_카드_고침은_물음():
cards = _text("M02_MasterTemplete_ItemCards.ts")
# 물음 한 번(31-7) — 어느 카드든(도면 · 엑셀) 고침이 있으면 · 틀의 저장 표시(saver)로 봄
assert "if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(" in cards
assert "(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === \"stay\"" in cards
assert "const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());" in cards
assert "dirty: anyDirty," in cards
assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts")
+29
View File
@@ -70,6 +70,9 @@ const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] };
const size = { 이름: "규격", 고르기: [25, 40] };
const area = { 이름: "지역" };
const inputs = [height, kind, size, area];
const machine = {
이름: "기계", 고르기: ["0201-0060", "9999"], 고르기이름: { "0201-0060": "굴착기(무한궤도) 0.6㎥" },
};
console.log(JSON.stringify({
stored: [
m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"),
@@ -82,6 +85,11 @@ console.log(JSON.stringify({
known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }),
line: m.valueLine(inputs, { 높이: 2, 규격: 40 }),
lineEmpty: m.valueLine(inputs, {}),
named: [
m.choicesOf(machine).map((c) => [c.값, c.이름]), m.valueHint(machine),
m.valueLine([machine], { 기계: "0201-0060" }).text, m.labelOf(machine, "9999"),
m.storedOf(machine, "0201-0060"),
],
}));
"""
@@ -100,6 +108,27 @@ def test_값_셈() -> None:
assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]}
assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음
assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님
# 85-1 — 보이는 글 = 이름 규격 · 모르는 번호는 그대로 · 저장 값은 번호
assert got["named"] == [
[["0201-0060", "굴착기(무한궤도) 0.6㎥"], ["9999", "9999"]],
"굴착기(무한궤도) 0.6㎥ / 9999",
"기계 굴착기(무한궤도) 0.6㎥",
"9999",
"0201-0060",
]
def test_고르기_원문번호에_이름표(client: TestClient) -> None:
"""85-1 — 기계 · 집게 = 「이름 규격」 · 직종(입력 이름에 「직종」) = 직종 이름 · 고르기는 번호 그대로."""
got = client.get("/api/m02/logic-lib/inputs", params={"keys": "GC000260"}).json()
specs = {i["이름"]: i for i in got["로직"][0]["입력"]}
assert specs["기계"]["고르기이름"]["0201-0060"] == "굴착기(무한궤도) 0.6㎥"
assert specs["집게"]["고르기이름"]["7206-0020"] == "부착용 집게 0.2㎥"
assert "0201-0060" in specs["기계"]["고르기"]
assert "고르기이름" not in specs["뒷길이"] and "고르기이름" not in specs["지역"]
whole = labor_inputs.m01.loaded()[1]
job = {"이름": "조종원직종", "고르기": ["1048", "없는번호"]}
assert labor_inputs._labels(whole, job) == {"1048": "건설기계운전사"}
ROWS_SCRIPT = r"""
+125
View File
@@ -0,0 +1,125 @@
"""저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정.
못박는 것
- 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소.
- 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾).
- 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름.
- M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀.
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
SCRIPT = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const src = fs.readFileSync(process.argv[1] + "/ui_template/ui_template_leave_dialog.ts", "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
let focused = null;
let overlay = null;
let buttons = [];
const node = (tag, o = {}) => ({
tag, ...o, kids: [], on: {},
append(...x) { this.kids.push(...x); },
addEventListener(k, f) { this.on[k] = f; },
remove() { overlay = null; },
});
const stubs = {
"./ui_template_elements": {
el: node,
createButton: (o) => {
const b = node("button", { label: o.label, variant: o.variant });
b.focus = () => { focused = b.label; };
b.click = o.onClick;
buttons.push(b);
return b;
},
},
"./ui_template_locale": { t: (k) => k },
};
globalThis.document = { activeElement: null, body: { append(x) { overlay = x; } } };
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
const { leaveOrStay } = box.exports;
const run = async (pick, hooks) => {
buttons = [];
focused = null;
const calls = [];
const wrap = {
save: async () => { calls.push("save"); if (hooks.throws) throw new Error("x"); },
stillDirty: () => !!hooks.left,
discard: () => calls.push("discard"),
};
const p = leaveOrStay("msg", wrap);
const labels = buttons.map((b) => b.label);
const first = focused;
if (pick === "esc") overlay.on.keydown({ key: "Escape", stopPropagation() {} });
else buttons[pick].click();
return { got: await p, calls, labels, first };
};
(async () => {
const out = {
cancel: await run(0, {}),
esc: await run("esc", {}),
discard: await run(1, {}),
save: await run(2, {}),
saveThrows: await run(2, { throws: true }),
saveLeft: await run(2, { left: true }),
};
process.stdout.write(JSON.stringify(out));
})();
"""
@pytest.fixture(scope="module")
def got() -> dict:
done = subprocess.run(
["node", "-e", SCRIPT, str(ROOT)],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_단추_순서_기본_초점_취소(got):
row = got["cancel"]
assert row["labels"] == ["M02_TreeCancel", "M02_TreeDiscardGo", "M02_TreeSaveGo"]
assert row["first"] == "M02_TreeCancel"
def test_취소_Esc는_머묾(got):
assert got["cancel"]["got"] == "stay" and got["cancel"]["calls"] == []
assert got["esc"]["got"] == "stay" and got["esc"]["calls"] == []
def test_버리고_넘어감(got):
assert got["discard"]["got"] == "discarded"
assert got["discard"]["calls"] == ["discard"]
def test_저장_후_넘어감과_실패_머묾(got):
assert got["save"] == {**got["save"], "got": "saved", "calls": ["save"]}
assert got["saveThrows"]["got"] == "stay" # 저장 던짐
assert got["saveLeft"]["got"] == "stay" # 저장 뒤에도 고침이 남음(카드 저장 실패 등)
def test_M02_자리가_3단추_물음을_씀():
main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8")
assert "leaveOrStay(message, { save: doSave, stillDirty: busy" in main
assert "askSettle" not in main
assert "!(await showConfirmDialog(L(" not in main
cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8")
assert "leaveOrStay(TEXT.discard" in cards
struct = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8")
assert struct.count("leaveOrStay(TEXT.discardCards") == 2
+8
View File
@@ -216,3 +216,11 @@ def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path)
assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"}
kept = fill.strip_design(doc)
assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음
def test_집계_길이는_구간형이면_시작_종료가_먼저(project: Path) -> None:
# 노면공 87-2 — 범위를 고쳐도 옛 length_m 이 안 앞섬
moved = {"start_m": 200, "end_m": 245, "options": {"length_m": 30}}
assert fill._length(moved) == 45
assert fill._length({"options": {"length_m": 12}}) == 12 # 점형 · 구간 없음 = 입력 길이
assert fill._length({"options": {}}) is None