Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-10-05 11:29:02 +09:00
26 changed files with 513 additions and 70 deletions
+3 -6
View File
@@ -70,31 +70,28 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자_카드_다섯_고른_회사_해시():
def test_시스템_관리자_카드_넷_고른_회사_해시():
m = "B01_Dashboard_PermMasterWrite"
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master&company=3"],
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
]
# 회사가 없으면 해시에 안 실음
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == (
f"{M02}?open=일위대가&root=master"
f"{M02}?open=구조물조합&root=master"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_회사_관리자_개인_카드_둘():
def test_회사_관리자_개인_카드_하나():
company = [
["B01_Dashboard_CardUnitCombo", WRITE_CO, f"{M02}?open=일위대가&root=company"],
["B01_Dashboard_CardStructCombo", WRITE_CO, f"{M02}?open=구조물조합&root=company"],
]
assert _cards({"role": "ADMIN", "is_master": False}, 3) == company # 회사 인자 안 실음
assert _cards({"role": "USER", "is_master": True}) == company # 회사 마스터 = 회사 수정
assert _cards({"role": "USER", "is_master": False}) == [
["B01_Dashboard_CardUnitCombo", READ_CO, f"{M02}?open=일위대가&root=personal"],
["B01_Dashboard_CardStructCombo", READ_CO, f"{M02}?open=구조물조합&root=personal"],
]
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""B05 · B06 좌측 패널 아래 고정(계획서 44-3) — 단추 묶음이 패널 바닥에 붙고 위 칸만 스크롤.
공용 쪼개기(`splitSidebarActions`)가 [스크롤 칸][아래 고정 단추] 로 나누는데, 한 페이지(design_page)는
좌측 자리를 빈 채 틀에 넘겨 쪼개기가 지나가고, 보기 전환이 패널 클래스를 통째 덮어 `ui-sidebar-fill` 을
지웠다. 세 자리가 다시 어긋나면 빨강. 화면 수치는 `ui_checks/44-3_좌측패널_하단고정.js`.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OVERLAY = ROOT / "ui_template" / "ui_template_overlay.ts"
PANEL = ROOT / "A00_Common" / "design" / "design_panel.ts"
SHARED = ROOT / "A00_Common" / "design" / "design_shared.ts"
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_split_is_exported_and_idempotent():
src = read(OVERLAY)
assert "export function splitSidebarActions(" in src
assert 'if (body.classList.contains("ui-sidebar-split")) return;' in src
def test_shared_panel_splits_after_attach():
src = read(SHARED)
attach = src.index("shared.left.append(panel.root);")
assert "splitSidebarActions(body)" in src[attach : attach + 400]
def test_panel_keeps_fill_class_and_scroll_host():
src = read(PANEL)
# 보기 전환이 클래스를 통째 덮으면 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다.
assert "root.className = next" not in src
# 늦게 서는 칸은 스크롤 칸 안으로 — 패널 루트에 바로 끼우면 스크롤 밖에 남는다.
assert ":scope > .ui-sidebar-scroll" in src
+10
View File
@@ -80,3 +80,13 @@ def test_일위대가_카드는_상세_카드_갈래():
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
assert "const tools = !!(move || remove);" in rows and "compactTable" not in rows
assert "compact" not in _text("M02_MasterTemplete_Labor_View.ts").split("*/", 1)[1]
def test_일위대가_상세_단위는_표준_단위_고르기():
"""44-7 — 상세 머리 「단위」 = 표준 단위 목록(UnitPick) · 지금 값이 목록에 없어도 선택지 ·
고르면 캐시만(저장은 상세 [저장]) · 「-」 = 없음."""
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert 'import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";' in detail
assert "extra: [unit0, cur.단위].filter((u): u is string => !!u)," in detail
assert "cur.단위 = u === NO_UNIT ? null : u;" in detail
assert "readOnly: !edit," in detail and "maxLength = 20" not in detail
+3 -1
View File
@@ -39,7 +39,7 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
for cid in ("rv", "c1"):
store.create_structure(cid)
got = store.read("labor", "rv")
logic = [{"로직": TEN, "입력값": {}, "수량": {"손": 2}}]
logic = [{"로직": TEN, "별칭": " 돌붙임 ", "입력값": {}, "수량": {"손": 2}}]
store.write("labor", "rv", got["판"], {**got["문서"], "단위": "개소", "담은로직": logic})
personal = layers.personal_dir(7, 42)
items_store.ensure(personal)
@@ -71,6 +71,7 @@ def test_같은_층_일위대가_하나_복사_번호_열은_받는_쪽(world):
c1 = _labor(system, "c1")
assert (c1["열"], c1["일위대가번"]) == ("c1", before["일위대가번"])
assert (c1["단위"], c1["담은로직"][0]["로직"]) == ("개소", TEN)
assert c1["담은로직"][0]["별칭"] == "돌붙임" # 별칭도 같이(44-6 · 앞뒤 빈칸 뗌)
assert c1["계산값"]["반영합"]["계"] > 0 # 서버가 다시 셈
@@ -80,6 +81,7 @@ def test_다른_층에서_내_개인으로_저장_뒤에도_덮어씀(world):
for _ in range(2): # 저장된 데이터도 다시 바꿈
assert client.post(url).status_code == 200
assert _labor(personal, "a")["담은로직"][0]["로직"] == TEN
assert _labor(personal, "a")["담은로직"][0]["별칭"] == "돌붙임"
area = URL.format("personal", "area", "a") + "?from=rv&from_layer=system"
assert client.post(area).status_code == 200
+10 -1
View File
@@ -34,7 +34,7 @@ def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
)
)
assert got["담은로직"] == [
{"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
{"로직": STONE, "별칭": None, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
]
assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2}
assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None)
@@ -47,6 +47,8 @@ def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
_doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직
_doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님
_doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림
_doc(담은로직=[{"로직": STONE, "별칭": 3}]), # 별칭 글 아님(44-6)
_doc(담은로직=[{"로직": STONE, "별칭": "가" * 41}]), # 별칭 40자 넘음
_doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님
_doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번
"문서 아님",
@@ -58,6 +60,13 @@ def test_틀린_문서는_400(bad):
assert e.value.status == 400
def test_별칭은_앞뒤_빈칸_떼고_빈_글은_null():
got = labor.clean(
_doc(담은로직=[{"로직": STONE, "별칭": " 돌붙임 "}, {"로직": TEN, "별칭": " "}])
)
assert [r["별칭"] for r in got["담은로직"]] == ["돌붙임", None]
def test_빈_일위대가도_됨():
got = labor.server(_doc())
assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0
+2 -2
View File
@@ -41,10 +41,10 @@ def test_하위_차례_전체_항목_이름():
assert '| "labor";' in api and "일위대가번: string;" in api
assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음
assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side
assert 'return void selectAll("labor");' in side # 해시 open=일위대가
assert 'selectAll("labor")' not in side # 해시 open=일위대가 길 없앰(44-7)
locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8")
assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale
assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale
assert "B01_Dashboard_CardUnitCombo" not in locale # 대시보드 일위대가 카드 없앰(44-7)
css = _text("M02_MasterTemplete_UI_Style.css")
assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css
+5 -5
View File
@@ -144,7 +144,7 @@ console.log(JSON.stringify([
def test_수량_글은_이은_칸_주소를_보임() -> None:
"""`Labor_View.setQty(수량, 칸)` 의 칸 주소로 「수량 B6 = 20 ㎡」 — 없으면 「면적산출 칸」."""
"""`Labor_View.setQty(수량, 칸)` 의 칸 주소로 「수량 20 ㎡(B6)」 — 없으면 「면적산출 칸」."""
node = shutil.which("node")
if not node:
pytest.skip("node 없음")
@@ -154,11 +154,11 @@ def test_수량_글은_이은_칸_주소를_보임() -> None:
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
# 수량산출 열(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 짧은 글
assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "B6 = 12.5", "B6 = 빈 칸", "—"]
assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "12.5(B6)", "빈 칸(B6)", "—"]
assert got[7:] == [
"수량 B6 = 20 ㎡",
"수량 면적산출 칸 = 20 ㎡",
"수량 20 ㎡(B6)", # 44-6 — 수량(칸 주소)
"수량 20 ㎡(면적산출 칸)",
"수량 손 입력 3 ㎡",
"수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
"수량 빈 칸(B6)", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
"",
]
+1 -1
View File
@@ -87,7 +87,7 @@ def test_주소_인자를_라우트에서_뗌():
# 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로
assert 'query.get("root") ?? query.get("layer")' in layer
assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer
assert '["구조물조합", "일위대가", "옵션", "도면"]' in layer
assert '["구조물조합", "옵션", "도면"]' in layer # 일위대가 = 구조물 조합 안(44-7)
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
@@ -0,0 +1,72 @@
/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기
* 시작 화면: B05 종단설계(#/b05-profile) · 환경 검증 프로젝트(OWNERS.md) · 로그인됨 · 계획노선 편집 닫힘
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: [계획노선 편집] 열기 → 첫 측점 라벨 누름(앱 모듈의 라벨 상자 자리) → 횡단 판 글자 잼 → [취소]로 닫음
* 기대 수치:
* - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 5.5px(옛 11px 의 절반)
* - station.fontSize = title.fontSize(측점 표기 = 제목 「횡단」 크기)
* - 바꾸는 값 없음(취소로 닫음 · 노선 안 고침)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
const v = fn();
if (v) return v;
await w(200);
}
return null;
};
const btn = (text) =>
[...document.querySelectorAll("button")].find(
(b) => b.offsetParent && b.textContent.trim() === text,
);
btn("계획노선 편집")?.click();
const canvas = await until(() =>
[...document.querySelectorAll("canvas")].find(
(c) => c.offsetParent && c.closest("[class*='routeedit']"),
),
);
if (!canvas) return JSON.stringify({ ok: false, why: "편집 캔버스 없음" });
// 앱이 실제로 부른 그 모듈 주소(@fs/<폴더>/…) — 같은 주소라야 라벨 상자 WeakMap 을 같이 씀
const url = performance
.getEntriesByType("resource")
.map((e) => e.name)
.find((n) => n.includes("B05_Profile_UI_RouteEdit_Input.ts"));
const input = await import(url);
const boxes = await until(() => {
const b = input.stationLabelBoxes(canvas);
return b.length ? b : null;
});
if (!boxes) return JSON.stringify({ ok: false, why: "측점 라벨 상자 없음" });
const rect = canvas.getBoundingClientRect();
const box = boxes[Math.floor(boxes.length / 2)];
const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true };
canvas.dispatchEvent(new PointerEvent("pointerdown", at));
canvas.dispatchEvent(new PointerEvent("pointerup", at));
const pane = await until(() =>
[...document.querySelectorAll(".b05-routeedit__cross")].find((p) =>
p.querySelector(".b05-routeedit__cross-key"),
),
);
if (!pane) return JSON.stringify({ ok: false, why: "횡단 판 안 뜸", chainage: box.chainage });
const fs = (el) => el && getComputedStyle(el).fontSize;
const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({
text: t.textContent.slice(0, 20),
fontSize: fs(t),
h: +t.getBoundingClientRect().height.toFixed(1),
}));
const title = pane.querySelector(".b05-routeedit__cross-title");
const station = pane.querySelector(".b05-routeedit__cross-station");
const out = {
keys,
title: { text: title.textContent, fontSize: fs(title) },
station: { text: station.textContent, fontSize: fs(station) },
};
out.ok =
keys.length === 3 &&
keys.every((k) => k.fontSize === "5.5px") &&
out.title.fontSize === out.station.fontSize;
btn("취소")?.click();
return JSON.stringify(out);
})();
@@ -0,0 +1,69 @@
/* 44-3 — B05 · B06 좌측 패널 아래 고정(구조물 목록 · 초기화 · 저장 · 보기 전환 단추 묶음)
* 시작 화면: B05 종단설계(#/b05-profile) · 좌측 패널 열림 · 프로젝트 = 환경 배정 storage 프로젝트
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 기대 수치(B05 → B06 → B05 · 각 보기에서 보통 · 긴 패널 둘):
* - split = true · 패널 루트 자식 = [ui-sidebar-scroll, 단추 묶음] · 칸은 모두 스크롤 칸 안(loose = 0)
* - dockGap = 패널 몸통 바닥 − 단추 묶음 바닥 = 0(±1px)
* - 긴 패널(스크롤 칸에 2000px 임시 칸): overflow = true · dockGap 그대로 0 · 단추 묶음 top 안 바뀜
* - 끝나면 임시 칸 지우고 처음 주소로 되돌림
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const start = location.hash;
const measure = async (tag) => {
const panel = document.querySelector(".b05-route__panel, .b06-profile__form");
if (!panel) return { tag, error: "패널 없음" };
const body = panel.closest(".ui-workflow-overlay__body");
const scroll = panel.querySelector(":scope > .ui-sidebar-scroll");
const dock = panel.querySelector(":scope > .ui-sidebar-actions");
const kids = [...panel.children].map((c) => c.classList[0]);
const gap = () =>
Math.round(body.getBoundingClientRect().bottom - dock.getBoundingClientRect().bottom);
const res = {
tag,
view: panel.classList.contains("b06-profile__form") ? "B06" : "B05",
split: body.classList.contains("ui-sidebar-split"),
kids,
loose: kids.filter((k) => k !== "ui-sidebar-scroll" && !dock?.classList.contains(k)).length,
dockGap: dock ? gap() : null,
};
if (!scroll || !dock) return res;
const top0 = Math.round(dock.getBoundingClientRect().top);
const tall = document.createElement("div");
tall.style.cssText = "height: 2000px; flex: none";
scroll.append(tall);
await w(100);
res.long = {
overflow: scroll.scrollHeight > scroll.clientHeight,
dockGap: gap(),
dockMoved: Math.round(dock.getBoundingClientRect().top) - top0,
};
tall.remove();
res.ok =
res.split &&
res.loose === 0 &&
Math.abs(res.dockGap) <= 1 &&
res.long.overflow &&
Math.abs(res.long.dockGap) <= 1 &&
res.long.dockMoved === 0;
return res;
};
const go = async (hash) => {
location.hash = hash;
for (let i = 0; i < 40; i++) {
await w(250);
const want = hash.includes("b06") ? ".b06-profile__form" : ".b05-route__panel";
if (document.querySelector(want + " > .ui-sidebar-actions")) break;
}
await w(500);
};
const out = [];
await go("#/b05-profile");
out.push(await measure("B05"));
await go("#/b06-section");
out.push(await measure("B06"));
await go("#/b05-profile");
out.push(await measure("B05 다시"));
if (location.hash !== start) await go(start);
return JSON.stringify({ ok: out.every((r) => r.ok), out });
})();
@@ -0,0 +1,78 @@
/* 44-7 — ① M02 일위대가 상세 머리 「단위」 = 표준 단위 드롭다운 ② 대시보드 「시스템 설정」 일위대가 카드 없음
* 시작 화면: 아무 화면 · 시스템 관리자 로그인 · M02 마스터 층에 구조물 열 하나 이상
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안)
* 기대 수치:
* - cards = ["마스터 데이터", "구조물 조합", "구조물 옵션", "도면 양식"] · hasLabor = false
* - pick = 1(단추) · textInput = 0(손글 칸 없음) · options > 10 · hasNow = true(지금 값이 선택지에 있음)
* - 다른 단위 고름 → after = 고른 값 · dirty = true(다른 열로 가려면 버림 물음 · 「취소」)
* - 처음 값 다시 고름 → back = 처음 값 · dirtyBack = false(물음 없이 옮겨 감 · 저장 안 함 · 바꾼 값 되돌림)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 6000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await w(150);
}
return null;
};
const out = {};
// ② 대시보드 시스템 설정 카드
location.hash = "#/b01-account";
await until(() => document.querySelector(".b01-dashboard__setting-title"));
out.cards = [...document.querySelectorAll(".b01-dashboard__setting-title")].map(
(e) => e.textContent,
);
out.hasLabor = out.cards.includes("일위대가");
// ① M02 일위대가 상세(좌측 일위대가 하위 첫 줄)
location.hash = "#/m02-master-template?open=구조물조합&root=master";
// 하위 줄은 다시 그려짐 — 누를 때마다 새로 찾음
const row = () =>
document
.querySelector('[data-all="labor"]')
?.parentElement?.querySelector(".m02-side__item:not([data-all])");
if (!(await until(row))) return JSON.stringify({ ...out, error: "일위대가 하위 없음" });
await w(500);
row().click();
const field = await until(() => document.querySelector('[data-labor-field="단위"]'));
if (!field) return JSON.stringify({ ...out, error: "단위 칸 없음" });
// 고침 표시 = 다른 열(하위 둘째 줄)로 갈 때 버림 물음이 뜨는지 — 뜨면 「취소」
const asks = async () => {
document
.querySelector('[data-all="labor"]')
.parentElement.querySelectorAll(".m02-side__item:not([data-all])")[1]
.click();
const ask = await until(() => document.querySelector(".ui-confirm"), 1500);
[...(ask?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "취소")?.click();
await w(200);
return !!ask;
};
const btn = () => document.querySelector('[data-labor-field="단위"] .m02-df-unitpick > button');
const items = async () => {
btn().click();
return until(() => {
const li = document.querySelectorAll('[data-labor-field="단위"] .m02-df-unitpick__item');
return li.length ? [...li] : null;
});
};
out.pick = document.querySelectorAll('[data-labor-field="단위"] .m02-df-unitpick').length;
out.textInput = field.querySelectorAll("input[type=text]:not(.m02-df-unitpick__filter)").length;
const u0 = btn().textContent;
out.before = u0;
const list = await items();
out.options = list.length;
out.hasNow = list.some((b) => b.classList.contains("is-now"));
const other = list.find((b) => !b.classList.contains("is-now") && !b.textContent.startsWith("-"));
other.click();
await w(200);
out.after = btn().textContent;
out.dirty = await asks();
// 되돌림 — 처음 값(없음이면 「-」)
const again = await items();
again.find((b) => (u0 === "-" ? b.textContent.startsWith("-") : b.textContent === u0)).click();
await w(200);
out.back = btn().textContent;
out.dirtyBack = await asks();
return JSON.stringify(out);
})();