feat(m01-sl): 시트 로직 상자 이름 바꾸기 · 바깥 값 약속

- renameNode — 엔진 시트 이름 명령으로 시트 이름 · 그 시트를 가리키던 선 수식이 따라감 · 1~31 글자 · 못 쓰는 글자 · 같은 이름은 막고 까닭
- 상자 더하기 이름도 시트 이름 규칙대로 고쳐 만듦(못 쓰는 글자 → · · 31 글자)
- SlSource(서버 바깥 값 물음 한 줄) · SlField · SlOutside.id(상자 id!칸) · source · SlNode.source
- 표 범위 칸 약속 RANGE_LOW · RANGE_HIGH · rangeHead(이름) = 「이름·아래」 「이름·위」
- answersToValues — 서버 답(id · ok · value · rows) → 넣을 값 · 못 받은 것은 쉬운 말
- 시험 13개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
2026-10-01 13:07:56 +09:00
co-authored by Claude Opus 5.5
parent e242183fed
commit ef0b4ae385
3 changed files with 190 additions and 12 deletions
+48 -2
View File
@@ -9,7 +9,13 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { nodeSheet, type SheetLogic, type SlNode, type SlOutside } from "./M01_SL_Model";
import {
nodeSheet,
type SheetLogic,
type SlNode,
type SlOutside,
type SlSource,
} from "./M01_SL_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */
export type SlValue = number | string | boolean | null;
@@ -48,6 +54,11 @@ const TEXT = {
"Outside value 「{pill}」 of 「{box}」 not filled yet",
],
NoResult: ["결과 상자가 없음", "There is no result box"],
/** {cell} {why} */
NoAnswer: [
"{cell} 칸 바깥 값을 못 받음 {why}",
"Couldn't get the outside value for {cell} {why}",
],
NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"],
} as const;
@@ -63,13 +74,48 @@ const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =
export function outsideCells(sl: SheetLogic): SlOutside[] {
const kindOf = (n: SlNode): SlOutside["kind"] =>
n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price";
const sourceOf = (n: SlNode): SlSource | undefined =>
n.source ??
(n.ref && n.kind === "table" ? { kind: "표", table: n.ref } : undefined) ??
(n.ref && n.kind === "logic" ? { kind: "로직", key: n.ref, inputs: {} } : undefined);
return sl.nodes.flatMap((n) =>
[...n.pillsIn, ...n.pillsOut]
.filter((p) => p.outside)
.map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })),
.map((p) => {
const source = sourceOf(n);
const at = { id: `${n.id}!${p.cell}`, node: n.id, cell: p.cell, kind: kindOf(n) };
return { ...at, ref: n.ref ?? "", ...(source ? { source } : {}) };
}),
);
}
/** 서버 바깥 값 답 한 줄(`/api/m01/sl/outside`) */
export interface SlOutsideAnswer {
id: string;
ok: boolean;
value?: number | null;
rows?: (string | number | null)[][];
reason?: string;
}
/** 서버 답 → 넣을 값 · 못 받은 것은 쉬운 말 */
export function answersToValues(
cells: SlOutside[],
answers: SlOutsideAnswer[],
): { values: SlOutsideValue[]; errors: string[] } {
const values: SlOutsideValue[] = [];
const errors: string[] = [];
for (const c of cells) {
const a = answers.find((x) => x.id === c.id);
if (!a || !a.ok) {
errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" }));
continue;
}
values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null });
}
return { values, errors };
}
/** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */
function putValue(칸: Record<string, Cell>, cell: string, value: SlValue): void {
const style = 칸[cell]?.서식;
+68 -7
View File
@@ -9,6 +9,7 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { applyCommand } from "../../A00_Common/spreadsheet/spreadsheet_commands";
import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */
@@ -34,6 +35,8 @@ export interface SlNode {
pillsOut: SlPill[];
/** 표 키 · 요소 · 기존 로직 키 · 내 표 id */
ref?: string;
/** 바깥 값을 어디서 받나(서버 `/api/m01/sl/outside` 물음 모양) — 없으면 kind · ref 로 짐작 */
source?: SlSource;
/** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */
stage: number;
}
@@ -45,14 +48,33 @@ export interface SlEdge {
to: { node: string; pill: string };
}
/** 바깥 값 칸 */
/** 돈 로직 결과에서 꺼낼 칸 */
export type SlField = "노무비" | "재료비" | "경비" | "계";
/** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`)
* · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */
export type SlSource =
| { kind: "표"; table: string }
| { kind: "노임" | "자재"; key: string }
| { kind: "장비"; element: string; inputs: Record<string, unknown>; field?: SlField }
| { kind: "로직"; key: string; inputs: Record<string, unknown>; field?: SlField };
/** 바깥 값 칸 — id = 「상자 id!칸」(서버 답 id 와 짝) */
export interface SlOutside {
id: string;
node: string;
cell: string;
kind: "table" | "price" | "logic";
ref: string;
source?: SlSource;
}
/** 표 범위 조건 · 범위 값 — 한 칸을 두 칸(아래 · 위)으로 펼친 머리 이름 */
export const RANGE_LOW = "·아래";
export const RANGE_HIGH = "·위";
export const rangeHead = (name: string): [string, string] => [name + RANGE_LOW, name + RANGE_HIGH];
/** 내 표 — 품셈에 없는 표(사용자가 만듦) */
export interface SlUserTable {
id: string;
@@ -100,6 +122,13 @@ const TEXT = {
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
Twice: ["이미 이어져 있음", "Already linked"],
NameLen: ["이름은 1~31 글자", "Name must be 1-31 characters"],
NameChar: [
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
"Name can't use [ ] : * ? / \\ or a leading/trailing '",
],
NameSame: ["같은 이름 상자가 있음", "A box with this name exists"],
NoBox: ["상자가 없음", "No such box"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
@@ -130,12 +159,25 @@ function nextId(prefix: string, ids: string[]): string {
return `${prefix}${Math.max(0, ...nums) + 1}`;
}
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」) */
function uniqueName(book: Workbook, name: string): string {
const base = name.trim() || "상자";
const used = new Set(book.시트.map((s) => s.이름));
if (!used.has(base)) return base;
for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`;
/** 시트 이름 규칙(엔진 `checkSheetName`) — 1~31 글자 · [ ] : * ? / \\ 와 앞뒤 ' 못 씀 */
const BAD_CHARS = /[[\]:*?/\\]/g;
const cleanName = (name: string): string =>
name
.replace(BAD_CHARS, "·")
.replace(/^'+|'+$/g, "")
.trim()
.slice(0, 31) || "상자";
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」 · 대소문자 안 가림) */
function uniqueName(book: Workbook, name: string, self?: string): string {
const base = cleanName(name);
const used = new Set(book.시트.filter((s) => s.id !== self).map((s) => s.이름.toLowerCase()));
if (!used.has(base.toLowerCase())) return base;
for (let n = 2; ; n++) {
const tail = ` (${n})`;
const next = base.slice(0, 31 - tail.length) + tail;
if (!used.has(next.toLowerCase())) return next;
}
}
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => {
@@ -435,3 +477,22 @@ export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null {
const node = nodeOf(sl, nodeId);
return node ? sheetOf(sl, node) : null;
}
/** 상자 이름 바꾸기 — 시트 이름도(엔진 시트 이름 명령) · 그 시트를 가리키던 선 수식 · 다른 수식도 따라감 */
export function renameNode(
sl: SheetLogic,
nodeId: string,
name: string,
): { sl: SheetLogic; why: string | null } {
const node = nodeOf(sl, nodeId);
if (!node) return { sl, why: tl("NoBox") };
const next = name.trim();
if (!next || next.length > 31) return { sl, why: tl("NameLen") };
if (/[[\]:*?/\\]/.test(next) || next.startsWith("'") || next.endsWith("'"))
return { sl, why: tl("NameChar") };
if (uniqueName(sl.workbook, next, node.sheetId) !== next) return { sl, why: tl("NameSame") };
const out = clone(sl);
applyCommand(out.workbook, { 종류: "시트이름", 시트: node.sheetId, 이름: next });
nodeOf(out, nodeId)!.name = next;
return { sl: out, why: null };
}
+74 -3
View File
@@ -202,9 +202,23 @@ def test_바깥값_칸목록과_표_덩어리() -> None:
)
cells, e1, f2, e3, f3, lg, check = got
assert cells == [
{"node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"},
{"node": "n4", "cell": "E1", "kind": "table", "ref": "QF000421"},
{"node": "n5", "cell": "B2", "kind": "logic", "ref": "GF000114"},
{"id": "n2!B3", "node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"},
{
"id": "n4!E1",
"node": "n4",
"cell": "E1",
"kind": "table",
"ref": "QF000421",
"source": {"kind": "표", "table": "QF000421"},
},
{
"id": "n5!B2",
"node": "n5",
"cell": "B2",
"kind": "logic",
"ref": "GF000114",
"source": {"kind": "로직", "key": "GF000114", "inputs": {}},
},
]
assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None)
assert lg == {"결과": 12.5}
@@ -261,3 +275,60 @@ def test_화면_글에_금지_낱말_없음() -> None:
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
assert bad not in text, (name, bad, text)
def test_상자_이름_바꾸기_수식이_따라감() -> None:
got = _run(
TWO + "const a = M.renameNode(sl, stone.id, '석공 (돌쌓기)');\n"
"const tries = [M.renameNode(sl, stone.id, '보통인부').why, M.renameNode(sl, stone.id, '').why,"
" M.renameNode(sl, stone.id, '가:나').why, M.renameNode(sl, 'n99', '무엇').why,"
" M.renameNode(sl, stone.id, 'x'.repeat(32)).why];\n"
"out([a.why, a.sl.nodes.find(n => n.id === stone.id).name, sheet(a.sl, stone.id).이름,"
" sheet(a.sl, res.id).칸.B2, E.evalLogic(a.sl).result.계, tries, sl.nodes[3].name]);"
)
why, name, sheet_name, cell, total, tries, before = got
assert why is None and name == sheet_name == "석공 (돌쌓기)"
assert cell == {"식": "'석공 (돌쌓기)'!B5+보통인부!B5"} and total == 72000
assert tries == [
"같은 이름 상자가 있음",
"이름은 1~31 글자",
"이름에 못 쓰는 글자 — [ ] : * ? / \ 와 앞뒤 '",
"상자가 없음",
"이름은 1~31 글자",
]
assert before == "석공"
def test_상자_이름_못쓰는_글자는_고쳐_만듦() -> None:
got = _run(
"let sl = M.newLogic(); let r = M.addNode(sl, 'input', '가:나/다'); sl = r.sl;"
" r = M.addNode(sl, 'input', 'x'.repeat(40)); sl = r.sl; r = M.addNode(sl, 'input', 'X'.repeat(40)); sl = r.sl;"
" out(sl.workbook.시트.map(s => s.이름));"
)
assert got == ["가·나·다", "x" * 31, "X" * 27 + " (2)"]
def test_바깥값_답을_넣을값으로_범위머리() -> None:
got = _run(
ONE + "r = M.addNode(sl, 'table', '증가율 표'); sl = r.sl; const tb = r.node;\n"
"sl.nodes.find(n => n.id === tb.id).source = { kind: '표', table: 'QF000422' };\n"
"sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n"
"const cells = E.outsideCells(sl);\n"
"const head = M.rangeHead('높이');\n"
"const got = E.answersToValues(cells, [\n"
" { id: 'n2!B3', ok: true, value: 172068 },\n"
" { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n"
"]);\n"
"const s2 = E.applyOutside(sl, got.values);\n"
"out([head, got, sheet(s2, tb.id).칸.F1, sheet(s2, tb.id).칸.G2, E.evalLogic(s2).result.계]);"
)
head, got_values, f1, g2, total = got
assert head == ["높이·아래", "높이·위"]
assert got_values["errors"] == []
assert got_values["values"][0] == {"node": "n2", "cell": "B3", "value": 172068}
assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034)
missing = _run(
ONE
+ "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B3', ok: false, reason: '없는 직종' }]));"
)
assert missing == {"values": [], "errors": ["B3 칸 바깥 값을 못 받음 없는 직종"]}