|
| 1 | +import { expect, test } from 'vitest'; |
| 2 | +import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; |
| 3 | +import fc from 'fast-check'; |
| 4 | +import { |
| 5 | + createSnapshotPresentationNode, |
| 6 | + foldSnapshotRect, |
| 7 | + serializeRegularSnapshotPresentationNode, |
| 8 | +} from './snapshot-presentation.ts'; |
| 9 | + |
| 10 | +const rawNode: RawSnapshotNode = { |
| 11 | + index: 7, |
| 12 | + type: 'Button', |
| 13 | + label: 'Save', |
| 14 | + rect: { x: 0, y: 0, width: 100, height: 80 }, |
| 15 | + hittable: true, |
| 16 | +}; |
| 17 | + |
| 18 | +const rectArb = fc.record({ |
| 19 | + x: fc.integer({ min: -500, max: 500 }), |
| 20 | + y: fc.integer({ min: -500, max: 500 }), |
| 21 | + width: fc.integer({ min: -20, max: 500 }), |
| 22 | + height: fc.integer({ min: -20, max: 500 }), |
| 23 | +}); |
| 24 | + |
| 25 | +const positiveRectArb = fc.record({ |
| 26 | + x: fc.integer({ min: -500, max: 500 }), |
| 27 | + y: fc.integer({ min: -500, max: 500 }), |
| 28 | + width: fc.integer({ min: 1, max: 500 }), |
| 29 | + height: fc.integer({ min: 1, max: 500 }), |
| 30 | +}); |
| 31 | + |
| 32 | +test('the shared fold carries both viewport and ancestor clipping into effective geometry', () => { |
| 33 | + expect( |
| 34 | + foldSnapshotRect( |
| 35 | + rawNode.rect, |
| 36 | + { x: 20, y: 10, width: 50, height: 50 }, |
| 37 | + { x: 30, y: 20, width: 100, height: 20 }, |
| 38 | + ), |
| 39 | + ).toEqual({ x: 30, y: 20, width: 40, height: 20 }); |
| 40 | +}); |
| 41 | + |
| 42 | +test('regular serialization publishes effective geometry and fails closed on degenerate clips', () => { |
| 43 | + const presented = createSnapshotPresentationNode(rawNode, { |
| 44 | + x: 30, |
| 45 | + y: 20, |
| 46 | + width: 40, |
| 47 | + height: 20, |
| 48 | + }); |
| 49 | + expect(serializeRegularSnapshotPresentationNode(presented)).toEqual({ |
| 50 | + ...rawNode, |
| 51 | + rect: { x: 30, y: 20, width: 40, height: 20 }, |
| 52 | + hittable: true, |
| 53 | + }); |
| 54 | + |
| 55 | + expect( |
| 56 | + serializeRegularSnapshotPresentationNode( |
| 57 | + createSnapshotPresentationNode(rawNode, { x: 30, y: 20, width: 0, height: 20 }), |
| 58 | + ), |
| 59 | + ).toEqual({ |
| 60 | + ...rawNode, |
| 61 | + rect: { x: 30, y: 20, width: 0, height: 20 }, |
| 62 | + hittable: undefined, |
| 63 | + }); |
| 64 | +}); |
| 65 | + |
| 66 | +test('property: effective geometry stays inside every positive clip and never upgrades actionability', () => { |
| 67 | + fc.assert( |
| 68 | + fc.property(rectArb, positiveRectArb, positiveRectArb, (reported, viewport, ancestorClip) => { |
| 69 | + const effective = foldSnapshotRect(reported, viewport, ancestorClip); |
| 70 | + const raw = { index: 0, depth: 0, rect: reported, hittable: true as const }; |
| 71 | + const presented = createSnapshotPresentationNode(raw, effective); |
| 72 | + const regular = serializeRegularSnapshotPresentationNode(presented); |
| 73 | + |
| 74 | + expect(presented.raw.rect).toEqual(reported); |
| 75 | + expect(regular.rect).toEqual(effective); |
| 76 | + if (effective && effective.width > 0 && effective.height > 0) { |
| 77 | + expect(rectContains(viewport, effective)).toBe(true); |
| 78 | + expect(rectContains(ancestorClip, effective)).toBe(true); |
| 79 | + expect(regular.hittable).toBe(true); |
| 80 | + } else { |
| 81 | + expect(regular.hittable).toBeUndefined(); |
| 82 | + } |
| 83 | + }), |
| 84 | + { numRuns: 100 }, |
| 85 | + ); |
| 86 | +}); |
| 87 | + |
| 88 | +function rectContains( |
| 89 | + outer: { x: number; y: number; width: number; height: number }, |
| 90 | + inner: { x: number; y: number; width: number; height: number }, |
| 91 | +): boolean { |
| 92 | + return ( |
| 93 | + inner.x >= outer.x && |
| 94 | + inner.y >= outer.y && |
| 95 | + inner.x + inner.width <= outer.x + Math.max(0, outer.width) && |
| 96 | + inner.y + inner.height <= outer.y + Math.max(0, outer.height) |
| 97 | + ); |
| 98 | +} |
0 commit comments