feat(B06 횡단): 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸 (370-3)

- 새 B06_Section_UI_Cross_Line_Cell.ts — 아래 띠 왼쪽 공용 칸(제목 · ◀▲↺▼▶ 한 벌 · 값 한 줄 말줄임 · 길게 누름 반복) · 숨은 버튼은 방향키 슬롯 뗌
- Design_Pick — 암 점선 위 판정 선(키 rock · ▲▼↺ · ◀▶ 막음 · 방향키 = 암만) · 떠 있는 선 조정창 · 우측 위 치수 라벨 걷음 · 안 고르면 암 칸 · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암)
- Center.ts bindAxisDrag(한 축 끌기 · 놓을 때 한 번 · click 한 번 막음) — 중심점 · 암 세로 끌기 같이 씀
- buildRockBoundaryControl = 공용 칸 「안 고름」 꼴(Cross_Design) · Card_Chrome 띠 왼쪽 = 공용 칸 · Detail_Pick 표 강조에 암 판정 선 뺌
- CSS(Cross.css 공용 칸 · Areas.css 암 고름 띠 · Controls.css 주석) · 시험 test_370_3 새로 · 137 · 139_6 · 179_1 · 193_1 · 194_1 · 318 · 356 · 370_2 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-17 12:14:43 +09:00
co-authored by Claude Opus 5.5
parent 6cac1b7cd4
commit fe025b6db2
25 changed files with 930 additions and 307 deletions
+7 -2
View File
@@ -288,7 +288,12 @@ const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-c
const stopped = hits[0].click();
handle.setActive(1);
const activeOn = hits.map((h) => h.classList.contains("is-active"));
const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent };
// 아래 띠 공용 칸(370-3) — 빈 칸 = is-empty · 값 = 버튼 줄 끝 글
const cellNow = () => ({
hidden: handle.cell.classList.contains("is-empty"),
text: handle.cell.children[1].children.at(-1).textContent,
});
const labelOn = cellNow();
handle.setActive(null);
out.real = {
designLines,
@@ -303,7 +308,7 @@ out.real = {
activeCount: activeOn.filter(Boolean).length,
activeIndex: activeOn.indexOf(true),
labelOn,
labelOff: { hidden: handle.label.hidden, text: handle.label.textContent },
labelOff: cellNow(),
zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(),
};
+15 -16
View File
@@ -147,21 +147,21 @@ const handle = pick.attachDesignLinePick({
set: (side, v) => (values[side] = v),
},
});
const panel = handle.panel;
const slot = (name) => panel.querySelector(`.b06-structure-panel__btn--${name}`);
const close = panel.querySelector(".b06-structure-panel__close");
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 · 값 한 줄
const cell = handle.cell;
const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
const cellTitle = () => cell.children[0].textContent;
const cellValue = () => cell.children[1].children.at(-1).textContent;
const pieceAt = (offset) =>
handle.pieces.findIndex(
(p) =>
Math.min(p.from.offset, p.to.offset) <= offset &&
offset <= Math.max(p.from.offset, p.to.offset),
);
const hiddenAtStart = panel.classList.contains("is-hidden");
const hiddenAtStart = cell.classList.contains("is-empty");
const log = [];
handle.setActive(pieceAt(4)); // 좌 사면
const titleLeft = panel.descendants.find((n) =>
n._cls.has("b06-structure-panel__title"),
).textContent;
const titleLeft = cellTitle();
slot("left").click(); // 화면 왼쪽 = 좌측 바깥 → +0.1
key("ArrowLeft"); // 방향키 = 십자
log.push({ ...values });
@@ -174,26 +174,25 @@ key("ArrowLeft"); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤
slot("right").click(); // 우측 바깥 → +0.1
log.push({ ...values });
handle.setActive(pieceAt(-4));
const text = handle.label.textContent;
const readout = panel.descendants.find((n) => n._cls.has("b06-structure-panel__value")).textContent;
const text = cellValue();
const slots = ["left", "up", "reset", "down", "right"]
.map((n) => slot(n))
.filter((b) => !b.hidden)
.map((b) => b.textContent);
slot("reset").click(); // ↺ = 그 쪽 0
const afterReset = { ...values };
close.click(); // ✕ = 같은 조각 다시 고름(카드가 풂)
handle.setActive(null);
key("ArrowRight"); // 숨은 패널은 방향키 안 먹음
key("ArrowRight"); // 빈 칸은 방향키 안 먹음
process.stdout.write(
JSON.stringify({
log,
text,
readout,
titleLeft,
afterReset,
afterHidden: { ...values },
picks,
pieceRight: pieceAt(-4),
hiddenAtStart,
hiddenAfter: panel.classList.contains("is-hidden"),
labelButtons: handle.label.children.length,
slots: ["left", "reset", "right"].map((n) => slot(n).textContent),
hiddenAfter: cell.classList.contains("is-empty"),
slots,
}),
);
+5 -8
View File
@@ -173,13 +173,10 @@ function card(sectionMode, ditch) {
Math.min(p.from.offset, p.to.offset) <= offset &&
offset <= Math.max(p.from.offset, p.to.offset),
);
const slot = (name) => handle.panel.querySelector(`.b06-structure-panel__btn--${name}`);
const title = () =>
handle.panel.descendants.find((n) => n._cls.has("b06-structure-panel__title")).textContent;
const rowHidden = () =>
handle.panel.descendants
.find((n) => n._cls.has("b06-structure-panel__struct"))
._cls.has("is-hidden");
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(◀▲↺▼▶ 보임만 바뀜) · 값 한 줄
const slot = (name) => handle.cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
const title = () => handle.cell.children[0].textContent;
const rowHidden = () => ["left", "reset", "right"].every((name) => slot(name).hidden);
return { design, handle, values, at, slot, title, rowHidden };
}
@@ -236,7 +233,7 @@ const out = {};
same: JSON.stringify(before) === JSON.stringify(c.values),
title: c.title(),
rowHidden: c.rowHidden(),
label: c.handle.label.textContent,
label: c.handle.cell.children[1].children.at(-1).textContent,
};
// 고름 풀면 아무 키도 안 막음
c.handle.setActive(null);
+14 -15
View File
@@ -176,15 +176,17 @@ function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], s
leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl),
road: find((p) => mid(p) > 0 && mid(p) < 2),
};
// 아래 띠 공용 칸(370-3) — 버튼 다섯 칸 한 벌(보임만 바뀜) · 제목 · 값 한 줄
const slot = (name) =>
handle.panel.descendants.find(
(n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden,
);
const rows = () =>
handle.panel.descendants
.filter((n) => n._cls.has("b06-structure-panel__struct"))
.map((n) => n._cls.has("is-hidden"));
const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent;
handle.cell.descendants.find((n) => n._cls.has(`b06-cross-card__cell-btn--${name}`));
const rows = () => [
slot("left").hidden && slot("right").hidden,
slot("up").hidden && slot("down").hidden,
];
const text = (what) =>
what === "title"
? handle.cell.children[0].textContent
: handle.cell.children[1].children.at(-1).textContent;
return { design, handle, values, sets, focus, at, slot, rows, text };
}
@@ -212,13 +214,10 @@ const out = {};
const afterSide = { values: { ...c.values }, sets: c.sets.length };
const upTaken = press("ArrowUp");
press("ArrowDown");
c.handle.panel.descendants
.filter((n) => n._cls.has("b06-structure-panel__btn--reset"))
.at(-1)
.click();
c.slot("reset").click();
out.berm = {
title: c.text("b06-structure-panel__title"),
readout: c.text("b06-structure-panel__value"),
title: c.text("title"),
readout: c.text("value"),
moveHidden,
liftHidden,
leftBlocked,
@@ -232,7 +231,7 @@ const out = {};
const before = c.sets.length;
out.berm.slopeUpBlocked = press("ArrowUp");
out.berm.slopeSets = c.sets.length - before;
out.berm.slopeTitle = c.text("b06-structure-panel__title");
out.berm.slopeTitle = c.text("title");
out.berm.focusAfter = c.focus.at(-1);
// 노면 = ▲▼ 는 종단도로 넘김(안 막음)
c.handle.setActive(c.at.road);
@@ -321,6 +321,12 @@ const out = {};
soil_exists: soil !== null,
soil_children: soil?.childElementCount ?? null,
};
// 370-3 — 설계선 고름 공용 칸이 오면 그 칸이 왼쪽(암 제어를 따로 안 만듦)
const cell = new FakeNode("div");
cell.className = "linecell";
const cellCard = new FakeNode("article");
appendCardFooter(cellCard, { design: { geometry_preset: "rock" } }, {}, {}, cell);
out.footer.cell_classes = cellCard.querySelector("footer").children.map((kid) => kid.className);
}
// ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ───────────
+15 -10
View File
@@ -260,18 +260,23 @@ out.toe = {
};
toes[1].click();
handle.setActive(picks[0]);
const right = handle.panel.children.find((k) =>
k.classList?.contains("b06-structure-panel__struct"),
);
const buttons = right.children[1].children;
const rightButton = x(-1) > x(0) ? buttons[2] : buttons[0]; // 화면 → 바깥(우 = −offset)
rightButton.click();
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(끝 = 값 글)
const cellOf = (h) => ({
title: h.cell.children[0].textContent,
value: h.cell.children[1].children.at(-1).textContent,
button: (slot) =>
h.cell.children[1].children.find((k) =>
k.classList.contains(`b06-cross-card__cell-btn--${slot}`),
),
});
const cell = cellOf(handle);
cell.button(x(-1) > x(0) ? "right" : "left").click(); // 화면 → 바깥(우 = −offset)
out.toePick = {
picked: picks,
active: toes[1].classList.contains("is-active"),
label: handle.label.textContent,
title: handle.panel.children[0].children[0].textContent,
panelOpen: !handle.panel.classList.contains("is-hidden"),
value: cell.value,
title: cell.title,
panelOpen: !handle.cell.classList.contains("is-empty"),
sent: changes.map((c) => c.shoulder_extend),
};
@@ -295,6 +300,6 @@ out.inherit = {
afterIndex,
};
after.handle.setActive(afterIndex);
out.inherit.label = after.handle.label.textContent;
out.inherit.label = cellOf(after.handle).title;
process.stdout.write(JSON.stringify(out));
+310
View File
@@ -0,0 +1,310 @@
/* PLAN 370-3 단위검증 헬퍼 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸.
* `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_370_3_rock_cell.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
class Node {
constructor(tag) {
this.tagName = tag;
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.isConnected = true;
this._text = "";
this._cls = new Set();
const self = this;
this.classList = {
add: (...n) => n.forEach((c) => self._cls.add(c)),
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
contains: (c) => self._cls.has(c),
};
}
get descendants() {
return this.children.flatMap((c) => [c, ...c.descendants]);
}
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
querySelector(sel) {
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
}
getClientRects() {
return this._cls.has("is-hidden") ? [] : [{}];
}
set className(v) {
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
}
set textContent(v) {
this._text = String(v);
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
this.children.push(...kids);
}
insertBefore(kid) {
this.children.push(kid);
}
setAttribute(n, v) {
this.attrs[n] = String(v);
if (n === "class") this.className = v;
}
getAttribute(n) {
return this.attrs[n] ?? null;
}
hasAttribute(n) {
return n in this.attrs;
}
addEventListener(t, fn) {
(this.listeners[t] ??= []).push(fn);
}
click() {
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
}
}
const docListeners = {};
globalThis.document = {
createElement: (t) => new Node(t),
createElementNS: (_n, t) => new Node(t),
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
removeEventListener: (t, fn) => {
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
},
};
const key = (name) =>
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => undefined }),
);
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const req = (r) =>
r.startsWith("@util/")
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
: loadTs(path.join(path.dirname(full), `${r}.ts`));
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(full, box.exports);
return box.exports;
}
globalThis.Element = Node;
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
const y = (e) => 900 - (e - 100) * 12;
const line = (pts, structure = false) => {
const el = new Node("polyline");
el.setAttribute("class", "b06-chart__design-cross");
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
return el;
};
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
const press = (name) => {
let prevented = false;
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
);
return prevented;
};
// 끌기 — window 듣개(놓을 때 click 한 번 막음 포함)
const winListeners = {};
globalThis.window = {
addEventListener: (t, fn) => (winListeners[t] ??= []).push(fn),
removeEventListener: (t, fn) => {
winListeners[t] = (winListeners[t] ?? []).filter((f) => f !== fn);
},
setTimeout: () => 0,
clearTimeout: () => undefined,
setInterval: () => 0,
clearInterval: () => undefined,
};
globalThis.setTimeout = () => 0;
const fire = (type, event) => [...(winListeners[type] ?? [])].forEach((fn) => fn(event));
/** 암 측점 카드 — 좌 사면 · 노면 · 우 사면 + 암 경계선 점선 두 토막(결측으로 끊김). */
function card(rockOn = true) {
docListeners.keydown = [];
const design = {
road_edges: {
left: { offset_m: 2, elevation_m: 100 },
right: { offset_m: -2, elevation_m: 100 },
},
ditch: null,
ditch_side: "left",
ditch_enabled: false,
section_mode: "both_fill",
};
const layer = new Node("g");
layer.getScreenCTM = () => ({ a: 1, d: 1 });
layer.append(
line([
[6, 97],
[2, 100],
]),
line([
[2, 100],
[-2, 100],
]),
line([
[-2, 100],
[-6, 97],
]),
);
const rockLine = (pts) => {
const el = new Node("polyline");
el.setAttribute("class", "b06-chart__rock-boundary");
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
el.style = { setProperty: (k, v) => (el.styleSet = v) };
return el;
};
const rocks = [
rockLine([
[8, 96],
[1, 98],
]),
rockLine([
[-1, 98],
[-8, 96],
]),
];
layer.append(...rocks);
const offsets = [];
const resets = [];
const rock = {
legend: "암 경계선",
offsetM: -0.5,
stepM: 0.1,
upTitle: "위로",
downTitle: "아래로",
resetTitle: "기본",
adjust: (d) => offsets.push(Math.round(d * 100) / 100),
reset: () => resets.push(true),
};
const values = { left: 0, right: 0 };
const picks = [];
const handle = pick.attachDesignLinePick({
layer,
design,
x,
toDisplayY: y,
onPick: (i) => picks.push(i),
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
stationId: "R1",
...(rockOn && { rock }),
});
const cell = handle.cell;
const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
const state = () => ({
empty: cell.classList.contains("is-empty"),
title: cell.children[0].textContent,
value: cell.children[1].children.at(-1).textContent,
slots: ["left", "up", "reset", "down", "right"].filter((n) => !slot(n).hidden),
arrowSlots: ["left", "up", "reset", "down", "right"].filter((n) =>
slot(n).classList.contains(`b06-structure-panel__btn--${n}`),
),
});
const hits = layer.children.filter((k) => k.classList.contains("b06-chart__design-hit"));
const rockHit = hits.find((k) => k.classList.contains("b06-chart__rock-hit"));
if (rockHit) rockHit.style = { setProperty: (k, v) => (rockHit.styleSet = v) };
return { handle, cell, slot, state, hits, rockHit, rocks, offsets, resets, values, picks };
}
const out = {};
// ① 안 고름 — 공용 칸 = 암 ▲▼↺ · 값 · 방향키는 안 먹음(종단 계획고로 그대로)
{
const c = card();
c.handle.setActive(null);
out.idle = { ...c.state(), upPassed: !press("ArrowUp") };
c.slot("up").click();
c.slot("down").click();
c.slot("reset").click();
out.idle.offsets = c.offsets;
out.idle.resets = c.resets.length;
}
// ② 판정 선 — 점선 위 path 하나(끊긴 두 토막 · M 둘) · 보라 선 판정 클래스 · 키 rock
{
const c = card();
const index = c.hits.indexOf(c.rockHit);
c.rockHit.click();
out.hit = {
found: !!c.rockHit,
tag: c.rockHit?.tagName,
moves: (c.rockHit?.getAttribute("d").match(/M/g) ?? []).length,
last: index === c.hits.length - 1,
picked: c.picks,
};
// ③ 고름 — 제목 · ▲▼↺ · ◀▶ 막음 · ▲▼ = 암만(종단으로 안 샘)
c.handle.setActive(index);
pick.setPickedLine("R1", index);
out.picked = {
...c.state(),
active: c.rockHit.classList.contains("is-active"),
leftBlocked: press("ArrowLeft"),
rightBlocked: press("ArrowRight"),
upTaken: press("ArrowUp"),
downTaken: press("ArrowDown"),
sideValues: { ...c.values },
key: pick.pickedLineAt("R1") === index,
};
out.picked.offsets = c.offsets;
// ④ 세로 끌기 — 위로 0.34 m(화면 px = m × 12) → 0.3 m 한 번 · 끄는 동안 점선 · 판정 선만 옮겨 보임
let stopped = false;
c.rockHit.listeners.pointerdown[0]({
button: 0,
clientX: 0,
clientY: 500,
preventDefault: () => undefined,
stopPropagation: () => (stopped = true),
});
fire("pointermove", { clientX: 0, clientY: 500 - 0.34 * 12 });
const during = { rock: c.rocks[0].styleSet, hit: c.rockHit.styleSet, value: c.state().value };
const before = c.offsets.length;
fire("pointerup", {});
out.drag = {
stopped,
during,
sent: c.offsets.slice(before),
clickSwallowed: (winListeners.click ?? []).length === 1,
};
// 풀면 다시 암 칸 · 방향키 안 먹음
c.handle.setActive(null);
out.released = { ...c.state(), upPassed: !press("ArrowUp") };
// 사면을 고르면 그 선 칸(제목 · ◀↺▶ · 값 한 줄)
const slope = c.handle.pieces.findIndex((p) => Math.max(p.from.offset, p.to.offset) > 3);
c.handle.setActive(slope);
out.slope = c.state();
pick.setPickedLine("R1", null);
}
// ⑤ 안 고른 끌기는 안 감(카드 팬) · 토사(암 없음) = 빈 칸 · 판정 선 없음
{
const c = card();
c.handle.setActive(null);
let stopped = false;
c.rockHit.listeners.pointerdown[0]({
button: 0,
clientY: 0,
preventDefault: () => undefined,
stopPropagation: () => (stopped = true),
});
out.idleDrag = { stopped };
const soil = card(false);
soil.handle.setActive(null);
out.soil = { ...soil.state(), rockHit: !!soil.rockHit };
}
process.stdout.write(JSON.stringify(out));
+3 -2
View File
@@ -100,8 +100,9 @@ def test_카드_배선_하나만() -> None:
assert "if (activeRevet === null) revetOffset?.select(section.chainage_m, null);" in view
assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적
assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림
assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽
assert "chartWrap.append(linePick.panel);" in view # 사면 이동 패널(151-3)
# 치수 · 이동 = 아래 띠 왼쪽 공용 칸(370-3 · 옛 [+ − ⤢] 왼쪽 치수 · 떠 있는 이동 패널 걷음)
assert "linePick.label" not in view and "linePick.panel" not in view
assert "appendCardFooter(card, section, rockBoundary, cutSlope, linePick?.cell);" in view
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
assert ".b06-chart__design-hit.is-active {" in css
+5 -6
View File
@@ -33,18 +33,17 @@ def test_화면_방향대로_그_쪽만_단추와_방향키(out: dict) -> None:
assert right == {"left": 0, "right": 0.1} # 우 사면 · ← = 안쪽 0 · ▶ = 바깥 0.1
def test_치수_줄과_패널에_이동_표기(out: dict) -> None:
def test_공용_칸_값에_이동_표기(out: dict) -> None:
"""370-3 — 치수 · 이동은 아래 띠 공용 칸 값 한 줄(옛 우측 위 치수 · 패널 읽기 줄)."""
assert "이동 +0.1m" in out["text"] and "노견 +" not in out["text"]
assert out["readout"] == "이동 0.1m"
assert out["text"].startswith("길이 ") and "\n" not in out["text"]
assert out["titleLeft"] == "사면 이동 (좌)"
def test_단추는_패널로_옮김_재설정_닫기(out: dict) -> None:
"""151-3 — 치수 칸 [◀ ▶] 지움 · 패널 ◀ ↺ ▶ · ↺ = 그 쪽 0 · ✕ = 고름 풂 · 숨으면 방향키 안 먹음."""
assert out["labelButtons"] == 0
def test_단추는_공용_칸_재설정(out: dict) -> None:
assert out["slots"] == ["◀", "↺", "▶"]
assert out["afterReset"] == {"left": 0, "right": 0}
assert out["picks"] == [out["pieceRight"]]
assert out["picks"] == [] # 칸 누름은 고름으로 안 번짐
assert out["hiddenAtStart"] is True and out["hiddenAfter"] is True
assert out["afterHidden"] == {"left": 0, "right": 0}
+12 -3
View File
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험)."""
"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험).
370-3 — 카드 치수는 아래 띠 공용 칸으로 옮김 · 치수 칸 꼴은 상세 모달(`_Detail_Select`)이 씀.
"""
import os
import re
@@ -22,9 +25,15 @@ def test_좁은_카드_내림_규칙_없음():
]
def test_치수_칸은_줌_막대_맨_앞():
def test_카드_치수는_아래_띠_공용_칸():
"""370-3 — 카드의 고른 선 치수는 줌 막대 앞 라벨을 걷고 아래 띠 왼쪽 공용 칸 값 한 줄로(상세 모달 치수는 그대로)."""
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
assert "zoomBar.prepend(linePick.label);" in view
assert "linePick.label" not in view and "zoomBar.prepend(" not in view
assert "linePick?.cell" in view
assert "b06-cross-card__line-dims" not in _src(
"B06_Section/B06_Section_UI_Cross_Design_Pick.ts"
)
assert "b06-cross-card__line-dims" in _src("B06_Section/B06_Section_UI_Cross_Detail_Select.ts")
zoom = (
_src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
.split(".b06-cross-card__zoom {", 1)[1]
+3 -2
View File
@@ -298,7 +298,8 @@ def test_이동_셋_분리(ui: dict) -> None:
@node_only
def test_소단_고름_위아래만(ui: dict) -> None:
berm = ui["berm"]
assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m"
assert berm["title"] == "소단 높이 (좌)"
assert berm["readout"].startswith("도로 아래 1.0m · 길이 ") # 값 한 줄(370-3 공용 칸)
assert berm["moveHidden"] is True and berm["liftHidden"] is False
assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘
assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감
@@ -332,7 +333,7 @@ def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None:
assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3}
assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음
assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 없음
selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text(
encoding="utf-8"
)
+5
View File
@@ -95,6 +95,11 @@ def test_footer_without_center_text(result: dict) -> None:
assert footer["rock_first"].split()[-1] == "b06-cross-card__rockb" # 암 경계 = 왼쪽 첫 칸
assert footer["rock_classes"][-1].split()[-1] == "b06-cross-card__cutslope"
assert footer["soil_exists"] and footer["soil_children"] == 0 # 토사도 띠는 남음(빈 띠)
# 370-3 — 선 공용 칸 = 왼쪽 첫 칸(암 꼴 클래스) · 암 절토 경사 = 오른쪽 그대로
assert footer["cell_classes"] == [
"linecell b06-cross-card__rockb",
"cutslope b06-cross-card__cutslope",
]
def test_center_wired_into_card_and_modal() -> None:
+2 -4
View File
@@ -82,11 +82,9 @@ def test_배선():
pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8")
assert "center: section.chainage_m," in pick # designLineMoves → 카드 고름 끝 조각
assert 'if (axis === "center") return centerShift?.step(dir);' in pick
# 364-7 덧 — 중심점 ◀▶ 툴팁 = 노선 좌우 · ↺ · 소단 ▲▼ 숨김
# 364-7 덧 — 중심점 ◀▶ 툴팁 = 노선 좌우 · ↺ 없음 · 소단 ▲▼ 없음(370-3 공용 칸 = 축에 맞는 버튼만)
assert "`이 측점 노선을 화면 ${screen}으로 0.1 m`" in pick
assert '[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];' in pick
assert 'move.controls.children[1]?.classList.toggle("is-hidden", axis === "center");' in pick
assert 'lift.row.classList.toggle("is-hidden", axis !== "depth");' in pick
assert 'title: "중심점", value: centerPreviewText(0), buttons: sideways(false)' in pick
order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8")
assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order
edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
+3 -3
View File
@@ -63,8 +63,8 @@ def test_교차점_손잡이_쪽마다_늘_보임(out) -> None:
def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None:
got = out["toePick"]
assert got["active"] is True and got["panelOpen"] is True
assert got["label"].startswith("교차점 (우) · 노선에서 ")
assert got["title"] == "교차점 이동 (우)"
assert got["title"] == "교차점 이동 (우)" # 아래 띠 공용 칸(370-3)
assert got["value"].startswith("노선에서 ")
assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right"
low, high = out["stop"]["crossing"]
assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리
@@ -76,4 +76,4 @@ def test_절에서_성으로_넘어가도_고름_유지(out) -> None:
assert inherit["cutIndex"] >= 0 and inherit["before"] == inherit["cutIndex"]
assert inherit["farMode"] == "left_cut" # 우 = 성토로 넘어감
assert inherit["afterIndex"] is not None
assert inherit["label"].startswith("교차점 (우)")
assert inherit["label"] == "교차점 이동 (우)"
+93
View File
@@ -0,0 +1,93 @@
"""PLAN 370-3 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸(요구 240-22).
짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(rock 축 · 판정 선) ·
`B06_Section/B06_Section_UI_Cross_Line_Cell.ts`(공용 칸) · `B06_Section_UI_Cross_Center.ts`(`bindAxisDrag`).
실제 TS 를 `helper_370_3_rock_cell.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_370_3_rock_cell.cjs"
B06 = ROOT / "B06_Section"
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_안_고르면_공용_칸은_암_버튼과_값(out: dict) -> None:
idle = out["idle"]
assert idle["title"] == "암 경계선" and idle["value"] == "-0.5m"
assert idle["slots"] == ["up", "reset", "down"]
assert idle["upPassed"] is True # 안 고르면 ▲▼ = 종단 계획고 그대로
assert idle["offsets"] == [0.1, -0.1] and idle["resets"] == 1
def test_암_경계선_판정_선(out: dict) -> None:
hit = out["hit"]
assert hit["found"] and hit["tag"] == "path" and hit["moves"] == 2 # 끊긴 두 토막 한 판정
assert hit["last"] and hit["picked"] == [3] # 조각 셋 뒤 · 누르면 고름
def test_암_고름은_위아래만_암_키는_암만(out: dict) -> None:
got = out["picked"]
assert got["active"] and got["key"] # 키 rock 으로 기억
assert got["title"] == "암 경계선" and got["slots"] == ["up", "reset", "down"]
assert got["leftBlocked"] and got["rightBlocked"] # ◀▶ 막음
assert got["upTaken"] and got["downTaken"] # ▲▼ = 암(종단으로 안 샘)
assert got["sideValues"] == {"left": 0, "right": 0}
assert got["offsets"][:2] == [0.1, -0.1]
def test_세로_끌기는_놓을_때_한_번(out: dict) -> None:
drag = out["drag"]
assert drag["stopped"] is True # 카드 팬으로 안 번짐
assert drag["during"]["rock"] == drag["during"]["hit"] # 점선 · 판정 선 같이
dy = float(drag["during"]["rock"].split()[1].removesuffix("px"))
assert abs(dy - (-0.3 * 12)) < 1e-9 # 위로 0.3 m(화면 px = m × 12)
assert drag["during"]["value"] == "-0.2m — 놓으면 반영"
assert drag["sent"] == [0.3] # 0.34 m → 걸음 0.1 맞춤 · 한 번
assert drag["clickSwallowed"] is True
assert out["idleDrag"]["stopped"] is False # 안 고르면 끌기 아님
def test_풀면_암_칸_사면_고르면_그_선_칸(out: dict) -> None:
assert out["released"]["title"] == "암 경계선" and out["released"]["upPassed"]
slope = out["slope"]
assert slope["title"] == "사면 이동 (좌)" and slope["slots"] == ["left", "reset", "right"]
assert slope["value"].startswith("길이 ") and "\n" not in slope["value"]
assert slope["arrowSlots"] == slope["slots"] # 숨은 버튼은 방향키 슬롯 없음
def test_토사는_빈_칸_판정_선_없음(out: dict) -> None:
soil = out["soil"]
assert soil["empty"] is True and soil["slots"] == [] and soil["rockHit"] is False
def test_배선_떠_있는_선_조정창_걷음() -> None:
pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8")
assert "buildPanelShell" not in pick and "b06-cross-card__line-dims" not in pick
view = (B06 / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert "rockBoundary && rockCellSpec(section, rockBoundary)" in view
chrome = (B06 / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8")
assert "if (lineCell) {" in chrome
detail = (B06 / "B06_Section_UI_Cross_Detail_Pick.ts").read_text(encoding="utf-8")
assert ".b06-chart__design-hit:not(.b06-chart__rock-hit)" in detail # 상세 표 강조에 안 섞임
css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8")
assert ".b06-cross-card__linecell.is-empty {" in css and "text-overflow: ellipsis;" in css