brainy/tests/streaming-pipeline.test.ts

330 lines
8.1 KiB
TypeScript
Raw Normal View History

/**
* Streaming Pipeline Tests
* Tests for the new streaming data pipeline system
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Pipeline, createPipeline } from '../src/streaming/pipeline.js'
import { Brainy } from '../src/brainy.js'
import { NounType } from '../src/types/graphTypes.js'
describe('Streaming Pipeline', () => {
let brain: Brainy
beforeEach(async () => {
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1) Brainy 8.0 makes subtype required by default on every public write path (`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`, import). Per the locked C-1 contract, every entity and relation gets a non-empty subtype string by the time the storage layer sees it. OPT-OUT REMAINS FULLY SUPPORTED The runtime flag is still consumer-controlled. Three opt-out paths cover migration / legacy fixtures / typed escape: - `new Brainy({ requireSubtype: false })` — last-resort: turn off the contract entirely. Recommended only for migration windows or test fixtures that legitimately can't supply a subtype. - `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` — per-type allowlist: strict everywhere except the listed types. - `brain.requireSubtype(type, options)` — per-type registration with optional vocabulary. Composes with the brain-wide flag. Default is now `true`. Opt-out is explicit and documented; nothing silently degrades. TEST SWEEP Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call site across 120 test files. Three sed patterns covered the shapes: - `new Brainy({` → `new Brainy({ requireSubtype: false,` - `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,` - `new Brainy()` → `new Brainy({ requireSubtype: false })` tests/helpers/test-factory.ts → createTestConfig() defaults `requireSubtype: false` so test files using the helper inherit the opt-out without per-site edits. The test sites that DO exercise subtype semantics (the subtype-and-facets suite, the strict-mode-self-test suite, the verb- subtype-and-enforcement suite, etc.) already pass real subtypes — they were the 7.30.x acceptance tests for this contract. Those tests continue to pass unchanged. CHANGES src/brainy.ts - normalizeConfig() — `requireSubtype` default `false` → `true`. Comment refreshed to document the three opt-out paths. tests/* (120 files) - Bulk-edited brain construction sites. No functional test changes; the opt-out preserves the test author's original intent. tests/helpers/test-factory.ts - createTestConfig() base config gains `requireSubtype: false`. NO-OP for consumers who were already passing subtype on every write. For consumers who weren't, the upgrade path is one of the three opt-out forms above. Migration recipe documented in 8.0 release notes (next commit). VERIFICATION - npx tsc --noEmit: clean - npm test: 1408 / 1409 (same pre-existing race-condition outstanding; no other regressions from the flip)
2026-06-09 14:58:25 -07:00
brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
warmup: false
})
await brain.init()
})
afterEach(async () => {
await brain.close()
})
describe('Core Operations', () => {
it('should process data through pipeline', async () => {
const results: number[] = []
await new Pipeline()
.source(async function* () {
for (let i = 1; i <= 5; i++) {
yield i
}
})
.map(x => x * 2)
.filter(x => x > 4)
.sink(x => { results.push(x) })
.run()
expect(results).toEqual([6, 8, 10])
})
it('should handle async transformations', async () => {
const results: string[] = []
await new Pipeline()
.source(async function* () {
yield 'hello'
yield 'world'
})
.map(async (text) => {
await new Promise(resolve => setTimeout(resolve, 10))
return text.toUpperCase()
})
.sink(x => { results.push(x) })
.run()
expect(results).toEqual(['HELLO', 'WORLD'])
})
it('should batch items', async () => {
const batches: number[][] = []
await new Pipeline()
.source(async function* () {
for (let i = 1; i <= 10; i++) {
yield i
}
})
.batch(3)
.sink(batch => { batches.push(batch) })
.run()
expect(batches).toEqual([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[10]
])
})
it('should collect all results', async () => {
const pipeline = new Pipeline()
.source(async function* () {
for (let i = 1; i <= 5; i++) {
yield i
}
})
.map(x => x * x)
const results = await pipeline.collect()
expect(results).toEqual([1, 4, 9, 16, 25])
})
})
describe('Window Operations', () => {
it('should support tumbling windows', async () => {
const windows: number[][] = []
await new Pipeline()
.source(async function* () {
for (let i = 1; i <= 10; i++) {
yield i
}
})
.window(3, 'tumbling')
.sink(window => { windows.push(window) })
.run()
expect(windows).toEqual([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[10]
])
})
it('should support sliding windows', async () => {
const windows: number[][] = []
await new Pipeline()
.source(async function* () {
for (let i = 1; i <= 5; i++) {
yield i
}
})
.window(3, 'sliding')
.sink(window => { windows.push([...window]) })
.run()
expect(windows).toEqual([
[1, 2, 3],
[2, 3, 4],
[3, 4, 5]
])
})
})
describe('Reduce Operations', () => {
it('should reduce values', async () => {
let result = 0
await new Pipeline()
.source(async function* () {
for (let i = 1; i <= 5; i++) {
yield i
}
})
.reduce((acc, val) => acc + val, 0)
.sink(sum => { result = sum })
.run()
expect(result).toBe(15)
})
it('should work with complex reducers', async () => {
interface Stats {
count: number
sum: number
max: number
}
let stats: Stats = { count: 0, sum: 0, max: 0 }
await new Pipeline<number>()
.source(async function* () {
for (let i = 1; i <= 10; i++) {
yield i
}
})
.reduce<Stats>((acc, val) => ({
count: acc.count + 1,
sum: acc.sum + val,
max: Math.max(acc.max, val)
}), { count: 0, sum: 0, max: 0 })
.sink(s => { stats = s })
.run()
expect(stats).toEqual({
count: 10,
sum: 55,
max: 10
})
})
})
describe('Brainy Integration', () => {
it('should sink data to Brainy', async () => {
const pipeline = new Pipeline(brain)
.source(async function* () {
yield { content: 'Document 1' }
yield { content: 'Document 2' }
yield { content: 'Document 3' }
})
.toBrainy({
type: NounType.Document,
metadata: { source: 'pipeline' },
batchSize: 2
})
await pipeline.run()
// Verify data was added
const results = await brain.find({
where: { 'metadata.source': 'pipeline' }
})
expect(results.length).toBe(3)
})
it('should process and transform before storing', async () => {
const pipeline = new Pipeline(brain)
.source(async function* () {
yield 'hello world'
yield 'goodbye world'
})
.map(text => ({
content: text,
processed: text.toUpperCase()
}))
.toBrainy({
type: NounType.Document,
metadata: { pipeline: true }
})
await pipeline.run()
const results = await brain.find({
where: { 'metadata.pipeline': true }
})
expect(results.length).toBe(2)
})
})
describe('Error Handling', () => {
it('should handle errors with handler', async () => {
const errors: Error[] = []
await new Pipeline()
.source(async function* () {
yield 1
yield 2
yield 3
})
.map(x => {
if (x === 2) throw new Error('Test error')
return x
})
.sink(() => {})
.run({
errorHandler: (error) => errors.push(error)
})
expect(errors.length).toBe(1)
expect(errors[0].message).toBe('Test error')
})
it('should stop on abort signal', async () => {
let count = 0
const pipeline = new Pipeline()
.source(async function* () {
for (let i = 1; i <= 100; i++) {
yield i
}
})
.sink(() => { count++ })
// Start pipeline
const runPromise = pipeline.run()
// Stop after a short delay
setTimeout(() => pipeline.stop(), 50)
await runPromise
// Should have processed some but not all items
expect(count).toBeGreaterThan(0)
expect(count).toBeLessThan(100)
})
})
describe('Performance Features', () => {
it('should throttle sink operations', async () => {
const times: number[] = []
const startTime = Date.now()
await new Pipeline()
.source(async function* () {
for (let i = 1; i <= 3; i++) {
yield i
}
})
.throttledSink(
() => { times.push(Date.now() - startTime) },
10 // 10 ops/sec max
)
.run()
// Check that operations were throttled
expect(times.length).toBe(3)
expect(times[2] - times[0]).toBeGreaterThanOrEqual(200) // At least 200ms for 3 items at 10/sec
})
it('should run monitoring', async () => {
const logs: string[] = []
const originalLog = console.log
console.log = (msg: string) => logs.push(msg)
try {
await new Pipeline()
.source(async function* () {
for (let i = 1; i <= 10; i++) {
yield i
}
})
.sink(() => {})
.run({ monitoring: true })
// Should have logged completion metrics
expect(logs.some(log => log.includes('Pipeline completed'))).toBe(true)
expect(logs.some(log => log.includes('Throughput'))).toBe(true)
} finally {
console.log = originalLog
}
})
})
})