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

This commit is contained in:
2026-10-01 20:36:42 +09:00
4 changed files with 513 additions and 59 deletions
@@ -410,3 +410,91 @@
font-size: 11px;
font-weight: 700;
}
/* 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 + 화살 */
.m02-dfdg__dep {
pointer-events: auto;
cursor: pointer;
}
.m02-dfdg__depwire {
fill: none;
stroke: color-mix(in srgb, var(--color-text-muted) 70%, transparent);
stroke-width: 1.5;
stroke-dasharray: 3 4;
}
.m02-dfdg__arrow {
fill: var(--color-text-muted);
}
.m02-dfdg__deptext rect {
fill: var(--color-canvas);
stroke: none;
opacity: 0.85;
}
.m02-dfdg__deptext text {
fill: var(--color-text-muted);
font-size: 10px;
}
.m02-dfdg__dep.is-strong .m02-dfdg__depwire {
stroke: var(--color-accent);
stroke-width: 2.5;
}
.m02-dfdg__dep.is-back .m02-dfdg__depwire {
stroke: var(--dfdg-back);
stroke-width: 2.5;
}
.m02-dfdg__dep.is-fwd .m02-dfdg__depwire {
stroke: var(--dfdg-fwd);
stroke-width: 2.5;
}
.m02-dfdg__dep.is-dim {
opacity: 0.25;
}
/* 쉼 — 조건이 안 맞아 이번엔 안 지나감(문제 아님) */
.m02-dfdg__link.is-rest .m02-dfdg__wire {
stroke: var(--color-text-muted);
stroke-width: 1.5;
stroke-dasharray: 2 5;
}
.m02-dfdg__restmark rect {
fill: var(--color-surface);
stroke: var(--color-text-muted);
stroke-dasharray: 2 2;
}
.m02-dfdg__restmark text {
fill: var(--color-text-muted);
font-size: 10px;
}
.m02-dfdg__box.is-rest {
opacity: 0.5;
}
/* 빈 판 안내 — 가운데 세 줄 */
.m02-dfdg__empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
padding: var(--spacing-16) var(--spacing-24);
list-style: none;
border: 1px dashed var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
pointer-events: none;
}
+99 -58
View File
@@ -14,6 +14,10 @@ import {
clampZoom,
fitView,
KIND_COLOR,
depInTrace,
follow,
depLines,
isRest,
linkLabel,
midOf,
pillsOf,
@@ -76,7 +80,15 @@ export function renderDiagram(
states.set(host, st);
const values = opts.values ?? {};
const labels = opts.labels ?? {};
const breaks = opts.breaks ?? [];
const allBreaks = opts.breaks ?? [];
/** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */
const breaks = allBreaks.filter((k) => !isRest(k));
const rests = allBreaks.filter(isRest);
const restLinks = new Set(rests.map((k) => k.link).filter(Boolean));
const restBoxes = new Set([
...rests.filter((k) => !k.link).map((k) => k.box),
...lg.links.filter((l) => restLinks.has(l.id)).map((l) => l.to.box),
]);
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
const redraw = (): void =>
renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill });
@@ -210,7 +222,8 @@ export function renderDiagram(
const node = el("div", {
className:
`m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` +
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))),
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))) +
(restBoxes.has(b.id) ? " is-rest" : ""),
attrs: {
"data-box": b.id,
tabindex: "0",
@@ -400,7 +413,7 @@ export function renderDiagram(
}),
],
}),
lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }),
view,
],
});
root.addEventListener("keydown", (ev) => {
@@ -438,19 +451,27 @@ export function renderDiagram(
);
view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault());
view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault());
if (!lg.boxes.length)
view.append(
el("ol", {
className: "m02-dfdg__empty",
children: (["Empty1", "Empty2", "Empty3"] as const).map((k) => el("li", { text: tt(k) })),
}),
);
host.replaceChildren(root);
applyView();
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 */
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 · 쉼 · 기댐 선 */
function drawLines(): void {
svg.replaceChildren();
const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => {
const at = (end: CfPort, dir: "in" | "out", side: "near" | "far" = "near") => {
const pill = world.querySelector<HTMLElement>(
`.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`,
);
if (!pill) return null;
const r = pill.getBoundingClientRect();
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
const right = (dir === "out") === (side === "near");
return toWorld(right ? r.right : r.left, r.top + r.height / 2);
};
const add = <K extends keyof SVGElementTagNameMap>(
parent: Element,
@@ -462,6 +483,33 @@ export function renderDiagram(
parent.append(n);
return n;
};
const label = (parent: Element, x: number, y: number, text: string, cls: string): void => {
const tag = add(parent, "g", { class: cls });
const box = add(tag, "rect", { rx: "4", ry: "4" });
const t = add(tag, "text", {
x: x.toFixed(1),
y: y.toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
t.textContent = text;
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
};
const defs = add(svg, "defs", {});
const arrow = add(defs, "marker", {
id: "m02-dfdg-arrow",
viewBox: "0 0 8 8",
refX: "7",
refY: "4",
markerWidth: "7",
markerHeight: "7",
orient: "auto",
});
add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" });
const mids = new Map<string, { x: number; y: number }>();
for (const l of lg.links) {
const a = at(l.from, "out");
const b = at(l.to, "in");
@@ -474,42 +522,30 @@ export function renderDiagram(
const traced = markOf((s) => s.some((x) => x.link === l.id));
const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음
const dim = !mark && st.selected !== null && !strong ? " is-dim" : "";
const rest = restLinks.has(l.id) ? " is-rest" : "";
const g = add(svg, "g", {
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}`,
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}${rest}`,
"data-link": l.id,
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
});
add(g, "path", { d, class: "m02-dfdg__hit" });
add(g, "path", { d, class: "m02-dfdg__wire" });
const mid = midOf(a, b);
mids.set(l.id, mid);
const rule = labels[l.id] ?? linkLabel(l);
if (rule) {
const tag = add(g, "g", { class: "m02-dfdg__rule" });
const box = add(tag, "rect", { rx: "4", ry: "4" });
const text = add(tag, "text", {
x: mid.x.toFixed(1),
y: mid.y.toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
text.textContent = rule;
const w = (text.getComputedTextLength?.() || rule.length * 7) + 10;
Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
}
if (rule) label(g, mid.x, mid.y, rule, "m02-dfdg__rule");
if (rest) label(g, mid.x, mid.y + (rule ? 18 : 0), tt("Rest"), "m02-dfdg__restmark");
const broken = breaks.filter((k) => k.link === l.id);
const why = [...broken, ...rests.filter((k) => k.link === l.id)]
.map((k) => k.text)
.join(" · ");
if (broken.length) {
const why = broken.map((k) => k.text).join(" · ");
const cy = (mid.y - (rule ? 18 : 0)).toFixed(1);
const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why });
add(badge, "circle", {
cx: mid.x.toFixed(1),
cy: (mid.y - (rule ? 18 : 0)).toFixed(1),
r: "8",
});
add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" });
const t = add(badge, "text", {
x: mid.x.toFixed(1),
y: (mid.y - (rule ? 18 : 0)).toFixed(1),
y: cy,
"dominant-baseline": "central",
"text-anchor": "middle",
});
@@ -518,10 +554,43 @@ export function renderDiagram(
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
const why = broken.map((k) => k.text).join(" · ");
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
});
}
// 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살
for (const dep of depLines(lg)) {
const a = at(dep.from, "out");
const b =
"link" in dep.to
? mids.get(dep.to.link)
: at({ box: dep.to.box, name: dep.to.port }, "out", "far");
if (!a || !b) continue;
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
const where = "link" in dep.to ? dep.to.link : dep.to.box;
const steps = opts.trace;
const traced =
steps?.back && depInTrace(dep, steps.back)
? " is-back"
: steps?.forward && depInTrace(dep, steps.forward)
? " is-fwd"
: steps && (steps.back?.length || steps.forward?.length)
? " is-dim"
: "";
const g = add(svg, "g", {
class: `m02-dfdg__dep${traced}${st.selected === where ? " is-strong" : ""}`,
"data-dep": dep.id,
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
});
add(g, "path", { d, class: "m02-dfdg__hit" });
add(g, "path", { d, class: "m02-dfdg__depwire", "marker-end": "url(#m02-dfdg-arrow)" });
const m = midOf(a, b);
label(g, m.x, m.y, dep.text, "m02-dfdg__deptext");
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(where);
});
}
}
const firstFit = (): void => {
if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines();
@@ -534,34 +603,6 @@ export function renderDiagram(
if (view.isConnected) firstFit();
/* 끌기 셋 — 상자 · 판 · 선 */
function follow(
ev: PointerEvent,
move: (m: PointerEvent) => void,
end: (u: PointerEvent, moved: boolean) => void,
): void {
const x0 = ev.clientX;
const y0 = ev.clientY;
let moved = false;
const mv = (m: PointerEvent): void => {
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
moved = true;
move(m);
};
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
if (moved) {
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
const swallow = (c: MouseEvent): void => c.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
}
end(u, moved);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
ev.preventDefault(); // 끌면서 글자가 잡히지 않게
const start = toWorld(ev.clientX, ev.clientY);
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_DF_Diagram_Parts.ts
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기. 그리기는 `M02_DF_Diagram.ts`.
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -15,6 +15,8 @@ import {
type CfPort,
type CfLink,
type CfStep,
elementPort,
ruleSources,
} from "./M02_DF_Model";
import type { CfBreak } from "./M02_DF_Eval";
import { ruleLabel } from "./M02_DF_Rule";
@@ -63,6 +65,13 @@ export const TEXT = {
],
Break: ["끊긴 곳", "Break"],
Back: ["온 길", "Came from"],
DepLookup: ["표 조건", "table key"],
DepGate: ["조건", "condition"],
DepMaterial: ["{f}에 {o}", "{f} {o}"],
Rest: ["쉼", "Resting"],
Empty1: ["① 「+ 입력」 으로 상자를 놓음", "① Place a box with “+ Input”"],
Empty2: ["② 알약을 끌어 다른 상자에 놓으면 선", "② Drag a pill onto another box to draw a line"],
Empty3: ["③ 선을 누르면 규칙", "③ Tap a line to set its rule"],
Forward: ["가는 길", "Goes to"],
} as const;
@@ -231,3 +240,81 @@ export const midOf = (
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
});
/* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */
/** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */
export interface DepLine {
id: string;
from: CfPort;
to: { link: string } | { box: string; port: string };
text: string;
}
export function depLines(lg: CfLogic): DepLine[] {
const out: DepLine[] = [];
for (const l of lg.links)
ruleSources(l.rule).forEach((from, i) =>
out.push({
id: `dep:${l.id}:${i}`,
from,
to: { link: l.id },
text: tt(l.rule?.kind === "lookup" ? "DepLookup" : "DepGate"),
}),
);
for (const b of lg.boxes)
for (const e of b.input?.elements ?? [])
if (e.mode === "rule" && e.rule)
out.push({
id: `dep:${b.id}:${elementPort(e)}`,
from: e.rule.from,
to: { box: b.id, port: elementPort(e) },
text: tt("DepMaterial", { f: e.rule.field, o: e.rule.op }),
});
return out;
}
/** 기댐 선이 흐름 길에 드나 — 기대는 알약과 닿는 곳(선 · 상자)이 같은 길에 있을 때 */
export function depInTrace(dep: DepLine, steps: CfStep[]): boolean {
const src = steps.some((s) => s.box === dep.from.box && s.port === dep.from.name);
const dst =
"link" in dep.to
? steps.some((s) => s.link === (dep.to as { link: string }).link)
: steps.some((s) => s.box === (dep.to as { box: string }).box);
return src && dst;
}
/** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — level 이 없으면 「통과 안 됨」 을 쉼으로 */
export const isRest = (k: CfBreak): boolean =>
((k as CfBreak & { level?: string }).level ?? (k.kind === "통과 안 됨" ? "쉼" : "문제")) === "쉼";
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */
export function follow(
ev: PointerEvent,
move: (m: PointerEvent) => void,
end: (u: PointerEvent, moved: boolean) => void,
): void {
const x0 = ev.clientX;
const y0 = ev.clientY;
let moved = false;
const mv = (m: PointerEvent): void => {
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
moved = true;
move(m);
};
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
if (moved) {
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
const swallow = (c: MouseEvent): void => c.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
}
end(u, moved);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
@@ -0,0 +1,238 @@
"""단가산출 흐름도 다이어그램 순수 부분(`M02_DF_Diagram_Parts.ts`) — 기댐 선 · 흐름 길 포함 · 쉼 가르기 · 맞춤.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(Model · Rule …)는 이어 트랜스파일 ·
화면 부품(@ui)은 빈 흉내. 견본 로직은 이 시험 안에 둠.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Diagram_Parts.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
const cache = {};
function load(f) {
if (cache[f]) return cache[f].exports;
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = (cache[f] = { exports: {} });
const req = (id) => {
if (id.startsWith(".")) {
const p = path.resolve(path.dirname(f), id);
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
}
return stub;
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const m = load(file);
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "deps") return m.depLines(job.lg).map((d) => ({ id: d.id, from: d.from, to: d.to }));
if (job.op === "inTrace") return m.depLines(job.lg).map((d) => m.depInTrace(d, job.steps));
if (job.op === "rest") return job.breaks.map((b) => m.isRest(b));
if (job.op === "fit") return m.fitView(job.rects, job.view);
if (job.op === "value") return job.values.map((v) => m.showValue(v));
return null;
});
process.stdout.write(JSON.stringify(out));
"""
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def port(box, name):
return {"box": box, "name": name}
# 견본 — 관경 → 흄관 재료(조건 찾기) · 지반 → 조건 선 · 뒷길이 → 표 조건
LOGIC = {
"key": None,
"name": "견본",
"resultUnit": "원/m",
"userElements": [],
"boxes": [
{
"id": "v",
"kind": "input",
"name": "설계 값",
"color": "",
"unit": "-",
"x": 0,
"y": 0,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{"name": "관경", "type": "choice", "unit": "㎜", "example": 600},
{"name": "지반", "type": "choice", "unit": "-", "example": "암"},
{"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30},
],
},
},
{
"id": "m",
"kind": "input",
"name": "재료",
"color": "",
"unit": "-",
"x": 0,
"y": 200,
"input": {
"mode": "group",
"group": "재료",
"variables": [],
"elements": [
{
"ref": "",
"name": "흄관",
"spec": "",
"unit": "본",
"mode": "rule",
"slot": "흄관",
"rule": {"field": "규격", "op": "포함", "from": port("v", "관경")},
},
{"ref": "MT1", "name": "모래", "spec": "", "unit": "㎥", "mode": "fixed"},
],
},
},
{
"id": "t",
"kind": "input",
"name": "품 표",
"color": "",
"unit": "-",
"x": 0,
"y": 400,
"input": {
"mode": "group",
"group": "소요량",
"elements": [],
"variables": [],
"table": {"key": "QF1"},
},
},
{
"id": "f",
"kind": "function",
"name": "셈",
"color": "",
"unit": "-",
"x": 300,
"y": 100,
"func": {
"sheet": {},
"inputs": [{"port": "품", "cell": "B1"}, {"port": "흄관", "cell": "B2"}],
"variables": [],
"output": {"cell": "B9", "name": "금액", "unit": "원"},
},
},
],
"links": [
{
"id": "l1",
"from": port("t", "표"),
"to": port("f", "품"),
"rule": {
"kind": "lookup",
"conds": [{"col": "뒷길이", "from": port("v", "뒷길이")}],
"col": "석공",
},
},
{
"id": "l2",
"from": port("m", "흄관"),
"to": port("f", "흄관"),
"rule": {"kind": "gate", "from": port("v", "지반"), "op": "=", "value": "암"},
},
{"id": "l3", "from": port("v", "관경"), "to": port("f", "흄관")},
],
}
def test_기댐_선_셋():
[deps] = run([{"op": "deps", "lg": LOGIC}])
got = sorted((d["from"]["name"], json.dumps(d["to"], ensure_ascii=False)) for d in deps)
assert got == sorted(
[
("뒷길이", json.dumps({"link": "l1"})),
("지반", json.dumps({"link": "l2"})),
("관경", json.dumps({"box": "m", "port": "흄관"}, ensure_ascii=False)),
]
)
def test_기댐_선이_흐름_길에_듦():
steps = [
{"box": "v", "port": "지반", "dir": "out"},
{"box": "f", "port": "흄관", "dir": "in", "link": "l2"},
{"box": "m", "port": "흄관", "dir": "out"},
]
[marks] = run([{"op": "inTrace", "lg": LOGIC, "steps": steps}])
# 차례: l1 표 조건(뒷길이) · l2 조건(지반) · 재료 조건 찾기(관경)
assert marks == [False, True, False]
def test_쉼_가르기():
[rest] = run(
[
{
"op": "rest",
"breaks": [
{"kind": "통과 안 됨", "box": "f", "link": "l2", "text": "지반이 암이 아님"},
{"kind": "빈 받는 자리", "box": "f", "port": "품", "text": "비어 있음"},
{"kind": "통과 안 됨", "box": "f", "level": "문제", "text": "level 이 먼저"},
{"kind": "조건 겹침", "box": "f", "level": "쉼", "text": "level 이 먼저"},
],
}
]
)
assert rest == [True, False, False, True]
def test_한눈에_90_아래로_안_내림과_알약_값():
fit, values = run(
[
{
"op": "fit",
"rects": [{"x": 0, "y": 0, "w": 3000, "h": 200}],
"view": {"w": 1000, "h": 600},
},
{
"op": "value",
"values": [
97882.512,
268908,
0.3640,
"깬돌",
[["뒷길이", "석공"], [25, 0.15], [30, 0.22]],
],
},
]
)
assert fit["zoom"] == 0.9
assert values == ["97,882.512", "268,908", "0.364", "깬돌", "· 2줄"]