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:
@@ -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]?.서식;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 칸 바깥 값을 못 받음 없는 직종"]}
|
||||
|
||||
Reference in New Issue
Block a user