updated design

This commit is contained in:
SinachPat
2026-04-25 22:52:30 +01:00
parent df79ae0bca
commit e836ac6d2b
22 changed files with 3480 additions and 781 deletions
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { computeDiff, diffComponents } from '../src/engine.js';
import type { ComponentSnapshot } from '../src/schemas.js';
const btn: ComponentSnapshot = {
id: 'btn-1',
name: 'Button',
filePath: 'src/components/Button.tsx',
props: { variant: 'primary', size: 'md', disabled: false },
styles: { background: '#0066FF', borderRadius: '8px' },
};
describe('computeDiff', () => {
it('marks unchanged when nothing changed', () => {
const d = computeDiff(btn, btn);
expect(d.changeType).toBe('unchanged');
expect(d.propChanges).toHaveLength(0);
expect(d.styleChanges).toHaveLength(0);
expect(d.patch).toBe('');
});
it('detects modified prop', () => {
const after = { ...btn, props: { ...btn.props, size: 'lg' } };
const d = computeDiff(btn, after);
expect(d.changeType).toBe('modified');
const change = d.propChanges.find(c => c.key === 'size');
expect(change?.before).toBe('md');
expect(change?.after).toBe('lg');
expect(change?.changeType).toBe('modified');
});
it('detects added prop', () => {
const after = { ...btn, props: { ...btn.props, loading: true } };
const d = computeDiff(btn, after);
const change = d.propChanges.find(c => c.key === 'loading');
expect(change?.changeType).toBe('added');
expect(change?.before).toBeUndefined();
expect(change?.after).toBe(true);
});
it('detects removed prop', () => {
const { disabled: _, ...remaining } = btn.props;
const after = { ...btn, props: remaining };
const d = computeDiff(btn, after);
const change = d.propChanges.find(c => c.key === 'disabled');
expect(change?.changeType).toBe('removed');
expect(change?.before).toBe(false);
expect(change?.after).toBeUndefined();
});
it('detects style change', () => {
const after = { ...btn, styles: { ...btn.styles, borderRadius: '12px' } };
const d = computeDiff(btn, after);
const change = d.styleChanges.find(c => c.key === 'borderRadius');
expect(change?.before).toBe('8px');
expect(change?.after).toBe('12px');
});
it('generates a non-empty patch when changed', () => {
const after = { ...btn, props: { ...btn.props, size: 'xl' } };
const d = computeDiff(btn, after);
expect(d.patch).toBeTruthy();
expect(d.patch).toContain('--- a/');
expect(d.patch).toContain('+++ b/');
});
it('propagates id and name from after snapshot', () => {
const d = computeDiff(btn, { ...btn, name: 'IconButton' });
expect(d.name).toBe('IconButton');
expect(d.id).toBe(btn.id);
});
it('deep-equal objects are unchanged', () => {
const snap: ComponentSnapshot = {
id: 'x',
name: 'X',
props: { config: { a: 1, b: [2, 3] } },
};
const d = computeDiff(snap, { ...snap, props: { config: { a: 1, b: [2, 3] } } });
expect(d.changeType).toBe('unchanged');
});
it('deep-equal objects with different value are modified', () => {
const snap: ComponentSnapshot = {
id: 'x',
name: 'X',
props: { config: { a: 1, b: [2, 3] } },
};
const d = computeDiff(snap, { ...snap, props: { config: { a: 1, b: [2, 99] } } });
expect(d.changeType).toBe('modified');
});
it('handles added child', () => {
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
const before: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [] };
const after: ComponentSnapshot = { ...before, children: [child] };
const d = computeDiff(before, after);
expect(d.childDiffs).toBeDefined();
expect(d.childDiffs![0]?.changeType).toBe('added');
});
it('handles removed child', () => {
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
const before: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [child] };
const after: ComponentSnapshot = { ...before, children: [] };
const d = computeDiff(before, after);
expect(d.childDiffs).toBeDefined();
expect(d.childDiffs![0]?.changeType).toBe('removed');
});
it('omits childDiffs when no child changes', () => {
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
const snap: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [child] };
const d = computeDiff(snap, snap);
expect(d.childDiffs).toBeUndefined();
});
});
describe('diffComponents', () => {
it('returns diff and patch at top level', () => {
const after = { ...btn, props: { ...btn.props, size: 'xl' } };
const result = diffComponents(btn, after);
expect(result.diff.changeType).toBe('modified');
expect(result.patch).toBe(result.diff.patch);
expect(result.patch).toBeTruthy();
});
it('patch and diff.patch are identical', () => {
const result = diffComponents(btn, btn);
expect(result.patch).toBe('');
expect(result.diff.patch).toBe('');
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { computeEditScript } from '../src/myers.js';
describe('computeEditScript', () => {
it('returns empty for two empty arrays', () => {
expect(computeEditScript([], [])).toEqual([]);
});
it('returns all-insert for empty before', () => {
const ops = computeEditScript([], ['a', 'b']);
expect(ops).toEqual([{ type: 'insert', lines: ['a', 'b'] }]);
});
it('returns all-delete for empty after', () => {
const ops = computeEditScript(['a', 'b'], []);
expect(ops).toEqual([{ type: 'delete', lines: ['a', 'b'] }]);
});
it('returns all-equal for identical arrays', () => {
const ops = computeEditScript(['a', 'b', 'c'], ['a', 'b', 'c']);
expect(ops).toEqual([{ type: 'equal', lines: ['a', 'b', 'c'] }]);
});
it('detects a single changed line', () => {
const ops = computeEditScript(['a', 'b', 'c'], ['a', 'X', 'c']);
const types = ops.map(o => o.type);
expect(types).toContain('delete');
expect(types).toContain('insert');
const deleted = ops.filter(o => o.type === 'delete').flatMap(o => o.lines);
const inserted = ops.filter(o => o.type === 'insert').flatMap(o => o.lines);
expect(deleted).toContain('b');
expect(inserted).toContain('X');
});
it('detects inserted lines at end', () => {
const ops = computeEditScript(['a'], ['a', 'b', 'c']);
const inserted = ops.filter(o => o.type === 'insert').flatMap(o => o.lines);
expect(inserted).toEqual(['b', 'c']);
});
it('detects deleted lines at start', () => {
const ops = computeEditScript(['x', 'y', 'a'], ['a']);
const deleted = ops.filter(o => o.type === 'delete').flatMap(o => o.lines);
expect(deleted).toEqual(['x', 'y']);
});
it('merges consecutive ops of same type', () => {
const ops = computeEditScript(['a', 'b'], ['c', 'd']);
// Should have merged deletes and inserts, not one-op-per-line
const delOp = ops.find(o => o.type === 'delete');
const insOp = ops.find(o => o.type === 'insert');
expect(delOp?.lines.length).toBeGreaterThanOrEqual(1);
expect(insOp?.lines.length).toBeGreaterThanOrEqual(1);
});
it('round-trip: applying ops to before yields after', () => {
const before = ['prop: 1', 'style: red', 'size: md'];
const after = ['prop: 1', 'style: blue', 'size: lg', 'weight: 600'];
const ops = computeEditScript(before, after);
const result: string[] = [];
for (const op of ops) {
if (op.type === 'equal' || op.type === 'insert') result.push(...op.lines);
}
expect(result).toEqual(after);
});
it('handles single-element change', () => {
const ops = computeEditScript(['x'], ['y']);
expect(ops.some(o => o.type === 'delete' && o.lines.includes('x'))).toBe(true);
expect(ops.some(o => o.type === 'insert' && o.lines.includes('y'))).toBe(true);
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { generatePatch } from '../src/patch.js';
const A = `Component: Button
Props:
size: "md"
variant: "primary"
Styles:
background: #0066FF
borderRadius: 8px
`;
const B = `Component: Button
Props:
size: "lg"
variant: "primary"
Styles:
background: #0066FF
borderRadius: 12px
`;
describe('generatePatch', () => {
it('returns empty string for identical inputs', () => {
expect(generatePatch(A, A)).toBe('');
});
it('returns empty string for two empty strings', () => {
expect(generatePatch('', '')).toBe('');
});
it('includes --- and +++ headers', () => {
const patch = generatePatch(A, B);
expect(patch).toMatch(/^--- a\//m);
expect(patch).toMatch(/^\+\+\+ b\//m);
});
it('includes @@ hunk header', () => {
const patch = generatePatch(A, B);
expect(patch).toMatch(/^@@ /m);
});
it('marks changed lines correctly', () => {
const patch = generatePatch(A, B);
expect(patch).toContain('- size: "md"');
expect(patch).toContain('+ size: "lg"');
expect(patch).toContain('- borderRadius: 8px');
expect(patch).toContain('+ borderRadius: 12px');
});
it('preserves unchanged lines as context', () => {
const patch = generatePatch(A, B);
expect(patch).toContain(' variant: "primary"');
});
it('uses custom filename in headers', () => {
const patch = generatePatch(A, B, { filename: 'Button.tsx' });
expect(patch).toContain('--- a/Button.tsx');
expect(patch).toContain('+++ b/Button.tsx');
});
it('handles all-insert (empty before)', () => {
const patch = generatePatch('', 'line1\nline2\n');
expect(patch).toContain('+line1');
expect(patch).toContain('+line2');
});
it('handles all-delete (empty after)', () => {
const patch = generatePatch('line1\nline2\n', '');
expect(patch).toContain('-line1');
expect(patch).toContain('-line2');
});
it('produces multiple hunks when changes are far apart', () => {
const before = Array.from({ length: 20 }, (_, i) => `line${i}`).join('\n') + '\n';
const after = before
.replace('line0', 'CHANGED0')
.replace('line19', 'CHANGED19');
const patch = generatePatch(before, after);
const hunkCount = (patch.match(/^@@/gm) ?? []).length;
expect(hunkCount).toBe(2);
});
it('merges nearby hunks into one', () => {
const before = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n') + '\n';
const after = before.replace('line0', 'X').replace('line1', 'Y');
const patch = generatePatch(before, after);
const hunkCount = (patch.match(/^@@/gm) ?? []).length;
expect(hunkCount).toBe(1);
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { serializeSnapshot, serializeValue } from '../src/serialize.js';
import type { ComponentSnapshot } from '../src/schemas.js';
const snap: ComponentSnapshot = {
id: 'btn-1',
name: 'Button',
filePath: 'src/components/Button.tsx',
props: { variant: 'primary', size: 'md', disabled: false },
styles: { borderRadius: '8px', background: '#0066FF' },
};
describe('serializeSnapshot', () => {
it('includes component name and filePath', () => {
const out = serializeSnapshot(snap);
expect(out).toContain('Component: Button [src/components/Button.tsx]');
});
it('sorts props alphabetically for stable diffs', () => {
const out = serializeSnapshot(snap);
const propLines = out.split('\n').filter(l => l.includes(':') && !l.includes('Component'));
const propNames = propLines
.map(l => l.trim().split(':')[0]!.trim())
.filter(k => ['disabled', 'size', 'variant', 'background', 'borderRadius'].includes(k));
// disabled < size < variant (alphabetical)
const dIdx = propNames.indexOf('disabled');
const sIdx = propNames.indexOf('size');
const vIdx = propNames.indexOf('variant');
expect(dIdx).toBeLessThan(sIdx);
expect(sIdx).toBeLessThan(vIdx);
});
it('sorts styles alphabetically', () => {
const out = serializeSnapshot(snap);
const bgIdx = out.indexOf('background:');
const brIdx = out.indexOf('borderRadius:');
expect(bgIdx).toBeLessThan(brIdx);
});
it('produces identical output for same input (deterministic)', () => {
expect(serializeSnapshot(snap)).toBe(serializeSnapshot(snap));
});
it('handles component without filePath', () => {
const out = serializeSnapshot({ id: 'x', name: 'Foo', props: {} });
expect(out).toBe('Component: Foo');
});
it('handles empty props and styles', () => {
const out = serializeSnapshot({ id: 'x', name: 'Foo', props: {}, styles: {} });
expect(out).not.toContain('Props:');
expect(out).not.toContain('Styles:');
});
it('serializes nested children', () => {
const withChild: ComponentSnapshot = {
...snap,
children: [{ id: 'icon', name: 'Icon', props: { name: 'check' } }],
};
const out = serializeSnapshot(withChild);
expect(out).toContain('Children:');
expect(out).toContain('[0]');
expect(out).toContain('Component: Icon');
});
});
describe('serializeValue', () => {
it('wraps strings in quotes', () => expect(serializeValue('hello')).toBe('"hello"'));
it('renders numbers as-is', () => expect(serializeValue(42)).toBe('42'));
it('renders booleans', () => {
expect(serializeValue(true)).toBe('true');
expect(serializeValue(false)).toBe('false');
});
it('renders null', () => expect(serializeValue(null)).toBe('null'));
it('renders undefined', () => expect(serializeValue(undefined)).toBe('undefined'));
it('renders arrays', () => expect(serializeValue([1, 'a'])).toBe('[1, "a"]'));
it('renders objects with sorted keys', () => {
expect(serializeValue({ z: 1, a: 2 })).toBe('{ a: 2, z: 1 }');
});
});