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:
@@ -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
|
||||||
Reference in New Issue
Block a user