diff --git a/src/core/copy.ts b/src/core/copy.ts new file mode 100644 index 0000000..29a1dd4 --- /dev/null +++ b/src/core/copy.ts @@ -0,0 +1,62 @@ +import type { EditableKey } from './changes'; +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'; + +interface ItemRule { + label: string; + keys: EditableKey[]; + /** Off by default (spec: 말뚝 길이·코핑 길이 기본 해제). */ + defaultOn: boolean; + /** Pile items only apply when the foundation type matches (spec §9 건너뜀). */ + pileItem: boolean; +} + +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 }, +}; + +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; +} + +export interface CopyPlan { + changes: CopyChange[]; + /** Items not applied to this target, with the reason shown in the preview. */ + skipped: { item: CopyItem; reason: string }[]; +} + +/** Preview for one target: what would change and what is skipped. Same values are not counted as changes. */ +export function planCopy(source: PierInput, target: PierInput, items: CopyItem[]): CopyPlan { + const changes: CopyChange[] = []; + const skipped: CopyPlan['skipped'] = []; + for (const item of items) { + const rule = COPY_ITEMS[item]; + if (rule.pileItem && source.foundationType !== target.foundationType) { + skipped.push({ item, reason: `기초 형식이 다릅니다 (${target.foundationType})` }); + continue; + } + for (const key of rule.keys) { + if (source[key] === target[key]) continue; + changes.push({ key, from: target[key], to: source[key] }); + } + } + return { changes, skipped }; +} + +/** Applies a plan to a target input. Only the planned keys change. */ +export function applyCopy(target: PierInput, plan: CopyPlan): PierInput { + const next = { ...target }; + for (const c of plan.changes) next[c.key] = c.to; + return next; +} diff --git a/tests/copy.test.ts b/tests/copy.test.ts new file mode 100644 index 0000000..e0afd6e --- /dev/null +++ b/tests/copy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { applyCopy, DEFAULT_COPY_ITEMS, planCopy } from '../src/core/copy'; +import type { PierInput } from '../src/core/types'; +import { loadP1 } from './helpers'; + +const source = (): PierInput => ({ ...loadP1(), copingWidthMm: 3300, pileLengthMm: 20000, copingLengthMm: 11000 }); +const target = (): PierInput => ({ ...loadP1(), name: 'P2', copingWidthMm: 3100, pileLengthMm: 24000, copingLengthMm: 12000 }); + +describe('속성 복사 engine (spec §9, plan C7)', () => { + it('defaults copy only the coping width; length items are off', () => { + expect(DEFAULT_COPY_ITEMS).toEqual(['coping-width']); + const plan = planCopy(source(), target(), DEFAULT_COPY_ITEMS); + expect(plan.changes).toEqual([{ key: 'copingWidthMm', from: 3100, to: 3300 }]); + }); + + it('identity, coordinates, IPs, bearings and elevations are never copied', () => { + const src = { ...source(), name: 'X', ips: [], bearings: [], copingTopEl: 99, groundEl: 99, footingBottomEl: 99 }; + const tgt = target(); + const next = applyCopy(tgt, planCopy(src, tgt, ['coping-width', 'coping-length', 'pile-length'])); + expect(next.name).toBe('P2'); + expect(next.ips).toEqual(tgt.ips); + expect(next.bearings).toEqual(tgt.bearings); + expect(next.copingTopEl).toBe(tgt.copingTopEl); + expect(next.groundEl).toBe(tgt.groundEl); + expect(next.footingBottomEl).toBe(tgt.footingBottomEl); + }); + + it('a pile item is skipped when the foundation type differs', () => { + const tgt = { ...target(), foundationType: '직접기초' as const }; + const plan = planCopy(source(), tgt, ['pile-length']); + expect(plan.changes).toEqual([]); + expect(plan.skipped).toEqual([{ item: 'pile-length', reason: '기초 형식이 다릅니다 (직접기초)' }]); + }); + + it('equal values are not counted as changes', () => { + const src = source(); + const tgt = { ...target(), copingWidthMm: 3300 }; + expect(planCopy(src, tgt, ['coping-width']).changes).toEqual([]); + }); + + it('the preview count equals the number of applied changes', () => { + const plan = planCopy(source(), target(), ['coping-width', 'coping-length', 'pile-length']); + const next = applyCopy(target(), plan); + expect(plan.changes).toHaveLength(3); + expect(next.copingWidthMm).toBe(3300); + expect(next.copingLengthMm).toBe(11000); + expect(next.pileLengthMm).toBe(20000); + }); +});