feat(core): attribute-copy engine (plan C7)

- planCopy previews the changes and the skipped items for one target; applyCopy
  applies exactly the planned changes.
- Default selection copies only the coping width; coping length and pile length
  are off by default (spec §9). Pile items are skipped when the foundation type
  differs. Coordinates, IPs, bearings and elevations are not in any item.
- Equal values are not counted, so the preview count equals the applied count.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-22 09:59:48 +09:00
1 parent fae8d7b9b8
commit 4accf1f3be
2 files changed
+111

No files matched your search

+62
View File
@@ -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<CopyItem, ItemRule> = {
'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;
}
+49
View File
@@ -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);
});
});