diff --git a/resources/tester/spreadsheet/harness_links.html b/resources/tester/spreadsheet/harness_links.html
new file mode 100644
index 00000000..04d11161
--- /dev/null
+++ b/resources/tester/spreadsheet/harness_links.html
@@ -0,0 +1,22 @@
+
+
+
+
+ 알약 막대 시험 틀
+
+
+
+
+
+
+
diff --git a/resources/tester/spreadsheet/harness_links.ts b/resources/tester/spreadsheet/harness_links.ts
new file mode 100644
index 00000000..9b36313a
--- /dev/null
+++ b/resources/tester/spreadsheet/harness_links.ts
@@ -0,0 +1,57 @@
+/* 알약 막대 시험 틀 — 진짜 부품(createSpreadsheet)에 `links` 선택 · 가짜 입력값 · 가짜 자재 목록.
+ * Vite 개발 서버가 바로 풂: /resources/tester/spreadsheet/harness_links.html?mode=입력|자재
+ * window.ss = 부품 손잡이(getDoc · getLinkedValues) · window.ssFind = 자재찾기 부른 글 목록 */
+import "../../../ui_template/ui_template_theme.css"; // 앱 색 · 간격 토큰
+import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
+import type { LinkMaterial, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet";
+
+const w = window as unknown as Record;
+const mode = new URLSearchParams(location.search).get("mode") === "자재" ? "자재" : "입력";
+
+const book: Workbook = {
+ 종류: "통합문서",
+ 판: 1,
+ 열: "harness",
+ 서식: [{}],
+ 시트: [
+ {
+ id: "s1",
+ 이름: "면적산출",
+ 칸: {
+ A1: { 값: "길이" },
+ B1: { 값: 12.5 },
+ A2: { 값: "높이" },
+ B2: { 값: 3 },
+ A3: { 값: "면적" },
+ B3: { 식: "B1*B2" },
+ },
+ },
+ ],
+ 활성: "s1",
+};
+
+const MATERIALS: LinkMaterial[] = [
+ { 코드: "M001", 이름: "레미콘", 규격: "25-21-12", 단위: "m³" },
+ { 코드: "M002", 이름: "철근", 규격: "SD400 D13", 단위: "ton" },
+ { 코드: "M003", 이름: "거푸집", 규격: "합판 3회", 단위: "m²" },
+];
+const asked: string[] = [];
+w.ssFind = asked;
+
+w.ss = createSpreadsheet(document.getElementById("host")!, book, {
+ tabs: false,
+ onChange: () => (w.ssChanges = ((w.ssChanges as number) ?? 0) + 1),
+ links: {
+ 모드: mode,
+ 항목: [
+ { 키: "L", 이름: "길이", 단위: "m" },
+ { 키: "H", 이름: "높이", 단위: "m" },
+ { 키: "A", 이름: "면적", 단위: "m²" },
+ ],
+ 자재찾기: async (글) => {
+ asked.push(글);
+ await new Promise((r) => setTimeout(r, 50));
+ return MATERIALS.filter((m) => !글 || `${m.코드}${m.이름}${m.규격}`.includes(글));
+ },
+ },
+});
diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs
index 16b3be28..527b4e66 100644
--- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs
+++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs
@@ -123,5 +123,28 @@ const out = {
};
return addr.toA1(sel.move(s, sel.selectCell(s, 1, 11), 1, 0).활성.r, 11);
})(),
+ // 알약 연결 — 행 넣기 · 열 지우기(연결 칸 지움 → 빠짐) · 되돌리기 두 번
+ links: (() => {
+ const lk = load(path.join(SS, "spreadsheet_links.ts"));
+ const book = JSON.parse(JSON.stringify(sample));
+ const id = book.시트[0].id;
+ book.시트[0].연결 = [
+ { 키: "L", 이름: "길이", 칸: "B5" },
+ { 키: "W", 이름: "폭", 칸: "D3" },
+ ];
+ const read = () => (book.시트[0].연결 ?? []).map((l) => `${l.키}:${l.칸}`).join(",");
+ const out = [];
+ const ins = lk.applyLinked(book, { 종류: "행넣기", 시트: id, at: 2, 수: 2 });
+ out.push(read());
+ const del = lk.applyLinked(book, { 종류: "열지우기", 시트: id, at: 3, 수: 1 });
+ out.push(read());
+ lk.applyLinked(book, del.undo);
+ out.push(read());
+ lk.applyLinked(book, ins.undo);
+ out.push(read());
+ const one = [{ 키: "a", 칸: "C1" }];
+ out.push(lk.shiftLinks(one, { 종류: "열넣기", 시트: id, at: 0, 수: 1 })[0].칸);
+ return out;
+ })(),
};
console.log(JSON.stringify(out));
diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py
index 3a1d008f..ad128f05 100644
--- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py
+++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py
@@ -106,3 +106,8 @@ def test_abs_cycle_and_fold(r):
None,
]
assert r["foldSkip"] == "L5"
+
+
+def test_links_follow_rows_cols(r):
+ # 행 넣기 → 밀림 · 연결 칸 열 지움 → 빠짐 · 되돌리기 → 제자리
+ assert r["links"] == ["L:B7,W:D5", "L:B7", "L:B7,W:D5", "L:B5,W:D3", "D1"]