From 2c848702ea3652bf0ca6106b5796ec4b22fc5009 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:53:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(m01-sl):=20=ED=91=9C=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=EC=97=90=20=ED=91=9C=20=EB=B2=88=ED=98=B8=20=EC=95=88=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EA=B2=8C=20=E2=80=94=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=ED=8C=90=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=EB=A5=BC=20=ED=91=9C=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=C2=B7=20=ED=91=9C=20=EC=A4=84=20=EC=88=98?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=EA=B0=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../SheetLogic/M01_SL_Table_Build.ts | 57 ++++++++++++++----- resources/tester/test_m01_sl_table_build.py | 46 ++++++++++++++- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts index 977fe606a..3ec4d3f50 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -1,12 +1,12 @@ /* ============================================================================= * M01_SL_Table_Build.ts - * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 + * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 합친 조건 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts` * * 시트 모양(표 상자 시트 하나): * A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움 - * 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로) - * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) + * 빈 열 하나 건너 = 찾기용 합친 조건 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 합친 조건 열이 따로) + * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 합친 조건 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) * 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A * ========================================================================== */ @@ -58,7 +58,7 @@ export interface TblPlan { outCells: Record; idxCell: string; statusCell: string; - /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */ + /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 합친 조건 열은 다시 쓸 때 지움 */ helperFrom: number; warnings: string[]; } @@ -74,7 +74,8 @@ const colIndex = (letters: string): number => [...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1; const at = (r: number, c: number): string => `${colName(c)}${r}`; -const MARGIN = 20; +/** 표 줄이 늘어도 따라가도록 블록 아래로 여유 줄을 둠 — 줄 수 이상 · 최소 20 */ +const marginOf = (n: number): number => Math.max(20, n); const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`; /** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */ @@ -148,16 +149,16 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음"); const rule = info.rule ?? "초과·이하"; const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule)); - const R = (range ? n : n + MARGIN) + 1; + const R = n + marginOf(n) + 1; const rng = (L: string): string => `${L}$2:${L}$${R}`; - // 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 + // 합친 조건 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 const variants: string[][] = [[]]; if (!range) { const bl = blankable.slice(0, 2); const subsets = bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : []; - variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음 + variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 합친 조건가 없음 } const keyCols = exact.length ? variants : []; const kStart = W + 1; @@ -166,7 +167,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const V = L + 1; const cells: Record = {}; keyCols.forEach((skip, v) => { - cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" }; + cells[at(1, kStart + v)] = { + 값: skip.length ? `합친 조건(${skip.join("·")} 무관)` : "합친 조건", + }; const rest = exact.filter((c) => !skip.includes(c)); for (let r = 2; r <= R; r++) { const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&'); @@ -183,7 +186,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { // 찾기 판 let row = 1; - cells[at(row++, L)] = { 값: "표에서 찾기" }; + cells[at(row++, L)] = { + 값: ["표에서 찾기", info.name, info.no, info.base].filter(Boolean).join(" · "), + }; const inCells: Record = {}; const inputOf: Record = {}; for (const c of conds) { @@ -194,6 +199,14 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { inputOf[c] = at(row, V); row++; } + // 표 줄 수 — 블록 안 칸이 찬 줄 수(마스터 표 줄이 늘거나 줄어도 따라감) + const rowsCell = ((): string => { + cells[at(row, L)] = { 값: "표 줄 수" }; + cells[at(row, V)] = { + 식: `MAX(${cols.map((_, c) => `COUNTA(${rng(colName(c))})`).join(",")})`, + }; + return at(row++, V); + })(); const keyOf = (skip: string[]): string => exact .filter((c) => !skip.includes(c)) @@ -210,10 +223,10 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { let idx = ""; let count = ""; if (exact.length) { - const keyCell = idxRow("열쇠", keyOf([])); + const keyCell = idxRow("합친 조건", keyOf([])); const ks = keyCols.map((skip, v) => { if (v === 0) return keyCell; - cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` }; + cells[at(row, L)] = { 값: `합친 조건(${skip.join("·")} 무관)` }; cells[at(row, V)] = { 식: keyOf(skip) }; return at(row++, V); }); @@ -224,7 +237,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { ); count = idxRow( "일치 수", - `IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, + `IF(ISNUMBER(${idx}),IF(${idx}>${rowsCell},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, ); } else { const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`); @@ -242,7 +255,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const inside = lowBased ? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`; - count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`); + count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${rowsCell}),IF(${inside},1,0),0)`); } else { warnings.push("조건 칸이 없음 — 찾을 줄이 없음"); count = idxRow("일치 수", "0"); @@ -294,6 +307,21 @@ export function pillsOf( const sheetIndexOf = (sl: SheetLogic, node: SlNode): number => sl.workbook.시트.findIndex((s) => s.id === node.sheetId); +/** 시트 이름을 바꿈 — 같은 이름이 있으면 번호를 붙이고 · 다른 시트가 이 시트를 가리키던 참조 수식도 따라 고침 */ +function renameSheet(sl: SheetLogic, sheet: Sheet, want: string): void { + if (sheet.이름 === want) return; + const taken = new Set(sl.workbook.시트.filter((s) => s !== sheet).map((s) => s.이름)); + let name = want; + for (let i = 2; taken.has(name); i++) name = `${want} (${i})`; + const quote = (n: string): string => `'${n.replace(/'/g, "''")}'`; + const old = sheet.이름; + const olds = [`${quote(old)}!`, ...(/^[\p{L}\p{N}_]+$/u.test(old) ? [`${old}!`] : [])]; + for (const s of sl.workbook.시트) + for (const cell of Object.values(s.칸)) + if (cell.식) for (const o of olds) cell.식 = cell.식.split(o).join(`${quote(name)}!`); + sheet.이름 = name; +} + /** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */ export function applyTable( sl: SheetLogic, @@ -331,6 +359,7 @@ export function applyTable( !(e.to.node === nodeId && gone("in", e.to.pill)), ); node.name = `표 · ${info.name}`; + renameSheet(next, sheet, node.name); node.ref = opts.ref; node.pillsIn = pills.pillsIn; node.pillsOut = pills.pillsOut; diff --git a/resources/tester/test_m01_sl_table_build.py b/resources/tester/test_m01_sl_table_build.py index a1f6eb1df..f353c54e4 100644 --- a/resources/tester/test_m01_sl_table_build.py +++ b/resources/tester/test_m01_sl_table_build.py @@ -31,11 +31,21 @@ const jobs = JSON.parse(fs.readFileSync(0, "utf8")); const out = jobs.map((job) => { if (job.op === "plan") { const plan = m.planLookup(job.info, job.roles); - const cells = { ...m.blockCells(job.info), ...plan.cells }; + const cells = { ...m.blockCells(job.blockInfo || job.info), ...plan.cells }; for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v }; return { cells, plan }; } if (job.op === "paste") return m.parsePaste(job.text); + if (job.op === "rename") { + const sl = { key: null, name: "t", resultUnit: "", edges: [], + workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [ + { id: "s1", 이름: "표", 칸: {} }, + { id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] }, + nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; + const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl; + const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? "")); + return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts }; + } if (job.op === "reapply") { const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [], workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] }, @@ -230,3 +240,37 @@ def test_블록_머리_차례(): "폭·아래", "폭·위", ] + + +def test_표가_늘어도_찾기_범위가_따라감(): + """마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다).""" + small = {**RATE, "rows": RATE["rows"][:2]} + extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)] + grown = {**small, "rows": [*small["rows"], *extra]} + roles = {"cond": ["높이"], "out": ["증가율"]} + plan = run( + [{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}] + )[0] + book = { + "종류": "통합문서", + "판": 1, + "열": "t", + "서식": [{}], + "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], + "활성": "s1", + } + values = recalc_workbooks([book])[0]["s1"] + assert values[plan["plan"]["statusCell"]]["글"] == "찾음" + assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72 + + +def test_표_상자에_표_번호가_안_보임_이름으로_보임(): + info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"} + roles = {"cond": list(info["conds"]), "out": ["석공"]} + out = run([{"op": "rename", "info": info, "roles": roles}])[0] + assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"] + assert out["ref"] == "QF000421" # 번호는 상자 ref 에만 + assert "QF000421" not in " ".join(out["texts"]) + assert any("13-4-1" in t and "1㎡" in t for t in out["texts"]) + assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2" + assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1"