From 610ac8564face784025cfa883b42167e7b8f847c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:29:17 +0900 Subject: [PATCH] =?UTF-8?q?fix(spreadsheet):=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=ED=92=80=EC=9D=B4=20=E2=80=94=20=3DSUM(?= =?UTF-8?q?=EA=B0=92=EB=93=A4)=20=EC=9D=B4=20=EB=B2=94=EC=9C=84=EB=A1=9C?= =?UTF-8?q?=20=ED=92=80=EB=A6=BC=20=C2=B7=20=ED=96=89=EC=97=B4=20=EB=84=A3?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20=EB=95=8C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B2=94=EC=9C=84=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔진: 식 나무의 이름을 Sheet.이름정의 범위로 바꿔 풂(자기 시트 먼저 · 없으면 다른 시트 · 없는 이름 #NAME?) · 의존 그래프도 범위로 이어짐 - 식 캐시는 시트마다 · rebuild 때 비움 — 이름 바꾸기 · 지우기(시트통째) 뒤 새로 풂 - 행열 넣기 · 지우기: 병합과 같은 범위 옮김으로 이름 범위도 옮김 · 통째로 지워진 이름은 뺌 - 수식 계산 걸음도 이름을 풂 - 시험: test_spreadsheet_analysis.py names 묶음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../spreadsheet/spreadsheet_commands.ts | 18 +++-- .../spreadsheet/spreadsheet_eval_steps.ts | 4 +- A00_Common/spreadsheet/spreadsheet_graph.ts | 49 ++++++++++++-- .../helper_spreadsheet_analysis.cjs | 65 +++++++++++++++++++ .../spreadsheet/test_spreadsheet_analysis.py | 5 +- 5 files changed, 126 insertions(+), 15 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index e89a56a8..f4ecdf36 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -292,16 +292,24 @@ function insertDelete( setComments(s, notes); } - const merges: CellRange[] = []; - for (const m of mergesOf(s)) { + const shiftRange = (m: CellRange) => { const sp = rows ? shiftSpan(m.r0, m.r1, insert, at, n, max) : shiftSpan(m.c0, m.c1, insert, at, n, max); - if (!sp) continue; - const next = rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] }; - if (next.r0 !== next.r1 || next.c0 !== next.c1) merges.push(next); + return sp && (rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] }); + }; + const merges: CellRange[] = []; + for (const m of mergesOf(s)) { + const next = shiftRange(m); + if (next && (next.r0 !== next.r1 || next.c0 !== next.c1)) merges.push(next); } setMerges(s, merges); + // 이름 범위도 따라감 · 범위가 통째로 지워진 이름은 뺌(엑셀은 `#REF!` 이름으로 남김 — 여기선 식이 `#NAME?`) + if (s.이름정의) + s.이름정의 = s.이름정의.flatMap((d) => { + const 범위 = shiftRange(d.범위); + return 범위 ? [{ ...d, 범위 }] : []; + }); const axis = rows ? "행" : "열"; const table = s[axis]; diff --git a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts index 15750ba8..e19ee044 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts @@ -15,7 +15,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { isError, isRange, numberText } from "./spreadsheet_eval"; import type { PlacedRange } from "./spreadsheet_eval"; -import { evalSafe } from "./spreadsheet_graph"; +import { bindNames, evalSafe } from "./spreadsheet_graph"; import { formulaToText, parseFormula } from "./spreadsheet_parser"; import type { CalcEngine, @@ -157,7 +157,7 @@ export function evalSteps( for (const n of nodes) { if (n === p.node) vals.set(n, engine.value(sheet, r, c)); else { - const v = evalSafe(n, ctx); + const v = evalSafe(bindNames(n, book, sheet), ctx); if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로 vals.set(n, v); } diff --git a/A00_Common/spreadsheet/spreadsheet_graph.ts b/A00_Common/spreadsheet/spreadsheet_graph.ts index 7e9046da..02f4d14e 100644 --- a/A00_Common/spreadsheet/spreadsheet_graph.ts +++ b/A00_Common/spreadsheet/spreadsheet_graph.ts @@ -21,6 +21,7 @@ import type { FormulaNode, ParseResult, Scalar, + Sheet, SheetCellAddress, Workbook, } from "./spreadsheet_types"; @@ -82,7 +83,36 @@ function collectRefs( } } -/** 나무 하나 풂 — 던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */ +/** 이름(`값들`) → 정의한 범위 참조(자기 시트 이름 먼저 · 없으면 다른 시트) · 없는 이름은 그대로(→ `#NAME?`) */ +export function bindNames(node: FormulaNode, book: Workbook, host: string): FormulaNode { + const go = (n: FormulaNode): FormulaNode => { + switch (n.type) { + case "name": { + const low = n.name.toLowerCase(); + const def = (s?: Sheet) => s?.이름정의?.find((d) => d.이름.toLowerCase() === low); + const s = [book.시트.find((x) => x.id === host), ...book.시트].find(def); + if (!s) return n; + const g = def(s)!.범위; + const sheet = s.id === host ? null : s.이름; + const from = { r: g.r0, c: g.c0, absR: true, absC: true }; + const to = { r: g.r1, c: g.c1, absR: true, absC: true }; + return g.r0 === g.r1 && g.c0 === g.c1 + ? { type: "cell", sheet, ref: from } + : { type: "range", sheet, from, to }; + } + case "unary": + return { ...n, arg: go(n.arg) }; + case "binary": + return { ...n, left: go(n.left), right: go(n.right) }; + case "call": + return { ...n, args: n.args.map(go) }; + } + return n; + }; + return book.시트.some((s) => s.이름정의?.length) ? go(node) : node; +} + +/** 나무 하나 풂 —던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */ export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult { try { return evaluate(node, ctx); @@ -111,9 +141,15 @@ export function createCalcEngine(book: Workbook): CalcEngine { const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null; - const parse = (text: string) => { - let p = parsed.get(text); - if (!p) parsed.set(text, (p = parseFormula(text))); + /** 식 나무(이름은 범위로 바꿈) — 시트마다 캐시 · rebuild 때 비움 */ + const parse = (text: string, host: string) => { + const k = `${host}!${text}`; + let p = parsed.get(k); + if (!p) { + p = parseFormula(text); + if (p.ok) p.node = bindNames(p.node, book, host); + parsed.set(k, p); + } return p; }; @@ -145,7 +181,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { vals.set(key, inputValue(cell.값!)); return; } - const p = parse(cell.식); + const p = parse(cell.식, sheet.id); const deps: Dep[] = []; if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps); precs.set(key, deps); @@ -167,6 +203,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { function rebuild(next: Workbook) { book = next; + parsed.clear(); sheets = new Map(); names = new Map(); precs = new Map(); @@ -294,7 +331,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { } function run(text: string, ctx: EvalContext): Scalar { - const p = parse(text); + const p = parse(text, ctx.sheet); if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`); const one = intersect(evalSafe(p.node, ctx), ctx); return one === null ? ZERO : one; diff --git a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs index 0699003b..996e25fc 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs @@ -164,4 +164,69 @@ section("errcheck", () => { ]); }); +// ── 4. 이름 정의 풀이 ──────────────────────────────────────────────────────── +section("names", () => { + const { C, E, F } = require("./helper_spreadsheet_engine.cjs"); + const N = load("spreadsheet_names"); + const show = (v) => (v === null ? null : E.isError(v) ? v.error : E.isFrac(v) ? F.fracToString(v) : v); + const b = book( + { A1: 1, A2: 2, A3: 3, B1: 10, C1: "=SUM(값들)", C2: "=단가*2", C3: "=없는이름+1", C4: "=SUM(값들)*단가" }, + { D1: "=SUM(값들)", D2: "=SUM(둘값)", E1: 100 }, + ); + b.시트[0].이름정의 = [ + { 이름: "값들", 범위: A.parseRange("A1:A3") }, + { 이름: "단가", 범위: A.parseRange("B1") }, + ]; + b.시트[1].이름정의 = [{ 이름: "둘값", 범위: A.parseRange("E1") }]; + const e = G.createCalcEngine(b); + const v = (a1, s = "s1") => { + const p = A.parseA1(a1); + return show(e.value(s, p.r, p.c)); + }; + eq("풀이", ["C1", "C2", "C3", "C4"].map((a) => v(a)), ["6", "20", "#NAME?", "60"]); + eq("다른 시트에서 · 자기 시트 이름", [v("D1", "s2"), v("D2", "s2")], ["6", "100"]); + // 이름 범위 칸을 고치면 따라 풂 + C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { A1: { 값: 5 } } }); + e.update([{ 시트: "s1", r: 0, c: 0 }]); + eq("딸린 칸 다시 풂", [v("C1"), v("C4"), v("D1", "s2")], ["10", "100", "10"]); + // 식 계산 걸음도 이름을 품 + const p = A.parseA1("C4"); + eq( + "계산 걸음", + ST.evalSteps(b, e, "s1", p.r, p.c).map((s) => s.글), + ["SUM(값들)*단가", "10*단가", "10*10", "100"], + ); + + // 이름 바꾸기 · 지우기(sub4 명령 → 시트통째 → rebuild) + const ctx = { + get book() { + return b; + }, + sheet: () => b.시트[0], + dispatch: (cmd) => { + C.applyCommand(b, cmd); + e.rebuild(b); + }, + }; + eq("이름 바꿈", N.renameName(ctx, "값들", "수량"), null); + eq("바꾼 뒤", [b.시트[0].칸.C1.식, v("C1"), v("D1", "s2")], ["SUM(수량)", "10", "10"]); + N.deleteName(ctx, "단가"); + eq("지운 뒤 #NAME?", [v("C2"), v("C4")], ["#NAME?", "#NAME?"]); + + // 행 넣기 · 지우기 때 이름 범위가 따라감 · 되돌리기 + const fx = C.applyCommand(b, { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }); + e.rebuild(b); + eq("넣은 뒤 범위", A.rangeToA1(b.시트[0].이름정의[0].범위), "A2:A4"); + eq("넣은 뒤 값", [v("C2"), v("D1", "s2")], ["10", "10"]); + C.applyCommand(b, fx.undo); + e.rebuild(b); + eq("되돌린 범위", A.rangeToA1(b.시트[0].이름정의[0].범위), "A1:A3"); + C.applyCommand(b, { 종류: "행지우기", 시트: "s1", at: 1, 수: 1 }); + e.rebuild(b); + eq("가운데 지움", [A.rangeToA1(b.시트[0].이름정의[0].범위), v("C1")], ["A1:A2", "8"]); + C.applyCommand(b, { 종류: "열지우기", 시트: "s1", at: 0, 수: 1 }); + e.rebuild(b); + eq("통째 지움 → 이름 빠짐", [b.시트[0].이름정의.length, v("B1")], [0, "#NAME?"]); +}); + process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/spreadsheet/test_spreadsheet_analysis.py b/resources/tester/spreadsheet/test_spreadsheet_analysis.py index 2a736f48..ba28b190 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_analysis.py +++ b/resources/tester/spreadsheet/test_spreadsheet_analysis.py @@ -3,7 +3,8 @@ 선행 · 종속 추적(누를 때마다 한 단계 · 범위 화살 · 오류 칸 빨강 · 다른 시트 · 더 없으면 false) · 수식 계산 걸음(왼쪽 먼저 · 빈 칸 0 · 범위는 함수가 · IF 첫 인자만 · 괄호 안 밑줄 · 오류 전파) · -오류 검사(순환 · 식 결과 오류 · 숫자처럼 보이는 글). +오류 검사(순환 · 식 결과 오류 · 숫자처럼 보이는 글) · +이름 정의 풀이(범위로 · 자기 시트 먼저 · 바꾸기 · 지우기 · 행열 넣기 · 지우기 때 범위 따라감 · 없는 이름 #NAME?). 화면(화살 겹판 · 계산 창 · ⚠ 풍선)은 harness_analysis.html 을 ORCA 로. """ @@ -14,7 +15,7 @@ import subprocess import pytest HERE = os.path.dirname(os.path.abspath(__file__)) -GROUPS = ["trace", "eval_steps", "errcheck"] +GROUPS = ["trace", "eval_steps", "errcheck", "names"] @pytest.fixture(scope="module")