brainy/tests/unit/brainy/related-node-bidirectional.test.ts
David Snelling d4de48d004 feat(8.0): related({ node }) — one-call both-direction incident edges
related({ from }) / related({ to }) each return one direction; related({ node })
unions both (every edge where the node is source OR target), deduped, in a single
O(degree) call — the indexed equivalent of merging two related() calls by hand.
The 'all edges on a noun' query the graph-viz path needs.

- RelatedParams.node: mutually exclusive with from/to (throws if combined);
  composes with type/subtype/visibility filters; natural-key ids resolved.
- Implemented as a parallel out-edge (sourceId) + in-edge (targetId) fetch through
  the O(degree) graph-index fast paths, merged + deduped by id (a self-loop appears
  once), sorted for a stable page. Full incidence is the intended O(degree) cost;
  deep offset paging of a very-high-degree node is best-effort (use the native
  edgesForNode / a graph cursor for exhaustive paging).
- db.related() parity: node resolved + honored in relationMatchesParams (matches an
  edge incident in either direction) for the historical/overlay paths.

Test: related-node-bidirectional.test.ts — both directions, equals from∪to, self-loop
dedupe, type-filter composition, default-hides-internal, node+from/to throws.
2026-06-21 08:43:55 -07:00

86 lines
4 KiB
TypeScript

/**
* @module tests/unit/brainy/related-node-bidirectional
* @description Graph-perf #4 (8.0): `related({ node })` — the one-call,
* both-direction "all edges on a noun" query.
*
* `related({ from })` / `related({ to })` each return one direction; `related({ node })`
* unions both (edges where the node is source OR target), deduped, in a single
* O(degree) call — the indexed counterpart of merging two `related()` calls by hand.
* It composes with `type` / `subtype` / visibility filters and is mutually exclusive
* with `from` / `to`.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/index.js'
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js'
describe('related({ node }) both-direction incident edges (graph-perf #4)', () => {
let brain: Brainy
let a: string, b: string, c: string, d: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
c = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'C' })
d = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'D' })
// a's incident edges: a→b (out), c→a (in), a→d (out, ReportsTo), c→a internal (in, hidden)
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: c, to: a, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: a, to: d, type: VerbType.ReportsTo, subtype: 'direct' })
})
afterEach(async () => {
await brain.close()
})
it('returns edges incident in BOTH directions (source OR target)', async () => {
const edges = await brain.related({ node: a })
expect(edges).toHaveLength(3)
// Both out-edges (a is `from`) and the in-edge (a is `to`) are present.
const pairs = edges.map((e) => `${e.from}->${e.to}`).sort()
expect(pairs).toEqual([`${a}->${b}`, `${a}->${d}`, `${c}->${a}`].sort())
})
it('matches the union of related({from}) + related({to})', async () => {
const out = await brain.related({ from: a })
const inc = await brain.related({ to: a })
const expected = new Set([...out, ...inc].map((e) => e.id))
const node = await brain.related({ node: a })
expect(new Set(node.map((e) => e.id))).toEqual(expected)
})
it('dedupes a self-loop (an edge where the node is both source and target)', async () => {
await brain.relate({ from: a, to: a, type: VerbType.RelatedTo, subtype: 'self' })
const edges = await brain.related({ node: a })
const selfLoops = edges.filter((e) => e.from === a && e.to === a)
expect(selfLoops).toHaveLength(1) // appears once, not twice
})
it('composes with a type filter (both directions)', async () => {
const related = await brain.related({ node: a, type: VerbType.RelatedTo })
// a→b and c→a are RelatedTo; a→d is ReportsTo (excluded).
expect(related.map((e) => `${e.from}->${e.to}`).sort()).toEqual(
[`${a}->${b}`, `${c}->${a}`].sort()
)
})
it('excludes internal edges by default, includes them with includeInternal', async () => {
const e = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'E' })
await brain.relate({ from: e, to: a, type: VerbType.RelatedTo, subtype: 'colleague', visibility: 'internal' })
const visible = await brain.related({ node: a })
expect(visible.some((r) => r.from === e)).toBe(false)
const all = await brain.related({ node: a, includeInternal: true })
const internalEdge = all.find((r) => r.from === e)
expect(internalEdge?.visibility).toBe('internal')
})
it("throws when 'node' is combined with 'from' or 'to'", async () => {
await expect(brain.related({ node: a, from: b })).rejects.toThrow(/mutually exclusive/)
await expect(brain.related({ node: a, to: b })).rejects.toThrow(/mutually exclusive/)
})
})