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

This commit is contained in:
2026-10-16 20:50:07 +09:00
8 changed files with 432 additions and 55 deletions
+70 -39
View File
@@ -6,7 +6,7 @@
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
* 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로
* (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고,
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준만 · 종단 기준 숨김 354-2)를 쓰고,
* 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다.
*
* 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라
@@ -56,7 +56,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import "@util/common_util_mass_haul.css";
import "./B05_Profile_UI_Style_MassHaul.css";
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
import { frameOf } from "../common_util/common_util_view_window";
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
@@ -165,6 +165,8 @@ export interface RouteMassHaulPanel {
attachScrollSync(main: HTMLElement): void;
/** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */
draw(params: RouteMassHaulDrawParams): void;
/** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */
drawWindow(params: RouteMassHaulDrawParams): void;
}
function readVisible(): Set<string> {
@@ -173,8 +175,11 @@ function readVisible(): Set<string> {
if (!raw) return new Set(DEFAULT_VISIBLE);
const parsed = JSON.parse(raw) as unknown;
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙).
// 종단 기준은 안 그림(354-2) — 저장된 범례가 종단이면 횡단으로 맞춘다.
return Array.isArray(parsed)
? normalizeVisibleBasis(new Set(parsed.map(String)))
? normalizeVisibleBasis(
new Set(parsed.map((key) => (key === "longitudinal" ? "cross" : String(key)))),
)
: new Set(DEFAULT_VISIBLE);
} catch {
return new Set(DEFAULT_VISIBLE);
@@ -288,32 +293,29 @@ export function createRouteMassHaulPanel(
onChanged();
}
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다.
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3).
// 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 ·
// 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태.
let syncing = false;
let fitted = false;
function mirror(from: HTMLElement, to: HTMLElement): void {
from.addEventListener("scroll", () => {
if (syncing || fitted) return;
syncing = true;
to.scrollLeft = from.scrollLeft;
syncing = false;
});
}
let syncedMain: HTMLElement | null = null;
let putScroll: ((to: HTMLElement, left: number) => void) | null = null;
/** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */
let syncedIn: Document | null = null;
function attachScrollSync(main: HTMLElement): void {
if (syncedMain === main) return;
syncedMain = main;
mirror(main, scroll);
mirror(scroll, main);
putScroll = linkScrollLeft(main, scroll, () => fitted);
}
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
let drawnOnce = false;
/** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */
let lastSeries: MassHaulSeries[] | null = null;
function note(message: string): void {
drawnOnce = false;
lastSeries = null;
syncedIn = null;
pendingChip.hidden = true;
bar.replaceChildren();
scroll.replaceChildren();
@@ -360,18 +362,34 @@ export function createRouteMassHaulPanel(
);
}
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
function draw(params: RouteMassHaulDrawParams): void {
pipelineWork("유토곡선", () => drawNow(params));
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */
function draw(params: RouteMassHaulDrawParams, reuse = false): void {
pipelineWork("유토곡선", () => drawNow(params, reuse));
}
function drawNow(params: RouteMassHaulDrawParams): void {
/** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) ·
* 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */
let windowDirty = false;
function drawWindow(params: RouteMassHaulDrawParams): void {
if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return;
lastParams = params;
// 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림
if (windowDirty || windowState.frame) return;
windowDirty = true;
frameOf(overlay, () => {
if (windowDirty && lastParams) draw(lastParams, true);
});
}
function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void {
lastParams = params;
windowDirty = false;
overlay.hidden = !open;
syncHandlePosition();
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
// 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번).
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
if (!open) {
syncedIn = null;
reportSummary(params);
return;
}
@@ -400,23 +418,29 @@ export function createRouteMassHaulPanel(
.map((chainage) => `${chainage.toFixed(2)} m`)
.join(" · ")}${stale.length > 5 ? " …" : ""}`
: "다시 계산 중…";
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
// B06과 같은 계산 — 횡단 기준(정식)만 낸다. 종단 기준은 안 셈 · 안 그림 · 범례 단추 없음
// (354-2 · 계산 코드는 공용에 남김).
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
const series: MassHaulSeries[] = computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
);
// 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3).
const series: MassHaulSeries[] =
reuse && lastSeries
? lastSeries
: computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
["cross"],
);
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
emitSummary(series);
if (series !== lastSeries) emitSummary(series);
lastSeries = series;
if (!series.length) {
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
return;
}
bar.replaceChildren();
legendLayer.replaceChildren();
const visible = readVisible();
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
const banded = series.find((entry) => visible.has(entry.key));
@@ -486,24 +510,30 @@ export function createRouteMassHaulPanel(
wrap.append(chart);
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
// 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이
// 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다.
const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null;
scroll.replaceChildren(wrap);
// 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다.
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
if (fitAll) scroll.scrollLeft = 0;
else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft;
else if (keepLeft !== null) scroll.scrollLeft = keepLeft;
else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft);
syncedIn = fitAll ? null : scroll.ownerDocument;
legendLayer.append(
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
}),
);
// 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다.
if (!reuse || !legendLayer.firstChild)
legendLayer.replaceChildren(
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
}),
);
drawnOnce = true;
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
scheduleMassHaulSettle(
windowState,
() => {
if (lastParams) draw(lastParams);
if (lastParams) draw(lastParams, true);
},
overlay,
);
@@ -528,6 +558,7 @@ export function createRouteMassHaulPanel(
context = next;
},
attachScrollSync,
draw,
draw: (params) => draw(params),
drawWindow,
};
}
+6 -3
View File
@@ -64,7 +64,10 @@ export interface ProfileRenderContext {
* 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */
onStaleDesigns?: (key: string) => boolean;
callbacks: RouteProfilePanelCallbacks | undefined;
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
massHaul: {
draw: (params: RouteMassHaulDrawParams) => void;
drawWindow: (params: RouteMassHaulDrawParams) => void;
};
tableOverlay: {
isOpen: () => boolean;
contentHeight: () => number;
@@ -452,9 +455,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
// 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이
// 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는
// 값보다 크다(요소 수가 종단의 1/3).
// 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3).
massHaulUpdater = (fromM, toM) => {
massHaul.draw({
massHaul.drawWindow({
...massHaulParams,
axis: { ...massHaulParams.axis, viewRange: { fromM, toM } },
});
@@ -14,7 +14,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout";
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
import { frameOf } from "../common_util/common_util_view_window";
/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
@@ -125,16 +125,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
overlay.hidden = !open;
handleControl.root.addEventListener("click", () => applyOpen(!open));
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일).
let syncing = false;
function mirror(from: HTMLElement, to: HTMLElement): void {
from.addEventListener("scroll", () => {
if (syncing) return;
syncing = true;
to.scrollLeft = from.scrollLeft;
syncing = false;
});
}
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(유토곡선과 같음 · 354-3).
let syncedMain: HTMLElement | null = null;
return {
@@ -161,8 +152,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
attachScrollSync(main) {
if (syncedMain === main) return;
syncedMain = main;
mirror(main, scroll);
mirror(scroll, main);
linkScrollLeft(main, scroll);
},
setBottomOffset(px) {
bottomOffset = px;
@@ -39,6 +39,35 @@ const BASE_FRAME_MS = 1000 / 60;
* 쓰면 한 번에 목표까지 튄다. */
const MAX_FRAME_MS = 64;
/**
* 두 스크롤러의 가로 자리를 서로 따라가게 잇는다 — 종단 그래프 ↔ 유토곡선 · 표(354-3).
* 써 넣은 값으로 **다음 틀에** 오는 scroll(메아리)은 무시한다. 동기 플래그로 막던 예전 길은
* 메아리가 한 틀 전 값을 되써 앞서 간 쪽을 뒤로 튀게 했고, 그 튐이 휠 이동을 접어 끊겼다.
* `paused` 가 참이면 안 따라감. 돌려주는 `put` = 따라가는 쪽에 손으로 써 넣을 때(메아리 표시).
*/
export function linkScrollLeft(
a: HTMLElement,
b: HTMLElement,
paused: () => boolean = () => false,
): (to: HTMLElement, left: number) => void {
const echo = new Map<HTMLElement, number>();
const put = (to: HTMLElement, left: number): void => {
const before = to.scrollLeft;
to.scrollLeft = left;
if (to.scrollLeft !== before) echo.set(to, to.scrollLeft);
};
const follow = (from: HTMLElement, to: HTMLElement): void =>
from.addEventListener("scroll", () => {
const wrote = echo.get(from);
echo.delete(from);
if (wrote !== undefined && Math.abs(from.scrollLeft - wrote) < 1) return;
if (!paused() && to.scrollLeft !== from.scrollLeft) put(to, from.scrollLeft);
});
follow(a, b);
follow(b, a);
return put;
}
export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
let target = -1;
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
@@ -0,0 +1,57 @@
/* 354-2 종단 기준 숨김 — 진짜 `readVisible`(B05 유토곡선) · `normalizeVisibleBasis` ·
* `computeMassHaulSeries`(공용)를 잘라 돌려 JSON 으로 냄.
* · 저장된 범례 = 종단 → 횡단으로 맞춤 · 분배 켬은 그대로
* · 기준 ["cross"] = 횡단 계열 하나 · 기본(묶음 단계) = 두 기준 그대로 */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8");
const transpile = (code) =>
ts.transpileModule(code, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const block = (src, head) => {
const at = src.indexOf(head);
if (at < 0) throw new Error(`없음: ${head}`);
return src.slice(at, src.indexOf("\n}", at) + 2).replace(/^export /, "");
};
const util = read("common_util", "common_util_mass_haul.ts");
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"];',
block(util, "export function normalizeVisibleBasis("),
block(mass, "function readVisible(): Set<string> {"),
block(util, "export function computeMassHaulSeries("),
].join("\n");
const api = new Function(
"readStateRaw",
"designedSections",
"crossAreaSamples",
"longitudinalAreaSamples",
"integrate",
`${transpile(src)}; return { readVisible, computeMassHaulSeries };`,
)(
() => stored,
(sections) => sections,
() => [{ basis: "cross" }],
() => [{ basis: "longitudinal" }],
(samples) => ({ from: samples[0].basis }),
);
const visible = (raw) => ((stored = raw), [...api.readVisible()].sort());
process.stdout.write(
JSON.stringify({
longitudinal: visible('["longitudinal"]'),
longitudinalBalance: visible('["longitudinal","balance"]'),
cross: visible('["cross"]'),
empty: visible(null),
broken: visible("{"),
crossOnly: api.computeMassHaulSeries({}, [], {}, undefined, ["cross"]).map((s) => s.key),
both: api.computeMassHaulSeries({}, [], {}).map((s) => s.key),
}),
);
@@ -0,0 +1,121 @@
/* 354-3 스크롤 동기화 메아리 재현 — 진짜 휠 이동(`attachWheelHorizontalScroll`)과 진짜 잇기
* (`linkScrollLeft`)를 가짜 창 · 가짜 스크롤러 위에서 돌려 JSON 으로 냄.
* 가짜 창은 브라우저 차례 그대로 — scrollLeft 가 바뀌면 scroll 은 **다음 틀**에 오고(틀 앞머리),
* 그 뒤 화면 틀 콜백이 돎. 옛 잇기(동기 플래그)와 새 잇기를 같은 휠로 견줌.
* 경우 넷 = 표 켬/끔 × 분배(유토 전체 맞춤) 켬/끔 · 휠을 굴리는 쪽 = 종단 · 유토 둘. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const js = ts.transpileModule(
fs.readFileSync(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), "utf8"),
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } },
).outputText;
function fakeWindow() {
const win = { now: 0, frames: [], seq: 0, pending: new Set() };
win.requestAnimationFrame = (fn) => (win.frames.push({ id: ++win.seq, fn }), win.seq);
win.cancelAnimationFrame = (id) => (win.frames = win.frames.filter((f) => f.id !== id));
win.getSelection = () => ({ removeAllRanges() {} });
win.frame = () => {
win.now += 16;
const targets = [...win.pending];
win.pending.clear();
targets.forEach((el) => el.fire("scroll"));
const due = win.frames;
win.frames = [];
due.forEach((f) => f.fn(win.now));
};
return win;
}
function scroller(win, name, { hidden = false, scrollWidth = 6000 } = {}) {
const calls = {};
let left = 0;
const el = {
name,
ownerDocument: { defaultView: win },
clientWidth: hidden ? 0 : 1000,
scrollWidth: hidden ? 0 : scrollWidth,
scrollTop: 0,
style: {},
addEventListener: (type, fn) => (calls[type] ??= []).push(fn),
removeEventListener() {},
hasPointerCapture: () => false,
fire: (type, ev = {}) => {
const e = { preventDefault() {}, ...ev };
(calls[type] ?? []).forEach((fn) => fn(e));
},
};
Object.defineProperty(el, "scrollLeft", {
get: () => left,
set: (v) => {
const next = Math.min(Math.max(0, v), Math.max(0, el.scrollWidth - el.clientWidth));
if (next !== left) win.pending.add(el);
left = next;
},
});
return el;
}
/** 옛 잇기 — 유토곡선 · 표가 각자 들고 있던 동기 플래그 꼴 그대로. */
function oldLink(a, b, paused = () => false) {
let syncing = false;
const mirror = (from, to) =>
from.addEventListener("scroll", () => {
if (syncing || paused()) return;
syncing = true;
to.scrollLeft = from.scrollLeft;
syncing = false;
});
mirror(a, b);
mirror(b, a);
}
function run(kind, { table, balance, driver }) {
const win = fakeWindow();
const wheel = { exports: {} };
new Function("exports", "module", "require", "window", js)(
wheel.exports,
wheel,
() => ({ viewOf: () => win }),
win,
);
const body = scroller(win, "body");
// 분배 켬 = 유토곡선 전체 맞춤(스크롤 없음) · 잇기는 멈춤
const mass = scroller(win, "mass", { scrollWidth: balance ? 1000 : 6000 });
const tableEl = scroller(win, "table", { hidden: !table });
const link = kind === "old" ? oldLink : wheel.exports.linkScrollLeft;
link(body, mass, () => balance);
link(body, tableEl);
const lead = driver === "mass" && !balance ? mass : body;
[body, mass, tableEl].forEach((el) => wheel.exports.attachWheelHorizontalScroll(el));
const trace = [];
for (let i = 0; i < 60; i += 1) {
if (i < 20 && i % 2 === 0) lead.fire("wheel", { deltaY: 120 });
win.frame();
trace.push(body.scrollLeft);
}
let back = 0;
for (let i = 1; i < trace.length; i += 1) if (trace[i] < trace[i - 1] - 0.01) back += 1;
return {
back,
end: Math.round(body.scrollLeft),
lead: Math.round(lead.scrollLeft),
mass: Math.round(mass.scrollLeft),
table: Math.round(tableEl.scrollLeft),
};
}
const out = {};
for (const kind of ["old", "new"])
for (const driver of ["body", "mass"])
for (const table of [true, false])
for (const balance of [false, true])
out[`${kind}|${driver}|table=${table}|balance=${balance}`] = run(kind, {
table,
balance,
driver,
});
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""354-2 유토곡선 종단 기준 숨김 — 안 셈 · 안 그림 · 범례 단추 없음(코드 남김).
- 그리기 = 횡단 기준만(`["cross"]`) · 범례는 낸 계열마다라 종단 단추가 안 섬.
- 저장된 범례가 종단(`longitudinal`)이면 횡단으로 맞춤 · 분배 켬은 그대로.
- 계산 묶음 단계(MassHaul_Stage)는 두 기준 그대로 · 공용 계산 코드 남김.
`helper_354_2_masshaul_cross_only.cjs` 가 진짜 TS 를 잘라 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_354_2_masshaul_cross_only.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_저장된_종단_범례는_횡단으로(r):
assert r["longitudinal"] == ["cross"]
assert r["longitudinalBalance"] == ["balance", "cross"]
assert r["cross"] == ["cross"]
assert r["empty"] == ["cross"] and r["broken"] == ["cross"]
def test_기준_횡단만_계산(r):
assert r["crossOnly"] == ["cross"]
assert r["both"] == ["cross", "longitudinal"] # 공용 기본값 = 묶음 단계 그대로
def test_그리기는_횡단만_부름():
mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S)
assert calls and all('["cross"]' in call for call in calls)
def test_묶음_단계와_공용_코드는_그대로():
stage = (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").read_text(encoding="utf-8")
assert '["cross"]' not in stage
util = (ROOT / "common_util/common_util_mass_haul.ts").read_text(encoding="utf-8")
assert 'const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];' in util
assert "longitudinalAreaSamples(longitudinal, sections)" in util
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""354-3 종단 그래프 스크롤 끊김 — 동기화 메아리 · 스크롤마다 유토 다시 셈.
- 메아리: scroll 은 다음 틀에 옴 → 동기 플래그로 못 막아 한 틀 전 값을 되씀 → 뒤로 튐 →
휠 이동이 접힘. 공용 `linkScrollLeft`(Wheel.ts)가 써 넣은 값으로 온 scroll 을 무시.
가짜 창(다음 틀 scroll) 재현 — 옛 잇기는 뒤로 튐 · 멈춤, 새 잇기는 4 경우 × 휠 쪽 둘 모두 튐 0.
- 유토 스크롤 그리기 = 열림 · 분배 맞춤 아님 · 붙음일 때만 · 한 틀 한 번 · 다시 셈 없음.
`helper_354_3_scroll_echo.cjs` 가 진짜 휠 · 잇기 TS 를 가짜 창 위에서 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
B05 = Path(HERE).parents[1] / "B05_Profile"
CASES = [
f"{driver}|table={table}|balance={balance}"
for driver in ("body", "mass")
for table in ("true", "false")
for balance in ("false", "true")
]
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_354_3_scroll_echo.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_옛_잇기는_메아리로_뒤로_튐(r):
# 표 · 분배 끔(사용자 보고 경우)에서 휠 다섯 칸(1,200px) 중 210px 에서 멈춤
assert r["old|body|table=false|balance=false"]["back"] > 0
assert r["old|body|table=false|balance=false"]["end"] < 1200
# 분배 켜고 표 끔 = 잇기 없음 — 사용자 관찰(분배 켜면 매끄러움)과 같음
assert r["old|body|table=false|balance=true"] == {
"back": 0,
"end": 1200,
"lead": 1200,
"mass": 0,
"table": 0,
}
@pytest.mark.parametrize("case", CASES)
def test_새_잇기는_뒤로_튐_0_끝까지(r, case):
got = r[f"new|{case}"]
assert got["back"] == 0
assert got["end"] == 1200 and got["lead"] == 1200
table, balance = "table=true" in case, "balance=true" in case
assert got["table"] == (1200 if table else 0)
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_유토_스크롤_그리기는_창_따를_때만_한_틀_한_번():
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)
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