Merge branch 'sub_laptop_2' of https://gitea.chemifactory.com/eomsangdon/Aislo into main_laptop_1

This commit is contained in:
2026-10-20 20:40:49 +09:00
12 changed files with 514 additions and 40 deletions
@@ -0,0 +1,294 @@
/* 298-2 B08 · B09 좌측 아래 단추 줄 — 진짜 `B08_Quantity_UI_Options.ts` · `B08_Quantity_UI_Page.ts` ·
* `B09_Estimation_UI_Shell.ts` 를 트랜스파일해 가짜 DOM · 가짜 의존 위에서 돌리고 값을 JSON 으로 냄.
* [초기화] = 확인 창 → POST reset · [>] = 저장 → POST complete → 다음 길 · 실패면 머묾. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
// 무엇이든 받아 주는 빈 값 — 부르면 또 빈 값 · 펼치면 비어 있음 · 글자로는 ""
function any() {
const store = {};
return new Proxy(function () {}, {
get(_t, k) {
if (k in store) return store[k];
if (k === "then") return undefined;
if (k === Symbol.iterator) return function* () {};
if (k === Symbol.toPrimitive) return () => "";
return (store[k] = any());
},
set(_t, k, v) {
store[k] = v;
return true;
},
apply: () => any(),
construct: () => any(),
});
}
class Node {
constructor(tag = "div") {
this.tagName = tag.toUpperCase();
this.children = [];
this.parent = null;
this.dataset = {};
this.style = {};
this.listeners = {};
this.classList = { add() {}, toggle() {}, remove() {}, contains: () => false };
}
get isConnected() {
let n = this;
while (n.parent) n = n.parent;
return n.isRoot === true;
}
append(...kids) {
kids.forEach((k) => {
if (k instanceof Node) k.parent = this;
this.children.push(k);
});
}
replaceChildren(...kids) {
this.children.forEach((k) => k instanceof Node && (k.parent = null));
this.children = [];
this.append(...kids);
}
setAttribute() {}
querySelector() {
return null;
}
querySelectorAll() {
return [];
}
addEventListener(name, fn) {
(this.listeners[name] ??= []).push(fn);
}
}
global.document = { createElement: (tag) => new Node(tag) };
function load(rel, stubs) {
const src = fs
.readFileSync(path.join(ROOT, rel), "utf8")
.replace(/import\.meta\.glob\(/g, "(() => ({}))(");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const mod = { exports: {} };
const req = (id) => (id in stubs ? stubs[id] : id.endsWith(".css") ? {} : any());
new Function("exports", "module", "require", js)(mod.exports, mod, req);
return mod.exports;
}
const flush = async () => {
for (let k = 0; k < 8; k++) await new Promise((r) => setTimeout(r, 0));
};
const calls = [];
const buttons = []; // 만든 단추 — { variant, node }
let layoutOpts = null;
let confirmAnswer = false;
const store = new Map(); // 가짜 세션 캐시
const common = {
"@ui/ui_template_workflow_layout": {
createWorkflowLayout: (opts) => {
layoutOpts = opts;
const root = new Node();
root.append(opts.leftPanel, opts.mainContent);
return { root };
},
},
"@ui/ui_template_elements": {
createButton: (o) => {
const node = Object.assign(new Node("button"), { onClick: o.onClick, variant: o.variant });
buttons.push(node);
return node;
},
createInputField: () => ({ input: new Node("input"), root: new Node() }),
showToast: (_text, kind) => calls.push(`toast:${kind}`),
showLoadingOverlay: () => calls.push("lock"),
hideLoadingOverlay: () => calls.push("unlock"),
showConfirmDialog: async () => (calls.push("confirm"), confirmAnswer),
},
"@ui/ui_template_collapsible": { attachCollapsible() {}, createPanelCard: () => new Node() },
"@ui/ui_template_locale": { ui_locales: any(), currentLanguageIndex: 0 },
"@config/config_frontend": { CURRENT_PROJECT_ID_KEY: "pid", API_BASE_URL: "/api" },
"../A00_Common/b_page_scaffold": { workflowSteps: () => [] },
"../A00_Common/b_workflow_nav": {
WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"],
goToWorkflowStage: (_p, route) => calls.push(`go:${route}`),
},
"../A00_Common/b_page_state": {
readState: (name) => store.get(name) ?? null,
writeState: (name, v) => store.set(name, v),
clearState: (name) => store.delete(name),
},
};
global.localStorage = { getItem: () => "P1" };
const take = () => calls.splice(0).filter((c) => !c.startsWith("toast:success"));
// B08 좌측 옵션 [저장] [초기화]
async function b08Options() {
let fail = false;
const m = load("B08_Quantity/B08_Quantity_UI_Options.ts", {
...common,
"./B08_Quantity_UI_Hydro": {
request: async (url, init) => {
const method = init?.method ?? "GET";
if (method !== "GET") calls.push(`req:${method}:${url}`);
if (fail && method === "POST") throw new Error("500");
return { one_pipe_length_m: 6, rock_ratios_pct: { 연암: 90 }, material_surcharge_pct: {} };
},
},
"./B08_Quantity_UI_Milestone_State": {
saveMilestone: async () => calls.push("req:milestone"),
loadMilestone: async (_p, force) => calls.push(`milestone:${force}`),
},
});
buttons.length = 0;
const panel = m.createOptionsPanel("P1", () => calls.push("redraw"));
await flush();
const actions = panel.panel.children.find((c) => c.className === "ui-sidebar-actions");
const out = { order: actions.children.map((b) => b.variant) };
const reset = actions.children[1];
const drafts = ["b08-options", "b08-materials", "b08-hydro", "b08-milestone"];
const fill = () => drafts.forEach((d) => store.set(d, { x: 1 }));
const press = async () => {
take();
reset.onClick();
await flush();
return take();
};
fill();
confirmAnswer = false;
out.cancel = [await press(), drafts.filter((d) => store.has(d))];
confirmAnswer = true;
out.ok = [await press(), drafts.filter((d) => store.has(d))];
fill();
fail = true;
out.fail = [await press(), drafts.filter((d) => store.has(d))];
store.clear();
return out;
}
// B08 진행 단계 · [>] — B09 쪽 = 저장 → POST complete → 이동
async function b08Page() {
let completeFail = false;
const m = load("B08_Quantity/B08_Quantity_UI_Page.ts", {
...common,
"./B08_Quantity_UI_Options": {
createOptionsPanel: () => ({ panel: new Node(), save: async () => true }),
},
"./B08_Quantity_UI_Hydro": {
renderHydro() {},
request: async (url, init) => {
calls.push(`req:${init?.method}:${url}`);
if (completeFail) throw new Error("500");
},
},
});
m.renderB08Quantity(Object.assign(new Node(), { isRoot: true }));
const step = async (i) => {
take();
layoutOpts.onStepClick(i);
await flush();
return take();
};
const out = { next: await step(6), back: await step(4) };
completeFail = true;
out.fail = await step(6);
return out;
}
// B09 [초기화] · [>](공용 틀 대신 셸) — draft = DRAFT.next 있음 / 없음
async function b09(withNext) {
let failOn = null; // "PUT" | "complete"
global.fetch = async (url, init) => {
const method = init?.method ?? "GET";
if (method !== "GET") calls.push(`fetch:${method}:${url}`);
const bad = failOn === method || (failOn === "complete" && url.endsWith("/complete"));
if (bad) return { ok: false, status: 500, json: async () => ({}) };
return { ok: true, status: 200, json: async () => any() };
};
const tabs = any();
tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] };
if (withNext) tabs.DRAFT.next = () => calls.push("next");
const nextBtn = Object.assign(new Node("button"), { disabled: true });
const m = load("B09_Estimation/B09_Estimation_UI_Shell.ts", {
...common,
"./B09_Estimation_UI_Tabs": tabs,
"./B09_Estimation_UI_Dock": {
linkMode: () => "popup",
createDock: (list) => {
const dock = new Node();
dock.append(...list);
return dock;
},
},
"@ui/ui_template_workflow_layout": {
createWorkflowLayout: (opts) => {
layoutOpts = opts;
// 공용 [?] [<] [>] 자리 흉내 — B09 [>] = 흐림
opts.leftPanel.querySelector = (sel) => (sel === '[data-step-nav="1"]' ? nextBtn : null);
const root = new Node();
root.append(opts.leftPanel, opts.mainContent);
return { root };
},
},
});
buttons.length = 0;
m.renderB09Estimation(Object.assign(new Node(), { isRoot: true }));
await flush(); // GET 응답 반영
const left = layoutOpts.leftPanel;
const dock = left.children[left.children.length - 1];
const out = { dock: dock.children.map((b) => b.variant), nextEnabled: !nextBtn.disabled };
const nav = async (detail) => {
take();
let stopped = false;
const event = { detail, stopPropagation: () => (stopped = true) };
(left.listeners["ui-step-nav"] ?? []).forEach((fn) => fn(event));
await flush();
return [stopped, take()];
};
if (!withNext) {
out.nav = await nav(1);
return out;
}
out.clean = await nav(1);
store.set("b09-estimation", { x: 1 });
out.changed = await nav(1);
out.cleared = !store.has("b09-estimation");
store.set("b09-estimation", { x: 1 });
failOn = "PUT";
out.saveFail = await nav(1);
failOn = "complete";
store.delete("b09-estimation");
out.completeFail = await nav(1);
failOn = null;
out.prev = await nav(-1);
// [초기화]
const reset = dock.children[0];
const press = async () => {
take();
reset.onClick();
await flush();
return take();
};
store.set("b09-estimation", { x: 1 });
confirmAnswer = false;
out.resetCancel = [await press(), store.has("b09-estimation")];
confirmAnswer = true;
out.resetOk = [await press(), store.has("b09-estimation")];
store.clear();
return out;
}
(async () => {
const r = {
options: await b08Options(),
page: await b08Page(),
b09: await b09(true),
b09NoDraft: await b09(false),
};
console.log(JSON.stringify(r));
})().catch((e) => {
console.error(e && e.stack ? e.stack : e);
process.exit(1);
});
+4 -1
View File
@@ -127,7 +127,10 @@ async function b08() {
"./B08_Quantity_UI_Material": {
renderMaterialSheet: (_h, _p, _s, onSave) => (registerMaterial = onSave),
},
"./B08_Quantity_UI_Hydro": { renderHydro: (_h, _p, onSave) => (registerHydro = onSave) },
"./B08_Quantity_UI_Hydro": {
renderHydro: (_h, _p, onSave) => (registerHydro = onSave),
request: async (url) => calls.push(`req:${url.split("/").pop()}`), // 298-2 B09 쪽 = 단계 5 완료
},
});
const root = Object.assign(new Node(), { isRoot: true });
m.renderB08Quantity(root);
+9 -9
View File
@@ -1,5 +1,5 @@
"""184-3 · 246-1 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 삭제 → gone · 옮김 · 새 공종(←) ·
자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선.
자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [초기화] 배선(298-2).
- 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄
- 저장 = `user.bill` v2 {v: 2, rows: 표 차례 {key · parent · 고친 칸}, gone}
@@ -278,12 +278,12 @@ def test_표_칸_강조_번호_연결_칸():
assert out["renamed"] == "절토공" # 번호는 떼고 이름만
def test_내역_초기화_배선():
def test_초기화_배선():
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
block = src[src.index('label: "내역 초기화"') :]
block = block[: block.index("b09BillReset")]
# 확인 창 → bill 뺀 사용자 값으로 바로 PUT(덮개 빔) · 좌측 단추 줄
assert "showConfirmDialog(" in block and "if (!ok) return;" in block
assert "const { bill: _bill, ...rest } = user;" in block
assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block
assert "createDock([save, reset]," in src # 303-1 하단 고정 줄 맨 아래 줄
block = src[src.index('label: L("Common_Btn_Reset")') :]
block = block[: block.index("b09Reset")]
# 298-2 [내역 초기화] → [초기화] 하나 — 확인 창 → POST …/estimation/reset(내역 + 그 페이지 값 · 설정)
assert 'variant: "danger-ghost"' in block and "showConfirmDialog(" in block
assert 'if (ok) void run("/estimation/reset", "POST"' in block
assert "내역 초기화" not in src and "b09BillReset" not in src
assert "createDock([reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 · [저장] 없음(요구 301)
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""298-2 B08 · B09 좌측 아래 단추 줄 — [초기화] · [>] (PLAN 497-2 · 요구 300 C1 · C3 · C4 · C5 · 301).
`helper_298_2_b08_b09_buttons.cjs` 가 진짜 `B08_Quantity_UI_Options.ts` · `B08_Quantity_UI_Page.ts` ·
`B09_Estimation_UI_Shell.ts` 를 가짜 DOM · 가짜 의존 위에서 돌림.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
DRAFTS = ["b08-options", "b08-materials", "b08-hydro", "b08-milestone"]
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_298_2_b08_b09_buttons.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_B08_단추_줄_저장_다음_초기화(r):
assert r["options"]["order"] == ["filled", "danger-ghost"] # [?][<][>] 은 공용이 앞에
def test_B08_초기화_취소면_그대로(r):
assert r["options"]["cancel"] == [["confirm"], DRAFTS]
def test_B08_초기화_확인_잠금_요청_초안_비움_다시_그림(r):
calls, left = r["options"]["ok"]
assert calls == [
"confirm",
"lock",
"req:POST:/projects/P1/quantity/reset",
"milestone:true", # 이정 저장본 다시 읽음
"redraw",
"unlock",
]
assert left == []
def test_B08_초기화_실패면_초안_남음(r):
calls, left = r["options"]["fail"]
assert "toast:error" in calls and "redraw" not in calls and left == DRAFTS
def test_B08_다음_단계_완료_뒤_이동_실패면_머묾(r):
p = r["page"]
assert p["next"] == ["req:POST:/projects/P1/quantity/complete", "go:b09"]
assert p["back"] == ["go:b07"] # 앞 단계 = 완료 없음
assert p["fail"] == ["req:POST:/projects/P1/quantity/complete", "toast:error"]
def test_B09_단추_줄_초기화_하나_저장_없음(r):
assert r["b09"]["dock"] == ["danger-ghost"]
assert r["b09"]["nextEnabled"] is True
def test_B09_다음_저장_완료_인쇄(r):
b = r["b09"]
assert b["clean"] == [True, ["fetch:POST:/api/projects/P1/estimation/complete", "next"]]
assert b["changed"] == [
True,
[
"lock",
"fetch:PUT:/api/projects/P1/estimation",
"unlock",
"fetch:POST:/api/projects/P1/estimation/complete",
"next",
],
]
assert b["cleared"] is True
assert "next" not in b["saveFail"][1] and "toast:error" in b["saveFail"][1]
assert b["completeFail"][1] == [
"fetch:POST:/api/projects/P1/estimation/complete",
"toast:error",
]
assert b["prev"] == [False, []] # [<] = 공용 틀 몫
def test_B09_초기화(r):
b = r["b09"]
assert b["resetCancel"] == [["confirm"], True]
assert b["resetOk"] == [
["confirm", "lock", "fetch:POST:/api/projects/P1/estimation/reset", "unlock"],
False,
]
def test_B09_초안_없으면_다음_흐림(r):
assert r["b09NoDraft"] == {"dock": ["danger-ghost"], "nextEnabled": False, "nav": [False, []]}
def test_초안_다음_길_등록():
src = (ROOT / "B09_Estimation/draft/B09_Draft_Print_UI.ts").read_text("utf-8")
assert "DRAFT.next = () => navigateTo(ROUTE.path as RoutePath);" in src
@@ -98,6 +98,6 @@ def test_잇기():
groups = (ROOT / GROUPS).read_text("utf-8")
assert "toggle" not in groups and "redraw" not in groups and "createButton" not in groups
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "createDock([save, reset], () => isGroupTab(current) && show(current))" in shell
assert "createDock([reset], () => isGroupTab(current) && show(current))" in shell
state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8")
assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state
+2 -2
View File
@@ -32,7 +32,7 @@ def r():
def test_B08_저장_성공_뒤_이동_왼쪽_다음_지금_탭(r):
b = r["b08"]
assert b["summaryOk"] == ["save:options", "go:b09"]
assert b["summaryOk"] == ["save:options", "req:complete", "go:b09"] # 298-2 단계 5 완료
assert b["materialOk"] == ["save:options", "save:material", "go:b07"]
@@ -45,7 +45,7 @@ def test_B08_저장_실패면_머묾(r):
def test_B08_떠난_탭은_저장_안_부름(r):
assert r["b08"]["leftTab"] == ["save:options", "go:b09"]
assert r["b08"]["leftTab"] == ["save:options", "req:complete", "go:b09"]
def test_B08_좌측_옵션_save(r):