Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -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));
|
||||
})();
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user