Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-01 13:54:36 +09:00
2 changed files with 88 additions and 15 deletions
+43 -14
View File
@@ -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<string, string>;
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<string, Cell> = {};
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<string, string> = {};
const inputOf: Record<string, string> = {};
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(${up},${idx})))`
: `AND(OR(ISBLANK(INDEX(${lo},${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;
+45 -1
View File
@@ -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"