feat(spreadsheet): 손잡이 setLink — 알약 키를 칸에 잇기 · 풀기 · 알약 누름과 같은 규칙 · 되돌리기 됨 (PLAN 39-4 D4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-04 17:23:01 +09:00
co-authored by Claude Opus 5.5
parent 1871b6ffc2
commit 8739148bba
4 changed files with 228 additions and 0 deletions
+13
View File
@@ -24,6 +24,7 @@ import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys"; import { attachKeys } from "./spreadsheet_keys";
import type { ConditionOptions } from "./spreadsheet_condition"; import type { ConditionOptions } from "./spreadsheet_condition";
import type { LinkOptions, Links } from "./spreadsheet_links"; import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu"; import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse"; import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
@@ -48,6 +49,7 @@ import type {
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types"; export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
export type { LinkItem, LinkOptions } from "./spreadsheet_links"; export type { LinkItem, LinkOptions } from "./spreadsheet_links";
export type { LinkInfo } from "./spreadsheet_link_set";
export interface SpreadsheetOptions { export interface SpreadsheetOptions {
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */ /** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
@@ -76,6 +78,9 @@ export interface SpreadsheetHandle {
recalc(): CalcValues; recalc(): CalcValues;
/** 알약 키 → 연결 칸 값(빈 칸 0) */ /** 알약 키 → 연결 칸 값(빈 칸 0) */
getLinkedValues(): Record<string, CalcValue>; getLinkedValues(): Record<string, CalcValue>;
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
* 주소가 틀리거나 읽기만이면 false */
setLink(key: string, cell: string | null, info?: LinkInfo): boolean;
destroy(): void; destroy(): void;
} }
@@ -288,6 +293,14 @@ export function createSpreadsheet(
} }
return out; return out;
}, },
setLink(key, cell, info) {
if (readOnly) return false;
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info);
if (command === false) return false;
if (command) dispatch(command);
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
return true;
},
destroy() { destroy() {
clearTimeout(toastTimer); clearTimeout(toastTimer);
detachKeys(); detachKeys();
@@ -0,0 +1,55 @@
/* =============================================================================
* spreadsheet_link_set.ts (주인 D)
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 ·
* 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
* ========================================================================== */
import { parseA1, toA1 } from "./spreadsheet_address";
import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types";
/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */
export interface LinkInfo {
이름?: string;
단위?: string;
자재?: LinkMaterial;
}
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */
export function linkCommand(
book: Workbook,
sheetId: string,
key: string,
cell: string | null,
info: LinkInfo = {},
): Command | null | false {
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
if (cell !== null && !at) return false;
const a1 = at ? toA1(at.r, at.c) : null;
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
const home = book.시트.find((s) => s.id === sheetId);
if (a1 && !home) return false;
if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
if (one) {
const 이름 = info.이름 ?? old?.이름;
const 단위 = info.단위 ?? old?.단위;
const 자재 = info.자재 ?? old?.자재;
if (이름) one.이름 = 이름;
if (단위) one.단위 = 단위;
if (자재) one.자재 = 자재;
}
const commands: Command[] = [];
for (const s of book.시트) {
const was = s.연결 ?? [];
const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1));
const next = s === home && one ? [...rest, one] : rest;
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
const copy = structuredClone(s);
if (next.length) copy.연결 = next;
else delete copy.연결;
commands.push({ 종류: "시트통째", 시트: copy });
}
if (!commands.length) return null;
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
}
@@ -0,0 +1,92 @@
/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands";
import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
let pass = 0;
const failures: string[] = [];
const eq = (name: string, got: unknown, want: unknown): void => {
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
};
const book = (): Workbook =>
({
종류: "통합문서",
판: 1,
열: "t",
서식: [{}],
시트: [
{
id: "s1",
이름: "면적산출",
칸: { B1: { 값: 12.5 } },
연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }],
},
{ id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] },
],
활성: "s1",
}) as unknown as Workbook;
const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null);
const run = (b: Workbook, c: Command | null | false) => {
if (!c) return null;
return applyCommand(b, c).undo;
};
// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음
let b = book();
const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" };
let undo = run(
b,
linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }),
);
eq("새 연결", b.시트[0].연결?.[1], {
키: "자재|MT000008",
칸: "D2",
이름: "치즐",
단위: "EA",
자재: mt,
});
// 되돌리기 = 처음 그대로
run(b, undo);
eq("되돌림", links(b), links(book()));
// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것
b = book();
run(b, linkCommand(b, "s1", "깔기|㎡", "B1"));
eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]);
// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나)
b = book();
run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }));
eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다
b = book();
undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1"));
eq("시트 넘어 옮김", links(b), [
[
{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" },
{ 키: "치기|㎥", 칸: "A1", 이름: "치기" },
],
null,
]);
run(b, undo);
eq("시트 넘어 되돌림", links(b), links(book()));
// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐
b = book();
run(b, linkCommand(b, "s1", "깔기|㎡", null));
eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]);
// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false
b = book();
eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null);
eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null);
eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false);
eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false);
eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false);
eq("문서 그대로", links(b), links(book()));
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
@@ -0,0 +1,68 @@
"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4).
순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 —
새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소.
손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts"
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_setLink_순수_길():
with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir:
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
build = subprocess.run(
[
"node",
str(VITE),
"build",
"--configLoader",
"runner",
"--ssr",
entry_rel,
"--outDir",
outdir,
],
cwd=str(ROOT),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
run = subprocess.run(
["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
assert run.stdout.strip(), run.stderr
result = json.loads(run.stdout)
assert result["fail"] == 0, result["failures"]
assert result["pass"] == 13
def test_손잡이_setLink_몸():
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src
body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0]
assert "if (readOnly) return false;" in body
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body
assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님)
assert "loadLinks();" in body