Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-17 12:07:25 +09:00
24 changed files with 375 additions and 238 deletions
+72 -3
View File
@@ -26,7 +26,9 @@ function loadTs(file) {
}
const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts"));
const align = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"));
const align = loadTs(
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
);
// 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다.
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
@@ -124,14 +126,23 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
out.radiiKeys = Object.keys(next.curve_radii).sort();
const built = align.buildAlignment(base, next);
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) };
out.pvi = {
40: pvi(40),
60: pvi(60),
80: pvi(80),
100: pvi(100),
120: pvi(120),
140: pvi(140),
160: pvi(160),
};
out.ground = { 40: z0(40), 160: z0(160) };
const g1 = (103.46 - z0(40)) / 40;
const g2 = (101.2 - 103.46) / 40;
out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6));
out.r80 = next.curve_radii["80.000"];
out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null;
out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
out.outsideUntouched =
!("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
}
// ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작.
@@ -172,4 +183,62 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
out.enterOutsideDrawing = draw.key("Enter");
}
// ⑥ 되돌리기 · 다시하기(367-3) — 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · apply 안 불림 · 범위까지 거꾸로.
{
const { draw, log } = harness();
out.undoEmpty = { can: draw.canUndo(), backspace: draw.key("Backspace") };
draw.click(40, 0, stations);
draw.click(40, 0, stations); // 같은 세로선 다시 — 상태 그대로 · 안 쌓임
draw.click(160, 0, stations);
draw.click(40, 0, stations);
draw.click(80, 102, stations);
draw.click(120, 103, stations);
const dots = () => draw.marks().dots.map((d) => d.chainage_m);
draw.undo();
out.undo1 = { dots: dots(), canRedo: draw.canRedo() };
draw.redo();
out.redo1 = { dots: dots(), canRedo: draw.canRedo() };
draw.undo();
draw.click(100, 104, stations); // 새 클릭 = future 비움
out.newClick = { dots: dots(), canRedo: draw.canRedo() };
const phases = [];
while (draw.canUndo()) {
draw.undo();
phases.push(`${draw.phase()}:${draw.marks().edges.length}`);
}
out.undoAll = phases;
out.undoApplied = log.applied.length;
out.undoClosed = log.closed;
draw.redo();
draw.redo();
out.redoTwice = { phase: draw.phase(), band: draw.marks().band };
draw.reset();
out.resetClears = { undo: draw.canUndo(), redo: draw.canRedo() };
}
// ⑦ 적용이 막히면(apply = false) 자유지정 열린 채 · 점 그대로.
{
let tries = 0;
const draw = free.createFreeDraw({
base: () => base,
alignment: () => align.buildAlignment(base, align.emptyEdits()),
edits: () => align.emptyEdits(),
apply: () => ((tries += 1), false),
close: () => (tries += 100),
changed: () => undefined,
});
draw.click(40, 0, stations);
draw.click(160, 0, stations);
draw.click(40, 0, stations);
draw.click(80, 102, stations);
draw.key("Enter");
draw.click(160, 0, stations);
out.blocked = {
tries,
phase: draw.phase(),
dots: draw.marks().dots.length,
canUndo: draw.canUndo(),
};
}
process.stdout.write(JSON.stringify(out));
+33 -15
View File
@@ -489,7 +489,8 @@ function makePage() {
return app;
}
function fakeDocument(app) {
const doc = { body: new El("BODY") };
// 374-1 뜨는 자리 = body(모달 포함) · 원 · 배율 글은 html 바로 아래
const doc = { body: app, documentElement: new El("HTML") };
doc.createElement = (tag) => new El(tag.toUpperCase());
doc.getElementById = (id) => (id === "app" ? app : null);
return doc;
@@ -515,10 +516,12 @@ function fakeDocument(app) {
};
const view = m.createLoupeView(win);
view.show(500, 400);
const root = doc.body.children[0];
const label = doc.body.children[1];
const root = doc.documentElement.children[0];
const label = doc.documentElement.children[1];
const stage = root.children[0];
const page = stage.children[0];
const scaler = stage.children[0]; // 이동 = stage · 배율 = scaler
const page = scaler.children[0];
const lens = () => `${stage.style.transform} ${scaler.style.transform}`;
const copy = page.children[0];
const [scroller, offscreen, table, svg, frame, cadBox] = copy.children;
const [canvas, blank, input, check] = scroller.children;
@@ -529,7 +532,8 @@ function fakeDocument(app) {
ariaHidden: root.attrs["aria-hidden"],
hidden: root.hidden,
rootTransform: root.style.transform,
stageTransform: stage.style.transform,
stageTransform: lens(),
stageMoveOnly: stage.style.transform,
pageBox: [page.style.left, page.style.top, page.style.width, page.style.height],
isClone: copy.cloned === true && copy !== app,
canvasDrawn: canvas.drawn.map((d) => d[0]),
@@ -560,19 +564,31 @@ function fakeDocument(app) {
hidden: label.hidden,
};
view.setZoom(3);
r.label.zoom3 = [label.textContent, stage.style.transform, root.style.transform];
r.label.zoom3 = [label.textContent, lens(), root.style.transform];
view.move(500, 790);
r.label.bottom = label.style.transform;
view.setZoom(2);
view.move(500, 400);
// 화면이 바뀜 — 0.5 초 뒤 한 번만 다시 뜸
// 화면이 바뀜 — 멈춘 뒤 0.5 초에 한 번만 다시 뜸
const firstCopy = copy;
observers[0].fn();
observers[0].fn();
win.tick(499);
const sameBefore = stage.children[0].children[0] === firstCopy;
const sameBefore = scaler.children[0].children[0] === firstCopy;
win.tick(1);
const recaptured = stage.children[0].children[0] !== firstCopy && stage.children.length === 1;
const recaptured = scaler.children[0].children[0] !== firstCopy && scaler.children.length === 1;
// 374-1 움직이는 동안 안 뜸 — 바뀜 뒤 움직이면 처음부터 · 멈춘 뒤 0.5 초에 한 번
const moving = scaler.children[0].children[0];
observers[0].fn();
win.tick(300);
view.move(520, 400);
win.tick(300);
view.move(540, 400);
win.tick(499);
const heldWhileMoving = scaler.children[0].children[0] === moving;
win.tick(1);
r.debounce = { heldWhileMoving, afterStop: scaler.children[0].children[0] !== moving };
view.move(500, 400);
view.move(10, 10);
const nearEdge = root.style.transform;
view.hide();
@@ -580,20 +596,21 @@ function fakeDocument(app) {
sameBefore,
recaptured,
nearEdge,
stageChildren: stage.children.length,
stageChildren: scaler.children.length,
hidden: root.hidden,
disconnected: observers[0].watching === null,
scrollListen: win.calls.some((c) => c.type === "scroll"),
};
observers[0].fn();
win.tick(1000);
r.hide.noRecaptureAfterHide = stage.children.length;
r.hide.noRecaptureAfterHide = scaler.children.length;
r.hide.labelHidden = label.hidden;
view.setZoom(4);
view.hide();
view.show(500, 400);
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
r.hide.reuseRoot = doc.body.children.length;
r.hide.reopenLabel = [label.textContent, label.hidden, lens()];
r.hide.reuseRoot = doc.documentElement.children.length;
r.hide.bodyUntouched = app.children.length; // 원이 body 에 안 붙음
}
{
// 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서)
@@ -606,8 +623,9 @@ function fakeDocument(app) {
disconnect() {}
};
const view = m.createLoupeView(win);
const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0];
const page = () => doc.body.children[0].children[0].children[0];
const scaler = () => doc.documentElement.children[0].children[0].children[0];
const cloneScroller = () => scaler().children[0].children[0].children[0];
const page = () => scaler().children[0];
view.show(500, 400);
view.hide();
scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘
@@ -23,7 +23,7 @@ const mass = read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts");
let stored = null;
const src = [
'const SERIES_BASES = ["cross", "longitudinal"];',
'const DEFAULT_VISIBLE = ["cross"];',
'const DEFAULT_VISIBLE = ["cross", "balance"];', // 367-1 기본 = 횡단 + 분배
block(util, "export function normalizeVisibleBasis("),
block(mass, "function readVisible(): Set<string> {"),
block(util, "export function computeMassHaulSeries("),
@@ -115,6 +115,38 @@ def test_Backspace_와_Esc_취소(out):
assert out["enterOutsideDrawing"] is False
def test_자유지정_되돌리기_다시하기(out):
# 367-3 ⑥ 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · 큰 이력(apply · close) 안 건드림
assert out["undoEmpty"] == {"can": False, "backspace": False}
assert out["undo1"] == {"dots": [40, 80], "canRedo": True}
assert out["redo1"] == {"dots": [40, 80, 120], "canRedo": False}
assert out["newClick"] == {"dots": [40, 80, 100], "canRedo": False}
# 한 걸음 = 점 · 시작 · 범위 끝 · 범위 첫 · 같은 세로선 다시 누름은 안 쌓임
assert out["undoAll"] == ["drawing:2", "drawing:2", "armed:2", "range:1", "range:0"]
assert out["undoApplied"] == 0 and out["undoClosed"] == 0
assert out["redoTwice"] == {"phase": "armed", "band": [40, 160]}
assert out["resetClears"] == {"undo": False, "redo": False}
def test_적용_막히면_열린_채(out):
# 367-3 ⑦ Enter · 끝 세로선 둘 다 apply 시도(2) · close 없음 · 점 그대로
assert out["blocked"] == {"tries": 2, "phase": "drawing", "dots": 2, "canUndo": True}
def test_되돌리기_배선_자유지정_먼저():
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()' in panel
assert 'tools.mode() === "free" ? free.canUndo() : inner.canUndo()' in panel
keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :]
free_at = keys.index('if (tools.mode() === "free") {')
assert (
free_at < keys.index("history.undo()")
and "return;" in keys[free_at : keys.index("flushHold();")]
)
profile = _read("A00_Common/design/design_profile.ts")
assert "function applyEdits(next: AlignmentEdits): boolean {" in profile
def test_도구줄_직선화_오른쪽_자유지정():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts")
assert '"자유지정"' in tools
@@ -142,6 +174,28 @@ def test_오버레이_점선_빨강_글():
assert ".b05-profile-freemark__line.is-over" in css
def _css_rule(css: str, selector: str) -> str:
at = css.index(selector + " {")
return css[at : css.index("}", at)]
def test_자유지정_녹색_넘음_빨강_띠_보라():
# 367-4 선 · 미리 보기 · 점 = --color-success · 넘음 빨강 · 범위 띠 · 경계 보라 그대로
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__line")
assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__dot")
assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__line.is-over")
assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__grade.is-over")
assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__band")
assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__edge")
# 밝음 · 어두움(명시 · OS 자동) 모두 success · danger 토큰 있음
theme = _read("ui_template/ui_template_theme.css")
auto = theme[theme.index("@media (prefers-color-scheme: dark)") :]
auto = auto[: auto.index("\n}\n")]
for block in (_css_rule(theme, ":root"), _css_rule(theme, ':root[data-theme="dark"]'), auto):
assert "--color-success:" in block and "--color-danger:" in block
def test_줄_수_늘리지_않음():
for relative in (
"A00_Common/design/design_profile.ts",
+20
View File
@@ -162,6 +162,26 @@ def test_바뀌면_0_5초_뒤_다시_뜸_끄면_복제_지움(r):
assert h["hidden"] and h["disconnected"] and h["scrollListen"] is False
assert h["noRecaptureAfterHide"] == 0
assert h["reuseRoot"] == 2 # 원 · 배율 글 하나씩을 다시 씀
assert h["bodyUntouched"] == 6 # 원 · 글은 html 바로 아래 — body 복제 · 관찰에 안 걸림(374-1)
def test_움직이는_동안_안_뜸_멈춘_뒤_한_번(r):
# 374-1 디바운스 — 바뀜 뒤 계속 움직이면 안 뜸 · 마지막 움직임 0.5 초 뒤 한 번
assert r["debounce"] == {"heldWhileMoving": True, "afterStop": True}
# 이동 = 바깥 판 translate 만(합성층) · 배율 = 안쪽 상자
assert r["capture"]["stageMoveOnly"] == "translate(-820px, -620px)"
css = _read("ui_template/ui_template_loupe.css")
stage = css[css.index(".ui-loupe__stage {") :]
assert "will-change: transform;" in stage[: stage.index("}")]
scale = css[css.index(".ui-loupe__scale {") :]
assert "transform-origin: 0 0;" in scale[: scale.index("}")]
def test_뜨는_자리_화면_전체_모달_포함():
src = _read("ui_template/ui_template_loupe.ts")
assert "sourceOf: () => Element | null = () => win.document.body," in src
assert "doc.documentElement.append(root, label);" in src
assert 'getElementById("app")' not in src
def test_테두리_그림자_토큰():
@@ -11,7 +11,6 @@
import json
import os
import re
import subprocess
from pathlib import Path
@@ -65,7 +64,5 @@ def test_끌기도_유토_판_안_셈():
def test_뗀_유토는_노선_전체_세로():
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
assert re.search(
r'viewRange: overlay\.classList\.contains\("is-popout"\) \? undefined : params\.axis\.viewRange',
mass,
)
# 367-1 붙음 · 뗌 모두 노선 전체 세로
assert "viewRange: undefined," in mass
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창.
- 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 단추 = 두 판 머리줄 맨 앞(유토는 펼친 때만 보임 · 367-2) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐).
- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome.
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝.
+3 -1
View File
@@ -48,7 +48,9 @@ def test_Ctrl_Z_는_사진첩_한_줄(out):
def test_종단도_Ctrl_Z_줄이_사진첩_하나():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {')
assert "undoOwner" not in tools and "history.undo()" in tools[at : at + 240]
assert (
"undoOwner" not in tools and "history.undo()" in tools[at : at + 600]
) # 367-3 자유지정 분기 앞에 섬
history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts")
assert '"keydown"' not in history and "onPageHistory" in history
@@ -3,8 +3,8 @@
- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할]
(`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 머리줄만(손잡이 · [창 분할] 닿음).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화는 그대로.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 높이 0 · 손잡이만 매달림 · [창 분할] 숨김(367-2).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화 없음(367-1 늘 전체 맞춤).
"""
import os
@@ -30,7 +30,7 @@ def test_종단_열_아래_자기_칸():
assert 'column.className = "b05-route-profile__column";' in overlays
assert "column.append(bodyWrap, massHaul.overlay);" in overlays
assert "bodyWrap.append(massHaul.overlay" not in overlays
assert "massHaul.attachScrollSync(body);" in overlays
assert "massHaul.attachScrollSync" not in overlays # 367-1 측점 연계 없앰
profile = read("A00_Common/design/design_profile.ts")
assert "content.append(column);" in profile
assert sum(1 for _ in profile.splitlines()) <= 700
@@ -57,6 +57,18 @@ def test_흐름_칸_자기_높이_접으면_머리줄():
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)",
)
assert "display: none;" in hide
# 367-2 접음 = 높이 0 · 손잡이 판 위 경계에 매달림 · [창 분할] 숨김(뗀 창 [창 결합] 그대로)
folded = css_block(css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout)")
assert "height: 0;" in folded
head = css_block(
css,
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head",
)
assert "top: auto;" in head and "bottom: 100%;" in head
assert "display: none;" in css_block(
css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout"
)
assert ".is-popout .b05-subpanel__popout" not in css
assert "display: none;" in css_block(
css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay"
)
@@ -37,7 +37,7 @@ def test_저장된_종단_범례는_횡단으로(r):
assert r["longitudinal"] == ["cross"]
assert r["longitudinalBalance"] == ["balance", "cross"]
assert r["cross"] == ["cross"]
assert r["empty"] == ["cross"] and r["broken"] == ["cross"]
assert r["empty"] == ["balance", "cross"] and r["broken"] == ["balance", "cross"]
def test_기준_횡단만_계산(r):
+20 -20
View File
@@ -4,14 +4,13 @@
- 메아리: scroll 은 다음 틀에 옴 → 동기 플래그로 못 막아 한 틀 전 값을 되씀 → 뒤로 튐 →
휠 이동이 접힘. 공용 `linkScrollLeft`(Wheel.ts)가 써 넣은 값으로 온 scroll 을 무시.
가짜 창(다음 틀 scroll) 재현 — 옛 잇기는 뒤로 튐 · 멈춤, 새 잇기는 4 경우 × 휠 쪽 둘 모두 튐 0.
- 유토 스크롤 그리기 = 열림 · 분배 맞춤 아님 · 붙음일 때만 · 한 틀 한 번 · 다시 셈 없음.
- 유토곡선 = 늘 전체 맞춤 · 종단과 안 이음(367-1) — 휠 · 잇기 경우는 공용 도우미 시험으로 남김.
`helper_354_3_scroll_echo.cjs` 가 진짜 휠 · 잇기 TS 를 가짜 창 위에서 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
@@ -64,24 +63,25 @@ def test_새_잇기는_뒤로_튐_0_끝까지(r, case):
assert got["mass"] == (0 if balance else 1200)
def test_유토_표_같은_도우미():
for name in ("MassHaul", "TableOverlay"):
src = (B05 / f"B05_Profile_UI_Profile_{name}.ts").read_text(encoding="utf-8")
assert "linkScrollLeft(main, scroll" in src
assert "syncing" not in src
def test_유토_스크롤_그리기는_창_따를_때만_한_틀_한_번():
def test_표만_잇기_유토는_안_이음():
# 367-1 유토곡선 = 늘 전체 맞춤 · 종단 스크롤과 안 이음
table = (B05 / "B05_Profile_UI_Profile_TableOverlay.ts").read_text(encoding="utf-8")
assert "linkScrollLeft(main, scroll" in table
assert "syncing" not in table
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
body = mass[mass.index("function drawWindow(") :]
body = body[: body.index("\n }\n")]
assert '!open || fitted || overlay.classList.contains("is-popout") || !lastSeries' in body
assert "windowState.frame" in body and "frameOf(overlay" in body
assert "draw(lastParams, true)" in body
# 다시 셈 없음 · 총괄값 · 범례는 계열이 바뀔 때만
assert re.search(r"reuse && lastSeries\s*\? lastSeries", mass)
for gone in ("linkScrollLeft", "attachWheelHorizontalScroll", "attachScrollSync", "drawWindow"):
assert gone not in mass, gone
def test_유토는_늘_전체_맞춤_스크롤마다_안_그림():
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
assert "hideStations: true," in mass
assert "viewRange: undefined," in mass
assert "scroll.scrollLeft = 0;" in mass
assert "createMassHaulWindowState" not in mass and "scheduleMassHaulSettle" not in mass
assert "if (series !== lastSeries) emitSummary(series);" in mass
# 다시 그려도 제 자리 — 종단 값으로 되쓰는 것은 처음 · 맞춤 뒤 · 창 옮김 뒤만
assert "scroll.scrollLeft = syncedMain.scrollLeft" not in mass
render = (B05 / "B05_Profile_UI_Profile_Render.ts").read_text(encoding="utf-8")
assert "massHaul.drawWindow({" in render
assert "drawWindow" not in render and "massHaulUpdater" not in render
css = (B05 / "B05_Profile_UI_Style_MassHaul.css").read_text(encoding="utf-8")
block = css[css.index(".b05-profile__masshaul-scroll {") :]
assert "overflow-x: hidden;" in block[: block.index("}")]