Files
Imhotep/packages/imhotep-state/src/snapshots.ts
T

182 lines
6.1 KiB
TypeScript

import type { ImhotepId, StateKind, Environment, ExecutionContext } from 'imhotep-core'
import { getDefaultContext } from 'imhotep-core'
// ---------------------------------------------------------------------------
// Snapshot Identity
// ---------------------------------------------------------------------------
/** Stable identifier for a geometry snapshot. */
export type SnapshotId = ImhotepId
// Module-level monotonic counter ensures unique ids even when deterministic
// test contexts return identical clock/idGenerator values per call.
let _snapshotCounter = 0
/** Generates a unique snapshot id with an optional prefix.
* When no context is provided, uses the global default context.
* A module-level counter guarantees uniqueness across consecutive calls.
*/
export function createSnapshotId(prefix = 'snap', ctx?: ExecutionContext): SnapshotId {
const context = ctx ?? getDefaultContext()
const counter = ++_snapshotCounter
return `${prefix}_${context.clock()}_${context.idGenerator()}_${counter}`
}
// ---------------------------------------------------------------------------
// State Source Classification
// ---------------------------------------------------------------------------
/**
* Describes how a state was materialized.
*
* - interaction: Real Playwright interaction (hover, focus, mousedown).
* - css-pseudo: Synthetic emulation via forced CSS pseudo-class styles.
* - synthetic: Computed projection without browser interaction.
* - component-hook: Materialized via a component adapter hook.
*/
export type StateSource = 'interaction' | 'css-pseudo' | 'synthetic' | 'component-hook'
// ---------------------------------------------------------------------------
// Snapshot Metadata
// ---------------------------------------------------------------------------
/**
* Metadata attached to every geometry snapshot so diagnostics can
* trace which state, source, and environment produced it.
*/
export interface SnapshotMetadata {
/** Unique id for this snapshot instance. */
snapshotId: SnapshotId
/** The UI state kind that was materialized (hover, focus, etc). */
stateKind: StateKind
/** How the state was produced (real interaction vs synthetic). */
stateSource: StateSource
/** Selector of the element that received the state, if any. */
selector?: string
/** Milliseconds since epoch when the snapshot was captured. */
timestamp: number
/** Environment case active when the snapshot was taken. */
env?: Environment
/** Duration of the transition sample in ms, if applicable. */
durationMs?: number
/** Approximation note for diagnostics when state is not fully determinate. */
approximationNote?: string
}
// ---------------------------------------------------------------------------
// Geometry Snapshot
// ---------------------------------------------------------------------------
/**
* A captured geometry world for a specific UI state.
*
* The `world` property holds the extractor output (geometry world schema).
* It is typed as `unknown` here because the exact schema is owned by the
* extractor package; imhotep-state materializes and tags snapshots without
* needing to inspect world internals.
*/
export interface GeometrySnapshot {
/** Snapshot identifier used by the solver and diagnostics. */
id: SnapshotId
/** State metadata for traceability and comparison. */
metadata: SnapshotMetadata
/** The materialized geometry world produced by the extractor. */
world: unknown
}
// ---------------------------------------------------------------------------
// Snapshot Comparison
// ---------------------------------------------------------------------------
/** Result of comparing two geometry snapshots. */
export interface SnapshotDiff {
/** True when both snapshots have identical world and metadata. */
identical: boolean
/** Human-readable list of detected differences. */
differences: string[]
}
/**
* Deep-compares two geometry snapshots for equality.
*
* Used by tests and diagnostics to detect unexpected changes between
* states (e.g. hover caused the element to shift).
*/
export function compareSnapshots(a: GeometrySnapshot, b: GeometrySnapshot): SnapshotDiff {
const differences: string[] = []
if (a.id !== b.id) {
differences.push(`id changed from "${a.id}" to "${b.id}"`)
}
if (a.metadata.stateKind !== b.metadata.stateKind) {
differences.push(
`stateKind changed from "${a.metadata.stateKind}" to "${b.metadata.stateKind}"`
)
}
if (a.metadata.stateSource !== b.metadata.stateSource) {
differences.push(
`stateSource changed from "${a.metadata.stateSource}" to "${b.metadata.stateSource}"`
)
}
if (a.metadata.selector !== b.metadata.selector) {
differences.push(
`selector changed from "${a.metadata.selector}" to "${b.metadata.selector}"`
)
}
// Compare world via JSON serialization for a stable deep equality check.
// This is sufficient for unit tests and diagnostic diffs.
const worldA = JSON.stringify(a.world)
const worldB = JSON.stringify(b.world)
if (worldA !== worldB) {
differences.push('world content differs')
}
return {
identical: differences.length === 0,
differences,
}
}
// ---------------------------------------------------------------------------
// Snapshot Registry
// ---------------------------------------------------------------------------
/**
* In-memory store for geometry snapshots during a test run.
*
* The materializer populates this store; the solver reads from it.
*/
export interface SnapshotStore {
/** Add a snapshot to the store. */
add(snapshot: GeometrySnapshot): void
/** Retrieve a snapshot by id. */
get(id: SnapshotId): GeometrySnapshot | undefined
/** List all snapshots in insertion order. */
list(): GeometrySnapshot[]
/** Remove all snapshots. */
clear(): void
}
/** Factory for a default Map-backed snapshot store. */
export function createSnapshotStore(): SnapshotStore {
const store = new Map<SnapshotId, GeometrySnapshot>()
return {
add(snapshot) {
store.set(snapshot.id, snapshot)
},
get(id) {
return store.get(id)
},
list() {
return Array.from(store.values())
},
clear() {
store.clear()
},
}
}