diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 68c8575e1..9d3af662c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -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 { @@ -291,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(); @@ -363,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; } @@ -407,21 +422,25 @@ export function createRouteMassHaulPanel( // (354-2 · 계산 코드는 공용에 남김). // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). - const series: MassHaulSeries[] = computeMassHaulSeries( - params.longitudinal, - params.crossSections, - context.conversion, - context.naturalSpoilMinSlope, - ["cross"], - ); + // 스크롤로 창만 바뀐 그리기(`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)); @@ -491,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, ); @@ -533,6 +558,7 @@ export function createRouteMassHaulPanel( context = next; }, attachScrollSync, - draw, + draw: (params) => draw(params), + drawWindow, }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index dc5f5ba8b..d0a4fad59 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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 } }, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index e206a08f0..7c2d15b89 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index f1298db83..cdbfef24f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -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(); + 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; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ diff --git a/resources/tester/helper_354_3_scroll_echo.cjs b/resources/tester/helper_354_3_scroll_echo.cjs new file mode 100644 index 000000000..e781836b9 --- /dev/null +++ b/resources/tester/helper_354_3_scroll_echo.cjs @@ -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)); diff --git a/resources/tester/test_354_3_scroll_echo.py b/resources/tester/test_354_3_scroll_echo.py new file mode 100644 index 000000000..869f9df34 --- /dev/null +++ b/resources/tester/test_354_3_scroll_echo.py @@ -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