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

This commit is contained in:
2026-10-12 21:16:39 +09:00
17 changed files with 970 additions and 93 deletions
@@ -0,0 +1,216 @@
/**
* 글자 더블클릭 = 그 자리 입력 칸(PLAN 293-1) — prompt 대신 글자 위 입력 칸 · Enter 적용 · Esc 취소 ·
* 칸 밖 누름 적용 · 되돌리기 한 번 · 여러 줄(MTEXT)은 Ctrl+Enter 적용 · 키가 CAD 로 새지 않음.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-inplace-edit
*/
import { Point } from "@flatten-js/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../B07_DesignDetail/openwebcad/src/theme.ts", () => ({
paintColor: (color: string) => color,
}));
vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getScreenCanvasDrawController: () => ({
getScreenScale: () => 2,
worldToTarget: (point: Point) => point,
// 시험 쪽 Point 로 — 앱과 시험이 flatten 을 따로 실어 instanceof 가 갈림
targetToWorld: (point: Point) => new Point(point.x, point.y),
measureTextScreen: () => 40,
zoomToFitScreen: () => {},
}),
}));
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
import {
closeTextEditor,
editorKeyAction,
handleTextDoubleClick,
} from "../../../B07_DesignDetail/openwebcad/src/integration/text-inplace-edit";
import {
getEntities,
resetUndoBaseline,
setEntities,
undo,
} from "../../../B07_DesignDetail/openwebcad/src/state";
type Listener = (evt: Record<string, unknown>) => void;
class FakeElement {
public value = "";
public className = "";
public style: Record<string, string> = {};
public selected = false;
public removed = false;
public listeners: Record<string, Listener[]> = {};
constructor(public tagName: string) {}
setAttribute() {}
addEventListener(type: string, listener: Listener) {
(this.listeners[type] ??= []).push(listener);
}
fire(type: string, evt: Record<string, unknown> = {}) {
for (const listener of this.listeners[type] ?? []) listener(evt);
}
focus() {}
select() {
this.selected = true;
}
remove() {
this.removed = true;
this.fire("blur");
}
}
let created: FakeElement[] = [];
let documentListeners: Record<string, Listener[]> = {};
const prompt = vi.fn();
const canvas = {
getBoundingClientRect: () => ({ left: 10, top: 20, bottom: 520, right: 810 }),
} as unknown as HTMLCanvasElement;
beforeEach(() => {
created = [];
documentListeners = {};
prompt.mockReset();
vi.stubGlobal("window", { prompt, dispatchEvent: () => true });
vi.stubGlobal("requestAnimationFrame", () => 1);
vi.stubGlobal("cancelAnimationFrame", () => {});
vi.stubGlobal("document", {
createElement: (tag: string) => {
const element = new FakeElement(tag);
created.push(element);
return element;
},
body: { appendChild: () => {} },
addEventListener: (type: string, listener: Listener) =>
(documentListeners[type] ??= []).push(listener),
removeEventListener: (type: string, listener: Listener) =>
(documentListeners[type] = (documentListeners[type] ?? []).filter((l) => l !== listener)),
});
});
afterEach(() => {
closeTextEditor(false);
vi.unstubAllGlobals();
});
/** 화면 좌표 = 월드 좌표(가짜 제어기) · 캔버스 아래 520 기준으로 y 를 뒤집음 */
const dblclickAt = (x: number, y: number) =>
handleTextDoubleClick({ clientX: 10 + x, clientY: 520 - y } as MouseEvent, canvas);
const key = (editor: FakeElement, k: string, extra: Record<string, unknown> = {}) => {
const evt = {
key: k,
ctrlKey: false,
metaKey: false,
isComposing: false,
keyCode: 0,
stopped: false,
prevented: false,
stopPropagation() {
evt.stopped = true;
},
preventDefault() {
evt.prevented = true;
},
...extra,
};
editor.fire("keydown", evt);
return evt;
};
const labels = () => getEntities().map((entity) => (entity as TextEntity).getLabel());
const load = (...entities: TextEntity[]) => {
setEntities(entities, false);
resetUndoBaseline();
};
describe("글자 더블클릭 = 그 자리 입력 칸", () => {
it("더블클릭 → 글자 자리에 입력 칸 · 글 전체 골라짐 · prompt 안 부름 · 캔버스 글자 감춤", () => {
const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 });
load(text);
dblclickAt(101, 200);
expect(prompt).not.toHaveBeenCalled();
expect(created).toHaveLength(1);
const [editor] = created;
expect(editor.tagName).toBe("input");
expect(editor.value).toBe("배수관");
expect(editor.selected).toBe(true);
expect(editor.style.left).toBe("110px"); // 캔버스 왼쪽 10 + 글자 x 100
expect(editor.style.top).toBe("320px"); // 캔버스 아래 520 - 글자 y 200
expect(editor.style.font).toContain("10px"); // 글 크기 5 × 배율 2
expect(text.previewLabel).toBe("");
});
it("Enter = 적용 · 되돌리기 한 번에 옛 글 · 키가 CAD 로 새지 않음", () => {
load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }));
dblclickAt(100, 200);
const [editor] = created;
const typed = key(editor, "a");
expect(typed.stopped).toBe(true);
expect(typed.prevented).toBe(false);
editor.value = "횡단배수관";
const enter = key(editor, "Enter");
expect(enter.stopped).toBe(true);
expect(editor.removed).toBe(true);
expect(labels()).toEqual(["횡단배수관"]);
expect((getEntities()[0] as TextEntity).previewLabel).toBeNull();
undo();
expect(labels()).toEqual(["배수관"]);
});
it("Esc = 취소 · 글 그대로 · 감춘 글자 되살림", () => {
const text = new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 });
load(text);
dblclickAt(100, 200);
const [editor] = created;
editor.value = "바꿈";
key(editor, "Escape");
expect(editor.removed).toBe(true);
expect(labels()).toEqual(["배수관"]);
expect(getEntities()[0]).toBe(text);
expect(text.previewLabel).toBeNull();
});
it("칸 밖 누름 = 적용 · 가운데 단추(팬)는 닫지 않음", () => {
load(new TextEntity("0", "배수관", new Point(100, 200), { fontSize: 5 }));
dblclickAt(100, 200);
const [editor] = created;
editor.value = "옹벽";
for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 1 });
expect(editor.removed).toBe(false);
for (const listener of documentListeners.pointerdown) listener({ target: {}, button: 0 });
expect(editor.removed).toBe(true);
expect(labels()).toEqual(["옹벽"]);
});
it("한글 조합 중 Enter 는 적용하지 않음", () => {
expect(editorKeyAction({ key: "Enter", isComposing: true } as KeyboardEvent, false)).toBeNull();
expect(editorKeyAction({ key: "Enter", keyCode: 229 } as KeyboardEvent, false)).toBeNull();
});
it("여러 줄(MTEXT) = 여러 줄 칸 · Enter 줄바꿈 · Ctrl+Enter 적용 · 줄 늘면 아래로 · 되돌리기 한 번", () => {
const first = new TextEntity("0", "첫 줄", new Point(100, 200), { fontSize: 5 });
const second = new TextEntity("0", "둘째 줄", new Point(100, 193), { fontSize: 5 });
first.groupId = second.groupId = "m1";
load(second, first);
dblclickAt(100, 193);
const [editor] = created;
expect(editor.tagName).toBe("textarea");
expect(editor.value).toBe("첫 줄\n둘째 줄");
expect(key(editor, "Enter").prevented).toBe(false);
expect(editor.removed).toBe(false);
editor.value = "첫 줄\n둘째\n셋째";
key(editor, "Enter", { ctrlKey: true });
expect(editor.removed).toBe(true);
const lines = getEntities() as TextEntity[];
expect(lines.map((line) => line.getLabel())).toEqual(["둘째", "셋째", "첫 줄"]);
expect(lines.every((line) => line.groupId === "m1")).toBe(true);
expect(lines[1].getFirstPoint()?.y).toBeCloseTo(186);
undo();
expect(labels()).toEqual(["둘째 줄", "첫 줄"]);
});
it("수량표 글자는 칸을 띄우지 않음", () => {
load(new TextEntity("b08-cross-table", "12.5", new Point(100, 200), { fontSize: 5 }));
dblclickAt(100, 200);
expect(created).toHaveLength(0);
expect(prompt).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,90 @@
/* 290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움 — 진짜 TS(b_page_state · b_project_switch)를
* 가짜 저장소 위에서 돌려 JSON 으로 냄(공용 storage · 브라우저 안 씀). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (rel, requireStub) => {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const exports = {};
new Function("exports", "require", "window", js)(exports, requireStub, global.window);
return exports;
};
function fakeStorage() {
const map = new Map();
return {
get length() {
return map.size;
},
key: (i) => [...map.keys()][i] ?? null,
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
dump: () => Object.fromEntries(map),
};
}
global.window = { sessionStorage: fakeStorage(), localStorage: fakeStorage() };
const pageState = load("A00_Common/b_page_state.ts", () => ({}));
const asked = [];
const purged = [];
let answer = true;
const sw = load("A00_Common/b_project_switch.ts", (id) => {
if (id.includes("b_page_state")) return pageState;
if (id.includes("b_asset_cache")) return { purgeOtherProjects: async (p) => purged.push(p) };
return {
showConfirmDialog: async (msg, ok, cancel) => (asked.push([msg, ok, cancel]), answer),
};
});
const A = "aaaa1111";
const B = "bbbb2222";
const C = "cccc3333";
const SEED = {
[`aislo:draft:structures:${A}`]: '[{"id":1}]',
[`aislo:draft:crossw:${A}:12`]: '{"10":1}',
[`b06:culvertopt:${A}:12`]: '{"x":1}', // 옛 키(초안)
[`aislo:result:latest:${A}`]: '{"r":1}',
[`aislo:draft:structures:${B}`]: '[{"id":2}]',
[`aislo:result:section-detail:${B}:7`]: '{"d":1}',
[`aislo:result:latest:${C}`]: '{"r":3}',
[`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음
[`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸
"aislo.otp-pending": "x", // 프로젝트와 무관
};
const seed = (extra = {}) => {
global.window.sessionStorage = fakeStorage();
Object.entries({ ...SEED, ...extra }).forEach(([k, v]) =>
global.window.sessionStorage.setItem(k, v),
);
asked.length = 0;
purged.length = 0;
};
const keys = () => Object.keys(global.window.sessionStorage.dump()).sort();
const nameOf = async (id) => `이름-${id}`;
global.window.localStorage.setItem(`aislo:pref:routeedit-view:${A}`, "{}");
(async () => {
const out = {};
seed();
answer = true;
await sw.enterProject(B, nameOf);
out.drop = { keys: keys(), asked: [...asked], purged: [...purged] };
seed();
answer = false;
await sw.enterProject(B, nameOf);
out.keep = { keys: keys(), asked: asked.length };
seed({ "aislo:entered-project": B });
await sw.enterProject(B, nameOf);
out.same = { keys: keys(), asked: asked.length, purged: purged.length };
out.pref = global.window.localStorage.getItem(`aislo:pref:routeedit-view:${A}`);
out.plan = sw.planProjectPurge(SEED, B).draftProjects;
process.stdout.write(JSON.stringify(out));
})();
+1 -1
View File
@@ -89,7 +89,7 @@ def test_addAt_은_새로_놓기_패널_폄_후_판정():
body.index('root.classList.remove("is-collapsed")'),
body.index("addsAtOnce(type, optionInputs)"),
body.index("finishAdd("),
body.index("commitTempPipe()"), # 폼만 갈래 — A군 임시 배치 그대로
body.index("commitTempPipe(true)"), # 폼만 갈래 — A군 임시 배치 그대로(우클릭만 · 291-1)
]
assert order == sorted(order)
assert "DEFAULT_INTERVAL_LENGTH_M" in body # 구간형 기본 15 m 그대로
@@ -0,0 +1,94 @@
"""289 종단도 누가토량 알약 크게 · 측점 ▲ 줄 아래로.
- 테마 토큰 `--pill-font-size-lg` = 글자 한 단 위(14px · 공용 알약 12px)
- 배지(B05 · B06 한 벌 `common_util_mass_haul.css`) = 토큰 글자 · 줄 간격 · 여백 4/12
- 자리 = 위에서 52px(▲ 줄 바닥 47px 아래 · 잰 값) · 종단 오버레이 한 곳(`design_profile_overlays.ts`)
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, selector
return m.group(1)
def test_token_one_step_up():
theme = _read("ui_template/ui_template_theme.css")
assert "--pill-font-size-lg: var(--text-body-sm);" in theme
def test_badge_uses_large_pill():
badge = _block(_read("common_util/common_util_mass_haul.css"), ".mass-haul-badge")
assert "font-size: var(--pill-font-size-lg)" in badge
assert "line-height: var(--leading-body-sm)" in badge
assert "padding: var(--spacing-4, 4px) var(--spacing-12, 12px)" in badge
def test_badge_below_up_buttons():
overlays = _read("A00_Common/design/design_profile_overlays.ts")
m = re.search(r'"--mass-haul-badge-top", "(\d+)px"', overlays)
assert m and int(m.group(1)) > 47 # 측점 ▲ 줄 바닥(top 30 + 17)
# 289-2 붉은 계통 — 테마 위험색 섞음 · 밝음 · 어두움 대비(글자 4.5 : 1 이상)
THEMES = {
"light": {"danger": "#c0392b", "surface": "#ffffff", "body": "#333333", "secondary": "#615e6e"},
"dark": {"danger": "#e8695a", "surface": "#251f38", "body": "#e4e0f0", "secondary": "#b3aec6"},
}
def _rgb(hex_: str) -> tuple[float, ...]:
return tuple(int(hex_[i : i + 2], 16) / 255 for i in (1, 3, 5))
def _mix(a: str, pct: int, b: str) -> tuple[float, ...]:
"""CSS color-mix(in srgb, a pct%, b) — 불투명 근사."""
return tuple(x * pct / 100 + y * (100 - pct) / 100 for x, y in zip(_rgb(a), _rgb(b)))
def _lum(c: tuple[float, ...]) -> float:
lin = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c]
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def _contrast(a: tuple[float, ...], b: tuple[float, ...]) -> float:
hi, lo = sorted((_lum(a), _lum(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
def test_badge_red_tone_uses_theme_danger():
css = _read("common_util/common_util_mass_haul.css")
badge = _block(css, ".mass-haul-badge")
assert "border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent)" in badge
assert "color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body))" in badge
assert "var(--color-danger) 6%" in badge
em = _block(css, ".mass-haul-badge em")
assert "color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary))" in em
over = _block(css, ".mass-haul-badge.is-over")
assert "border-color: var(--color-danger)" in over
assert "var(--color-danger) 12%" in over
over_value = _block(css, ".mass-haul-badge.is-over strong")
assert "color-mix(in srgb, var(--color-danger) 85%, var(--color-text-body))" in over_value
assert not re.search(r"#[0-9a-fA-F]{3,6}\b", badge + em + over + over_value) # 하드코딩 색 없음
def test_badge_red_tone_contrast_both_themes():
for name, t in THEMES.items():
bg = _mix(t["danger"], 6, t["surface"])
text = _mix(t["danger"], 70, t["body"])
caption = _mix(t["danger"], 55, t["secondary"])
over_bg = _mix(t["danger"], 12, t["surface"])
over_value = _mix(t["danger"], 85, t["body"]) # 음수 값 = 위험색 더 많이
assert _contrast(text, bg) >= 4.5, name
assert _contrast(caption, bg) >= 4.5, name
assert _contrast(over_value, over_bg) >= 4.5, name
assert _contrast(caption, over_bg) >= 4.5, name
@@ -0,0 +1,64 @@
"""290-1 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움(요구 183-1).
- 들어갈 때(공용 `goToWorkflowStage`) 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 페이지 칸) · 3D 보관함 지움
- 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠
- 같은 프로젝트로 다시 들어오면 아무것도 안 지움 · 지금 프로젝트 · 계정 취향(pref) 그대로
`helper_290_1_project_purge.cjs` 가 진짜 TS 를 가짜 저장소 위에서 돌림.
"""
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__))
A, B, C = "aaaa1111", "bbbb2222", "cccc3333"
MINE = [f"aislo:draft:structures:{B}", f"aislo:result:section-detail:{B}:7"]
UNRELATED = ["aislo.otp-pending", "aislo:entered-project"]
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_290_1_project_purge.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_지우고_열기_다른_프로젝트_모두_지움(out):
assert out["drop"]["keys"] == sorted(MINE + UNRELATED)
assert out["drop"]["purged"] == [B] # 3D 보관함도
assert out["drop"]["asked"] == [
[f"「이름-{A}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기", "지우고 열기", "남기기"]
]
def test_남기기_그_프로젝트_초안만_둠(out):
kept = [f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", f"b06:culvertopt:{A}:12"]
assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept)
assert out["keep"]["asked"] == 1
def test_같은_프로젝트_다시_들어오면_안_지움(out):
assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0
assert len(out["same"]["keys"]) == 11
def test_빈_초안은_고침_아님_취향은_둠(out):
assert out["plan"] == [A] # C 의 빈 초안({})은 묻지 않음
assert out["pref"] == "{}"
def test_공용_이동_한_곳에서_부름():
nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8")
assert "void enterProject(" in nav and "./b_project_switch" in nav
ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8")
assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""PLAN 291-1 — 구조물 배치: 새 구조물은 [추가] 누른 뒤에만 생김.
A군(관 지점 정본) — 기준 측점 확정(`commitPosition`)은 관을 안 넣음 · 우클릭 「추가」(116-1)만
임시 배치를 만듦 · [추가](`commit`)가 관 정본에 넣음. B · C 군은 [추가] 전 기록이 없음(`liveCommit` 이
고르던 항목만). 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 `fordWaits`).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def _body(src: str, start: str, end: str) -> str:
at = src.index(start)
return src[at : src.index(end, at)]
PANEL = _read("B05_Profile_UI_Structures_Panel.ts")
def test_임시_배치는_create_일_때만_관을_넣음():
body = _body(
PANEL, "function commitTempPipe(create = false): void {", "function handleStationInput"
)
guard = body.index("if (!create) return;")
add = body.index("callbacks.onPipeAdd(anchor, attributes);")
assert body.index("if (tempPipeChainage === null) {") < guard < add
# 이미 있는 임시 관(우클릭)은 기준 측점으로 옮김 그대로
assert "callbacks.onPipeUpdate(from, anchor, attributes);" in body
def test_측점_확정은_관을_안_만듦():
body = _body(PANEL, "function commitPosition(): void {", "function renderOptionFields")
assert "commitTempPipe();" in body
assert "commitTempPipe(true)" not in body
def test_우클릭_추가만_임시_배치_만듦():
body = _body(PANEL, " addAt(chainageM, typeId) {", " moveById(")
assert "if (type.managed_by) commitTempPipe(true);" in body
assert PANEL.count("commitTempPipe(true)") == 1
def test_추가_단추가_관_정본에_넣음():
commit = _read("B05_Profile_UI_Structures_Panel_Commit.ts")
body = _body(commit, "export async function commit(", "const placement = type.placement;")
assert "ctx.callbacks.onPipeAdd(anchor, attributes);" in body
def test_고르던_항목만_실시간_반영():
body = _body(PANEL, "function liveCommit(): void {", "function commitSummary")
assert "if (!editingId && selectedPipeChainage === null) return;" in body
def test_새_세월교_물넘이_월류_높이는_추가_뒤_채움():
drainage = _read("B05_Profile_UI_Drainage_Panel.ts")
body = _body(drainage, " addPipe(chainageM, attributes) {", " updatePipeFacility(")
assert "fordWaits.add(chainageM)" in body
def test_패널_700줄_안():
assert len(PANEL.splitlines()) <= 700