From 17b5b24682defb9b87b61dda72c9c307f50dae9b Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 10:34:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(core,view):=20per-row=20pile=20counts=20?= =?UTF-8?q?=EC=97=B4=EB=B3=84=20=EB=B3=B8=EC=88=98=20(plan=20C4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A 열 is one row across the footing width; each has its own count along the length. Default floor((L − 1400) / 2250) + 1 per 열, as in spec §4.6. - Counts are validated: one per 열, whole numbers 2–30. Spacing below the 2250 mm minimum is shown as a warning, not refused (the plan's 6본 → 5@1800 example). - PierInput gains an optional pileRows; absent means the defaults, so existing files and the P1 results are unchanged. - Change tracking compares arrays by value; 열별 본수 can be reverted and copied (copy item off by default). - The footing card has −/+ per 열 with spacing, total, and the display string. - Tests: defaults 4열 × 5본 = 20 and the 1단계 text; 6본 → 5@1800; limits; model-level 21 piles. - Not verified in a browser. Co-Authored-By: Claude Haiku 5.5 --- index.html | 1 + src/core/changes.ts | 16 ++++++-- src/core/copy.ts | 13 ++++--- src/core/geometry.ts | 45 ++++++++++------------ src/core/piles.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++ src/core/types.ts | 2 + src/main.ts | 59 ++++++++++++++++++++++++++--- src/style.css | 6 +++ tests/piles.test.ts | 64 +++++++++++++++++++++++++++++++ 9 files changed, 256 insertions(+), 39 deletions(-) create mode 100644 src/core/piles.ts create mode 100644 tests/piles.test.ts diff --git a/index.html b/index.html index e0676c1..02a3ea1 100644 --- a/index.html +++ b/index.html @@ -296,6 +296,7 @@
  • 확대기초 · 말뚝 +
  • 기둥 · 받침 · 철근배근 diff --git a/src/core/changes.ts b/src/core/changes.ts index e0a260b..db83ba6 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -10,11 +10,21 @@ export const EDITABLE_FIELDS = [ { key: 'pileLengthMm', label: '말뚝 길이', unit: 'mm' }, ] as const; -export type EditableKey = (typeof EDITABLE_FIELDS)[number]['key']; +/** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows'] as const; -/** Keys whose values differ between two inputs. */ +export type EditableKey = (typeof TRACKED_KEYS)[number]; + +export const FIELD_LABELS: Record = { + copingWidthMm: '코핑 폭 B', + copingLengthMm: '코핑 길이 L', + pileLengthMm: '말뚝 길이', + pileRows: '열별 본수', +}; + +/** Keys whose values differ between two inputs. Arrays are compared by value. */ export function changedKeys(a: PierInput, b: PierInput): EditableKey[] { - return EDITABLE_FIELDS.map((f) => f.key).filter((key) => a[key] !== b[key]); + return TRACKED_KEYS.filter((key) => JSON.stringify(a[key]) !== JSON.stringify(b[key])); } /** diff --git a/src/core/copy.ts b/src/core/copy.ts index 29a1dd4..5eff6bf 100644 --- a/src/core/copy.ts +++ b/src/core/copy.ts @@ -5,7 +5,7 @@ import type { PierInput } from './types'; * 속성 복사 (spec §9 · plan C7). Copies chosen attributes from one pier to others. * Values the spec never copies (coordinates, IP, elevations, names) are not in any item, so they cannot be copied. */ -export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length'; +export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows'; interface ItemRule { label: string; @@ -20,14 +20,15 @@ export const COPY_ITEMS: Record = { 'coping-width': { label: '코핑 폭', keys: ['copingWidthMm'], defaultOn: true, pileItem: false }, 'coping-length': { label: '코핑 길이 L', keys: ['copingLengthMm'], defaultOn: false, pileItem: false }, 'pile-length': { label: '말뚝 길이', keys: ['pileLengthMm'], defaultOn: false, pileItem: true }, + 'pile-rows': { label: '열별 본수', keys: ['pileRows'], defaultOn: false, pileItem: true }, }; export const DEFAULT_COPY_ITEMS: CopyItem[] = (Object.keys(COPY_ITEMS) as CopyItem[]).filter((k) => COPY_ITEMS[k].defaultOn); export interface CopyChange { key: EditableKey; - from: number; - to: number; + from: unknown; + to: unknown; } export interface CopyPlan { @@ -47,7 +48,7 @@ export function planCopy(source: PierInput, target: PierInput, items: CopyItem[] continue; } for (const key of rule.keys) { - if (source[key] === target[key]) continue; + if (JSON.stringify(source[key]) === JSON.stringify(target[key])) continue; changes.push({ key, from: target[key], to: source[key] }); } } @@ -56,7 +57,7 @@ export function planCopy(source: PierInput, target: PierInput, items: CopyItem[] /** Applies a plan to a target input. Only the planned keys change. */ export function applyCopy(target: PierInput, plan: CopyPlan): PierInput { - const next = { ...target }; + const next: Record = { ...target }; for (const c of plan.changes) next[c.key] = c.to; - return next; + return next as unknown as PierInput; } diff --git a/src/core/geometry.ts b/src/core/geometry.ts index eae9646..2dbe05b 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,4 +1,5 @@ import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; +import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import type { PierInput, Row } from './types'; /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */ @@ -363,7 +364,9 @@ export function buildPier(input: PierInput): PierModel { thickness: DIM.blindingT, }); if (input.foundationType === '말뚝기초') { - piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, f.length).map((p) => ({ ...p, k: f.k }))); + const g = pileGeometry(f.length); + const counts = resolveRowCounts(input.pileRows, g); + piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts).map((p) => ({ ...p, k: f.k }))); } } } @@ -419,7 +422,7 @@ export function buildPier(input: PierInput): PierModel { columnHeight: levelBottom - columns[0].bottom, soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop), pileCount: piles.length, - pileLayout: input.foundationType === '말뚝기초' ? pileLayoutText(footings[0].length) : null, + pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length)), pileGeometry(footings[0].length)) : null, pileTipEl: piles.length ? pileTip : null, blockHeightFront, blockHeightBack, @@ -460,38 +463,30 @@ export function footingGroups(colS: number[]): { s: number; length: number }[] { * Piles under one footing (spec §4.6): rows across the footing width, piles along the length, * both with an end distance of 0.7 m and an even spacing that never exceeds 2.25 m. */ -export function pileGrid(footingLength: number = DIM.footingL): { countS: number; countN: number; spacingS: number; spacingN: number } { - const span = (size: number) => Math.floor((size - 2 * DIM.pileEnd) / DIM.pileSpacing) + 1; - const countS = span(footingLength); - const countN = span(DIM.footingW); - return { - countS, - countN, - spacingS: (footingLength - 2 * DIM.pileEnd) / (countS - 1), - spacingN: (DIM.footingW - 2 * DIM.pileEnd) / (countN - 1), - }; +/** Pile geometry of one footing, from the 1단계 constants (spec §4.6). */ +export function pileGeometry(footingLength: number): PileGeometry { + return { footingLength, footingWidth: DIM.footingW, end: DIM.pileEnd, minSpacing: DIM.pileSpacing }; } -/** Display string such as "4@2250 × 3@2250 = 20본" (gaps along the length × gaps across the width). */ -export function pileLayoutText(footingLength: number = DIM.footingL): string { - const g = pileGrid(footingLength); - return `${g.countS - 1}@${Math.round(g.spacingS * 1000)} × ${g.countN - 1}@${Math.round(g.spacingN * 1000)} = ${g.countS * g.countN}본`; -} - -export function pilesFor(s: number, topEl: number, length: number, footingLength: number = DIM.footingL): PileModel[] { - const { countS, countN, spacingS, spacingN } = pileGrid(footingLength); +/** + * Piles under one footing (spec §4.6): one 열 per row across the width, each with its own count along the length. + * counts holds the 본수 of each 열 (see core/piles.ts). + */ +export function pilesFor(s: number, topEl: number, length: number, g: PileGeometry, counts: number[]): PileModel[] { + const rows = rowLayout(counts, g); + const spacingN = (g.footingWidth - 2 * g.end) / (counts.length - 1); const out: PileModel[] = []; - for (let i = 0; i < countS; i++) { - for (let j = 0; j < countN; j++) { + rows.forEach((row, j) => { + for (let i = 0; i < row.count; i++) { out.push({ - s: s + (i - (countS - 1) / 2) * spacingS, + s: s + (i - (row.count - 1) / 2) * row.spacing, k: 0, - n: (j - (countN - 1) / 2) * spacingN, + n: (j - (counts.length - 1) / 2) * spacingN, top: topEl, bottom: topEl - length, diameter: DIM.pileDiameter, }); } - } + }); return out; } diff --git a/src/core/piles.ts b/src/core/piles.ts new file mode 100644 index 0000000..caaf6b6 --- /dev/null +++ b/src/core/piles.ts @@ -0,0 +1,89 @@ +/** + * 말뚝 열별 본수 (spec §4.6 · §5.4 · plan C4). A 열 is one row across the footing width; 본 are along the length. + * Default: each 열 gets floor((길이 − 2·단부)/간격) + 1 본, so the centre spacing is at least the minimum. + * The user may change any 열's count. Counts below the minimum spacing are allowed and reported as a warning. + */ +export interface PileGeometry { + footingLength: number; + footingWidth: number; + /** 단부 거리 (700 mm). */ + end: number; + /** 최소 중심간격 (2250 mm). */ + minSpacing: number; +} + +/** A count outside this range is refused: fewer than two piles has no spacing, and the cap guards against typing errors. */ +export const PILE_COUNT_LIMITS = { min: 2, max: 30 } as const; + +/** Largest count along one length with centres at or above the minimum spacing. */ +export function maxCount(length: number, g: PileGeometry): number { + return Math.floor((length - 2 * g.end) / g.minSpacing) + 1; +} + +/** Number of 열 across the width. */ +export function rowsAcross(g: PileGeometry): number { + return maxCount(g.footingWidth, { ...g, minSpacing: g.minSpacing }); +} + +export function defaultRowCounts(g: PileGeometry): number[] { + return Array.from({ length: rowsAcross(g) }, () => maxCount(g.footingLength, g)); +} + +/** Counts to use for a footing: the requested 열별 본수 if any, otherwise the defaults. Refuses bad input. */ +export function resolveRowCounts(requested: number[] | undefined, g: PileGeometry): number[] { + if (requested === undefined) return defaultRowCounts(g); + const rows = rowsAcross(g); + if (requested.length !== rows) { + throw new Error(`열별 본수는 ${rows}개여야 합니다 (입력 ${requested.length}개).`); + } + for (const c of requested) { + if (!Number.isInteger(c) || c < PILE_COUNT_LIMITS.min || c > PILE_COUNT_LIMITS.max) { + throw new Error(`본수는 ${PILE_COUNT_LIMITS.min}–${PILE_COUNT_LIMITS.max} 사이의 정수여야 합니다 (입력 ${c}).`); + } + } + return [...requested]; +} + +export interface RowLayout { + count: number; + /** Centre spacing along the length, in m. */ + spacing: number; + /** True when the spacing is below the minimum (warning, not refused). */ + belowMinimum: boolean; +} + +export function rowLayout(counts: number[], g: PileGeometry): RowLayout[] { + return counts.map((count) => { + const spacing = (g.footingLength - 2 * g.end) / (count - 1); + return { count, spacing, belowMinimum: spacing < g.minSpacing - 1e-9 }; + }); +} + +/** Total piles (sum of the counts along each 열). */ +export function totalPiles(counts: number[]): number { + return counts.reduce((sum, c) => sum + c, 0); +} + +/** Changes one 열's count by delta, staying inside the limits. Returns a new array. */ +export function adjustRow(counts: number[], row: number, delta: number): number[] { + const next = [...counts]; + next[row] = Math.min(PILE_COUNT_LIMITS.max, Math.max(PILE_COUNT_LIMITS.min, next[row] + delta)); + return next; +} + +/** + * Display string. Uniform counts keep the 1단계 form, e.g. "4@2250 × 3@2250 = 20본" + * (gaps along the length × gaps across the width). Non-uniform counts list each 열, e.g. "열1 5@2250 · 열2 6@1800 = 23본". + */ +export function layoutText(counts: number[], g: PileGeometry): string { + const layout = rowLayout(counts, g); + const total = totalPiles(counts); + const mm = (v: number) => Math.round(v * 1000); + const across = (g.footingWidth - 2 * g.end) / (counts.length - 1); + const uniform = counts.every((c) => c === counts[0]); + if (uniform) { + return `${layout[0].count - 1}@${mm(layout[0].spacing)} × ${counts.length - 1}@${mm(across)} = ${total}본`; + } + const parts = layout.map((r, j) => `열${j + 1} ${r.count - 1}@${mm(r.spacing)}`); + return `${parts.join(' · ')} = ${total}본`; +} diff --git a/src/core/types.ts b/src/core/types.ts index 5e3b4e3..6b35985 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -32,6 +32,8 @@ export interface PierInput { copingWidthMm: number; copingLengthMm: number; columnCount: number; + /** 열별 본수 (spec §4.6): one count per 열 across the footing width. Absent = the defaults. */ + pileRows?: number[]; ips: IpPoint[]; bearings: Bearing[]; } diff --git a/src/main.ts b/src/main.ts index a6a79f4..b9b59c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,8 +6,10 @@ import { parseWorkbook, type SheetReport } from './core/parseWorkbook'; import { buildPier, type PierModel } from './core/geometry'; import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate'; import { buildTemplateWorkbook } from './core/template'; -import { EDITABLE_FIELDS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes'; +import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes'; +import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles'; import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy'; +import { pileGeometry } from './core/geometry'; import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection'; import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; @@ -445,7 +447,7 @@ function renderCopyPreview(): void { for (const t of selected) { if (!src) break; const plan = planCopy(src.draft, t.draft, items); - const labels = plan.changes.map((c) => `${EDITABLE_FIELDS.find((f) => f.key === c.key)!.label} ${c.from} → ${c.to}`); + const labels = plan.changes.map((c) => `${FIELD_LABELS[c.key]} ${c.from} → ${c.to}`); const skipped = plan.skipped.map((k) => k.reason); skippedTotal += skipped.length; if (plan.changes.length) applied++; @@ -508,7 +510,7 @@ function pendingTotal(): number { } function fieldLabel(key: EditableKey): string { - return EDITABLE_FIELDS.find((f) => f.key === key)!.label; + return FIELD_LABELS[key]; } function editField(name: string, key: EditableKey, raw: string): void { @@ -590,6 +592,53 @@ function fieldRows(box: HTMLDivElement, pier: Pier, keys: EditableKey[]): { over return { overrides, pending }; } +/** Footing length of the generated model, which the 열별 본수 limits depend on. */ +function footingLengthOf(p: Pier): number | undefined { + return p.model?.footings[0]?.length; +} + +/** 열별 본수 −/+ per 열 (spec §5.4). Shows the spacing, the total, and a warning below the minimum spacing. */ +function renderPileRows(box: HTMLDivElement, pier: Pier): void { + const footingLength = footingLengthOf(pier); + if (pier.input.foundationType !== '말뚝기초' || footingLength === undefined) return; + const g = pileGeometry(footingLength); + let counts: number[]; + try { + counts = resolveRowCounts(pier.draft.pileRows, g); + } catch (err) { + box.appendChild(el('p', (err as Error).message, 'warn-text')); + return; + } + const layout = rowLayout(counts, g); + const wrap = el('div', undefined, 'pile-rows'); + wrap.appendChild(el('p', `열별 본수 · 합계 ${totalPiles(counts)}본`, 'lbl')); + counts.forEach((count, j) => { + const row = el('div', undefined, layout[j].belowMinimum ? 'pile-row warn' : 'pile-row'); + const minus = el('button', '−', 'btn small'); + minus.type = 'button'; + minus.setAttribute('aria-label', `열 ${j + 1} 본수 줄이기`); + minus.addEventListener('click', () => setPileRow(pier.input.name, j, -1)); + const plus = el('button', '+', 'btn small'); + plus.type = 'button'; + plus.setAttribute('aria-label', `열 ${j + 1} 본수 늘리기`); + plus.addEventListener('click', () => setPileRow(pier.input.name, j, 1)); + row.append(el('span', `열 ${j + 1}`), minus, el('span', `${count}본`, 'num'), plus, el('span', `간격 ${Math.round(layout[j].spacing * 1000)} mm`, 'num')); + if (layout[j].belowMinimum) row.append(el('span', `최소 ${Math.round(g.minSpacing * 1000)} mm보다 좁습니다`, 'warn-text')); + wrap.appendChild(row); + }); + wrap.appendChild(el('p', layoutText(counts, g), 'num')); + box.appendChild(wrap); +} + +function setPileRow(name: string, row: number, delta: number): void { + const p = state.piers.find((q) => q.input.name === name); + const length = p && footingLengthOf(p); + if (!p || length === undefined) return; + const counts = resolveRowCounts(p.draft.pileRows, pileGeometry(length)); + p.draft = { ...p.draft, pileRows: adjustRow(counts, row, delta) }; + renderAll(); +} + /** 형상 선택칩 (plan.md C5): the default is pressed; every other option is shown and disabled with the reason. */ function renderShapeOptions(member: MemberId): void { const box = $('member-options'); @@ -659,6 +708,7 @@ function renderPlan(): void { regen.disabled = true; } else { fieldRows(fields, pier, member.keys); + if (member.id === 'footing') renderPileRows(fields, pier); regen.textContent = `다시 생성 · 변경 ${pendingKeys(pier).length}`; regen.disabled = pendingKeys(pier).length === 0; } @@ -667,8 +717,7 @@ function renderPlan(): void { changes.replaceChildren(); const pending = pier ? pendingKeys(pier) : []; for (const key of pier ? overrideKeys(pier) : []) { - const f = EDITABLE_FIELDS.find((x) => x.key === key)!; - const li = el('li', `${f.label}: 기본 ${pier!.defaults[key]} → 현재 ${pier!.draft[key]}${pending.includes(key) ? ' (미반영)' : ''}`); + const li = el('li', `${FIELD_LABELS[key]}: 기본 ${pier!.defaults[key] ?? '없음'} → 현재 ${pier!.draft[key] ?? '없음'}${pending.includes(key) ? ' (미반영)' : ''}`); const revert = el('button', '되돌리기', 'btn small'); revert.type = 'button'; revert.addEventListener('click', () => revertField(pier!.input.name, key)); diff --git a/src/style.css b/src/style.css index 3cc8386..dd455d7 100644 --- a/src/style.css +++ b/src/style.css @@ -671,4 +671,10 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif; .chip-opt[aria-pressed='true'] { background: #1f5fa8; border-color: #1f5fa8; color: #ffffff; font-weight: 700; } .chip-opt:disabled { color: #8a95a3; background: #f2f4f7; border-style: dashed; cursor: default; } .pier-items { max-height: 300px; overflow-y: auto; } +/* 열별 본수 (C4) */ +.pile-rows { margin-top: 10px; display: grid; gap: 6px; } +.pile-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.pile-row .btn.small { min-width: 40px; } +.pile-row.warn { color: #8a4b00; } +.warn-text { color: #8a4b00; font-size: 12px; } diff --git a/tests/piles.test.ts b/tests/piles.test.ts new file mode 100644 index 0000000..687902c --- /dev/null +++ b/tests/piles.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { loadP1 } from './helpers'; +import { adjustRow, defaultRowCounts, layoutText, maxCount, resolveRowCounts, rowLayout, totalPiles, type PileGeometry } from '../src/core/piles'; + +// Footing 10400 × 8150, pile end 700, minimum centre spacing 2250 (spec §4.6, §3). +const G: PileGeometry = { footingLength: 10.4, footingWidth: 8.15, end: 0.7, minSpacing: 2.25 }; + +describe('말뚝 열별 본수 (spec §4.6, plan C4)', () => { + it('defaults give 4 열 × 5 본 = 20 for the P1 footing, and the 1단계 text is kept', () => { + expect(defaultRowCounts(G)).toEqual([5, 5, 5, 5]); + expect(totalPiles(defaultRowCounts(G))).toBe(20); + expect(layoutText(defaultRowCounts(G), G)).toBe('4@2250 × 3@2250 = 20본'); + }); + + it('a 열 with 6 본 gives 5 gaps at 1800 mm, as in the plan (warning, not refused)', () => { + const counts = adjustRow(defaultRowCounts(G), 0, 1); + expect(counts[0]).toBe(6); + const layout = rowLayout(counts, G); + expect(Math.round(layout[0].spacing * 1000)).toBe(1800); + expect(layout[0].belowMinimum).toBe(true); + expect(layout[1].belowMinimum).toBe(false); + expect(layoutText(counts, G)).toBe('열1 5@1800 · 열2 4@2250 · 열3 4@2250 · 열4 4@2250 = 21본'); + }); + + it('the total follows the counts: − and + change it by one per 열', () => { + expect(totalPiles(adjustRow(defaultRowCounts(G), 2, -1))).toBe(19); + expect(totalPiles(adjustRow(defaultRowCounts(G), 2, 1))).toBe(21); + }); + + it('counts are kept within the limits when adjusting', () => { + expect(adjustRow([2, 2, 2, 2], 0, -1)[0]).toBe(2); + expect(adjustRow([30, 2, 2, 2], 0, 1)[0]).toBe(30); + }); + + it('refuses a wrong number of 열 and a count that is not a whole number in range', () => { + expect(() => resolveRowCounts([5, 5, 5], G)).toThrow('4개여야'); + expect(() => resolveRowCounts([5, 5, 1, 5], G)).toThrow('2–30'); + expect(() => resolveRowCounts([5, 5, 5.5, 5], G)).toThrow('정수'); + expect(resolveRowCounts([6, 5, 5, 5], G)).toEqual([6, 5, 5, 5]); + }); + + it('the default count matches the spec formula floor((L − 1400) / 2250) + 1', () => { + expect(maxCount(10.4, G)).toBe(5); + expect(maxCount(17.6, G)).toBe(8); + }); +}); + +describe('열별 본수 in the model (spec §4.6)', () => { + it('buildPier uses the per-row counts: 6 + 5 + 5 + 5 = 21 piles, and the text follows', () => { + const p1 = loadP1(); + const m = buildPier({ ...p1, pileRows: [6, 5, 5, 5] }); + expect(m.piles).toHaveLength(21); + expect(m.derived.pileLayout).toBe('열1 5@1800 · 열2 4@2250 · 열3 4@2250 · 열4 4@2250 = 21본'); + }); + + it('absent 열별 본수 gives the 1단계 result: 20 piles', () => { + expect(buildPier(loadP1()).piles).toHaveLength(20); + }); + + it('a wrong number of 열 is refused by buildPier', () => { + expect(() => buildPier({ ...loadP1(), pileRows: [5, 5, 5] })).toThrow('4개여야'); + }); +});