feat: implement variable-bound FOL domain resolution for descendants/children
- Extend DomainResolver.resolve() signature to accept optional BindingEnv so that parentVar domains can be resolved with runtime variable bindings - Pass BindingEnv through evaluateForAll/evaluateExists to resolver calls - Add buildAncestorIndex() to precompute DOM ancestor sets from CDP data - SelectorDomainResolver now filters descendant domains by the bound parent when domain.parentVar is present and ancestor index is available - Return undefined for parentVar domains when no ancestor index or env (prevents silent fallback to global domain resolution) - Update all test DomainResolver mocks for new resolve interface - Add 10 unit tests covering ancestor index construction, backward compat, descendant filtering, exclusion of non-descendants, empty descendants, missing parentVar/env, and no-ancestor-index safety
This commit is contained in:
@@ -11,6 +11,9 @@ import assert from 'node:assert'
|
||||
import { imhotep, ImhotepUi, imhotepComponent, imhotepStory, imhotepFixture } from './public.js'
|
||||
import { defaultEnvironment } from './fixture.js'
|
||||
import { react, storybook } from './renderers.js'
|
||||
import { SelectorDomainResolver, buildAncestorIndex } from './extraction.js'
|
||||
import { BindingEnv } from 'imhotep-solver'
|
||||
import type { GeometryWorld } from 'imhotep-solver'
|
||||
|
||||
// Minimal Playwright page mock
|
||||
function createMockPage(): any {
|
||||
@@ -1207,3 +1210,149 @@ describe('Geometry cache API', () => {
|
||||
ui.clearCache()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable-bound Domain Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockWorld(domData: { nodeId: number[]; parentNodeId: number[] }): GeometryWorld {
|
||||
const count = Math.max(domData.nodeId.length, 1)
|
||||
const ids = Array.from({ length: count }, (_, i) => i + 1)
|
||||
return {
|
||||
sceneId: 'test',
|
||||
snapshotId: 'snap1',
|
||||
env: { viewportWidth: 1280, viewportHeight: 720, deviceScaleFactor: 1, colorScheme: 'light', pointer: 'fine', hover: false, reducedMotion: false, locale: 'en', writingMode: 'horizontal-tb' },
|
||||
strings: { values: [] },
|
||||
subjects: { ids, domNodeId: ids, subjectKind: Array(count).fill(0), primaryBoxId: ids, firstFragmentId: Array(count).fill(0), fragmentCount: Array(count).fill(0) },
|
||||
dom: { nodeId: domData.nodeId, parentNodeId: domData.parentNodeId, childCount: Array(count).fill(0), tagNameStringId: Array(count).fill(0) },
|
||||
boxes: { boxId: ids, subjectId: ids, frameId: Array(count).fill(0), borderLeft: Array(count).fill(0), borderTop: Array(count).fill(0), borderRight: Array(count).fill(0), borderBottom: Array(count).fill(0), paddingLeft: Array(count).fill(0), paddingTop: Array(count).fill(0), paddingRight: Array(count).fill(0), paddingBottom: Array(count).fill(0), contentLeft: Array(count).fill(0), contentTop: Array(count).fill(0), contentRight: Array(count).fill(0), contentBottom: Array(count).fill(0) },
|
||||
visualBoxes: { boxId: ids, subjectId: ids, frameId: Array(count).fill(0), borderLeft: Array(count).fill(0), borderTop: Array(count).fill(0), borderRight: Array(count).fill(0), borderBottom: Array(count).fill(0), paddingLeft: Array(count).fill(0), paddingTop: Array(count).fill(0), paddingRight: Array(count).fill(0), paddingBottom: Array(count).fill(0), contentLeft: Array(count).fill(0), contentTop: Array(count).fill(0), contentRight: Array(count).fill(0), contentBottom: Array(count).fill(0) },
|
||||
transforms: { transformId: [], subjectId: [], matrixStart: [], matrixLength: [], originX: [], originY: [] },
|
||||
matrices: { values: [] },
|
||||
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
|
||||
topology: { containingBlockOf: [], nearestPositionedAncestorOf: [], scrollContainerOf: [], stackingContextOf: [], formattingContextOf: [], clippingRootOf: [], paintOrderBucket: [], paintOrderIndex: [] },
|
||||
scroll: { containerId: [], scrollLeft: [], scrollTop: [], scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: [] },
|
||||
clipping: { clipNodeId: [], subjectId: [], clipKind: [], clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: [] },
|
||||
visibility: { subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('SelectorDomainResolver — variable-bound domain resolution', () => {
|
||||
it('resolves without parentVar (backward compatible)', () => {
|
||||
const resolver = new SelectorDomainResolver()
|
||||
resolver.register('.card', [1, 2, 3])
|
||||
const domain = resolver.resolve({ type: 'DomainRef', domain: 'elements', selector: '.card' })
|
||||
assert.ok(domain)
|
||||
assert.deepStrictEqual(Array.from(domain!.subjectIds), [1, 2, 3])
|
||||
assert.strictEqual(domain!.closed, true)
|
||||
})
|
||||
|
||||
it('resolves with parentVar and env, filtering to descendants', () => {
|
||||
const world = makeMockWorld({
|
||||
nodeId: [1, 2, 3],
|
||||
parentNodeId: [0, 1, 1],
|
||||
})
|
||||
const ancestors = buildAncestorIndex(world)
|
||||
const resolver = new SelectorDomainResolver()
|
||||
resolver.setAncestorIndex(ancestors)
|
||||
resolver.register('.title', [2, 3])
|
||||
|
||||
const env = new BindingEnv().bind('$card', 1)
|
||||
const domain = resolver.resolve(
|
||||
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
|
||||
env,
|
||||
)
|
||||
assert.ok(domain)
|
||||
assert.deepStrictEqual(Array.from(domain!.subjectIds), [2, 3])
|
||||
})
|
||||
|
||||
it('parentVar resolution excludes non-descendants', () => {
|
||||
const world = makeMockWorld({
|
||||
nodeId: [1, 2, 3],
|
||||
parentNodeId: [0, 2, 0],
|
||||
})
|
||||
const ancestors = buildAncestorIndex(world)
|
||||
const resolver = new SelectorDomainResolver()
|
||||
resolver.setAncestorIndex(ancestors)
|
||||
resolver.register('.item', [1, 3])
|
||||
|
||||
const env = new BindingEnv().bind('$container', 2)
|
||||
const domain = resolver.resolve(
|
||||
{ type: 'DomainRef', domain: 'descendants', selector: '.item', parentVar: '$container' },
|
||||
env,
|
||||
)
|
||||
assert.ok(domain)
|
||||
assert.deepStrictEqual(Array.from(domain!.subjectIds), [1])
|
||||
})
|
||||
|
||||
it('parentVar not found in env returns undefined', () => {
|
||||
const resolver = new SelectorDomainResolver()
|
||||
resolver.register('.title', [1, 2])
|
||||
const env = new BindingEnv().bind('$other', 5)
|
||||
const domain = resolver.resolve(
|
||||
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
|
||||
env,
|
||||
)
|
||||
assert.strictEqual(domain, undefined)
|
||||
})
|
||||
|
||||
it('parentVar without ancestorIndex returns undefined', () => {
|
||||
const resolver = new SelectorDomainResolver()
|
||||
resolver.register('.title', [1, 2])
|
||||
const env = new BindingEnv().bind('$card', 1)
|
||||
const domain = resolver.resolve(
|
||||
{ type: 'DomainRef', domain: 'descendants', selector: '.title', parentVar: '$card' },
|
||||
env,
|
||||
)
|
||||
assert.strictEqual(domain, undefined)
|
||||
})
|
||||
|
||||
it('no descendants yields empty domain', () => {
|
||||
const world = makeMockWorld({
|
||||
nodeId: [1, 2, 3],
|
||||
parentNodeId: [0, 0, 2],
|
||||
})
|
||||
const ancestors = buildAncestorIndex(world)
|
||||
const resolver = new SelectorDomainResolver()
|
||||
resolver.setAncestorIndex(ancestors)
|
||||
resolver.register('.orphan', [1])
|
||||
|
||||
const env = new BindingEnv().bind('$parent', 3)
|
||||
const domain = resolver.resolve(
|
||||
{ type: 'DomainRef', domain: 'descendants', selector: '.orphan', parentVar: '$parent' },
|
||||
env,
|
||||
)
|
||||
assert.ok(domain)
|
||||
assert.strictEqual(domain!.subjectIds.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAncestorIndex', () => {
|
||||
it('returns empty map for world with no DOM data', () => {
|
||||
const world = makeMockWorld({ nodeId: [], parentNodeId: [] })
|
||||
const index = buildAncestorIndex(world)
|
||||
assert.strictEqual(index.size, 0)
|
||||
})
|
||||
|
||||
it('builds ancestor sets from parent-child DOM relationships', () => {
|
||||
const world = makeMockWorld({
|
||||
nodeId: [1, 2, 3, 4],
|
||||
parentNodeId: [0, 1, 1, 2],
|
||||
})
|
||||
const index = buildAncestorIndex(world)
|
||||
assert.ok(index.has(3))
|
||||
assert.ok(index.get(3)!.has(1))
|
||||
assert.ok(index.has(4))
|
||||
assert.ok(index.get(4)!.has(2))
|
||||
assert.ok(index.get(4)!.has(1))
|
||||
})
|
||||
|
||||
it('no ancestors for root elements', () => {
|
||||
const world = makeMockWorld({
|
||||
nodeId: [1, 2],
|
||||
parentNodeId: [0, 0],
|
||||
})
|
||||
const index = buildAncestorIndex(world)
|
||||
assert.strictEqual(index.size, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user