/** * @module tests/integration/subtype-and-facets * @description Integration coverage for the 7.29.0 subtype-and-facets primitive * (Layer 1 top-level `subtype` field + rollup + counts API, Layer 2 * `brain.trackField()`, Layer 3 `brain.migrateField()`). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../src/brainy' import { NounType } from '../../src/types/graphTypes' describe('Subtype + Facets (7.29.0)', () => { let brain: Brainy beforeEach(async () => { brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await brain.init() }) afterEach(async () => { await brain.close() }) describe('Layer 1: subtype as top-level standard field', () => { it('persists subtype on add() and round-trips through get()', async () => { const id = await brain.add({ data: 'Avery operates the AI lab', type: NounType.Person, subtype: 'operator', metadata: { department: 'ai-lab' } }) const entity = await brain.get(id) expect(entity).not.toBeNull() expect(entity!.subtype).toBe('operator') expect(entity!.type).toBe(NounType.Person) // subtype lives at top-level, NOT in metadata expect((entity!.metadata as any).subtype).toBeUndefined() expect((entity!.metadata as any).department).toBe('ai-lab') }) it('allows omitting subtype (optional field)', async () => { const id = await brain.add({ data: 'plain entity', type: NounType.Thing }) const entity = await brain.get(id) expect(entity!.subtype).toBeUndefined() }) it('updates subtype via update() without disturbing other fields', async () => { const id = await brain.add({ data: 'switched roles', type: NounType.Person, subtype: 'contractor', metadata: { department: 'eng' } }) await brain.update({ id, subtype: 'employee' }) const entity = await brain.get(id) expect(entity!.subtype).toBe('employee') expect((entity!.metadata as any).department).toBe('eng') }) it('preserves existing subtype when update() omits it', async () => { const id = await brain.add({ data: 'unchanged subtype', type: NounType.Person, subtype: 'employee' }) await brain.update({ id, metadata: { tag: 'updated' } }) const entity = await brain.get(id) expect(entity!.subtype).toBe('employee') }) it('find({ type, subtype }) hits the fast path and matches', async () => { await brain.add({ data: 'a', type: NounType.Person, subtype: 'employee' }) await brain.add({ data: 'b', type: NounType.Person, subtype: 'employee' }) await brain.add({ data: 'c', type: NounType.Person, subtype: 'customer' }) await brain.add({ data: 'd', type: NounType.Document, subtype: 'employee' }) const employees = await brain.find({ type: NounType.Person, subtype: 'employee' }) expect(employees).toHaveLength(2) for (const r of employees) { expect(r.subtype).toBe('employee') expect(r.type).toBe(NounType.Person) } }) it('find({ subtype: [...] }) matches set membership', async () => { await brain.add({ data: 'a', type: NounType.Person, subtype: 'employee' }) await brain.add({ data: 'b', type: NounType.Person, subtype: 'contractor' }) await brain.add({ data: 'c', type: NounType.Person, subtype: 'customer' }) const both = await brain.find({ type: NounType.Person, subtype: ['employee', 'contractor'] }) expect(both).toHaveLength(2) const subtypes = both.map(r => r.subtype).sort() expect(subtypes).toEqual(['contractor', 'employee']) }) it('Result objects expose subtype at the top level', async () => { await brain.add({ data: 'a', type: NounType.Person, subtype: 'customer' }) const results = await brain.find({ subtype: 'customer' }) expect(results[0].subtype).toBe('customer') expect(results[0].entity?.subtype).toBe('customer') }) }) describe('Layer 1: counts API', () => { beforeEach(async () => { await brain.add({ data: 'a', type: NounType.Person, subtype: 'employee' }) await brain.add({ data: 'b', type: NounType.Person, subtype: 'employee' }) await brain.add({ data: 'c', type: NounType.Person, subtype: 'customer' }) await brain.add({ data: 'd', type: NounType.Person, subtype: 'customer' }) await brain.add({ data: 'e', type: NounType.Person, subtype: 'customer' }) await brain.add({ data: 'f', type: NounType.Document, subtype: 'invoice' }) }) it('counts.bySubtype(type) returns the full breakdown', async () => { const counts = brain.counts.bySubtype(NounType.Person) expect(counts).toEqual({ employee: 2, customer: 3 }) }) it('counts.bySubtype(type, subtype) returns the O(1) point count', async () => { expect(brain.counts.bySubtype(NounType.Person, 'employee')).toBe(2) expect(brain.counts.bySubtype(NounType.Person, 'customer')).toBe(3) expect(brain.counts.bySubtype(NounType.Person, 'never-existed')).toBe(0) }) it('counts.topSubtypes ranks by count, respects N', async () => { const top1 = brain.counts.topSubtypes(NounType.Person, 1) expect(top1).toEqual([['customer', 3]]) const all = brain.counts.topSubtypes(NounType.Person, 10) expect(all).toEqual([ ['customer', 3], ['employee', 2] ]) }) it('brain.subtypesOf(type) returns sorted distinct subtypes', async () => { expect(brain.subtypesOf(NounType.Person)).toEqual(['customer', 'employee']) expect(brain.subtypesOf(NounType.Document)).toEqual(['invoice']) }) it('rollup decrements on delete', async () => { const id = await brain.add({ data: 'g', type: NounType.Person, subtype: 'employee' }) expect(brain.counts.bySubtype(NounType.Person, 'employee')).toBe(3) await brain.remove(id) expect(brain.counts.bySubtype(NounType.Person, 'employee')).toBe(2) }) it('rollup re-routes on subtype change via update', async () => { const id = await brain.add({ data: 'h', type: NounType.Person, subtype: 'employee' }) expect(brain.counts.bySubtype(NounType.Person, 'employee')).toBe(3) expect(brain.counts.bySubtype(NounType.Person, 'customer')).toBe(3) await brain.update({ id, subtype: 'customer' }) expect(brain.counts.bySubtype(NounType.Person, 'employee')).toBe(2) expect(brain.counts.bySubtype(NounType.Person, 'customer')).toBe(4) }) }) describe('Layer 2: trackField() + counts.byField()', () => { it('tracks a metadata field and returns value frequencies', async () => { brain.trackField('status') await brain.add({ data: 'a', type: NounType.Task, metadata: { status: 'todo' } }) await brain.add({ data: 'b', type: NounType.Task, metadata: { status: 'todo' } }) await brain.add({ data: 'c', type: NounType.Task, metadata: { status: 'done' } }) await brain.add({ data: 'd', type: NounType.Task, metadata: { status: 'done' } }) await brain.add({ data: 'e', type: NounType.Task, metadata: { status: 'done' } }) const byStatus = await brain.counts.byField('status') expect(byStatus).toEqual({ todo: 2, done: 3 }) }) it('perType breakdown returns per-NounType counts when filtered', async () => { brain.trackField('status', { perType: true }) await brain.add({ data: 'a', type: NounType.Task, metadata: { status: 'todo' } }) await brain.add({ data: 'b', type: NounType.Task, metadata: { status: 'done' } }) await brain.add({ data: 'c', type: NounType.Event, metadata: { status: 'todo' } }) const taskStatuses = await brain.counts.byField('status', { type: NounType.Task }) expect(taskStatuses).toEqual({ todo: 1, done: 1 }) }) it('throws when per-type breakdown requested but field tracked without perType', async () => { brain.trackField('status') await expect( brain.counts.byField('status', { type: NounType.Task }) ).rejects.toThrow(/perType/) }) it('returns {} for fields never registered', async () => { const result = await brain.counts.byField('unregistered') expect(result).toEqual({}) }) it('values whitelist rejects off-vocabulary writes', async () => { brain.trackField('priority', { values: ['low', 'medium', 'high'] }) await expect( brain.add({ data: 'bad', type: NounType.Task, metadata: { priority: 'urgent' } }) ).rejects.toThrow(/priority.*urgent.*vocabulary/i) }) it('values whitelist accepts on-vocabulary writes', async () => { brain.trackField('priority', { values: ['low', 'medium', 'high'] }) const id = await brain.add({ data: 'ok', type: NounType.Task, metadata: { priority: 'high' } }) const entity = await brain.get(id) expect((entity!.metadata as any).priority).toBe('high') }) }) describe('Layer 3: migrateField()', () => { it('migrates metadata.X → top-level subtype and clears the source', async () => { // Set up the legacy shape: store kind inside metadata. const id1 = await brain.add({ data: 'a', type: NounType.Person, metadata: { kind: 'employee', department: 'eng' } }) const id2 = await brain.add({ data: 'b', type: NounType.Person, metadata: { kind: 'customer' } }) const result = await brain.migrateField({ from: 'metadata.kind', to: 'subtype' }) expect(result.errors).toEqual([]) expect(result.migrated).toBe(2) expect(result.scanned).toBeGreaterThanOrEqual(2) const e1 = await brain.get(id1) expect(e1!.subtype).toBe('employee') expect((e1!.metadata as any).kind).toBeUndefined() expect((e1!.metadata as any).department).toBe('eng') const e2 = await brain.get(id2) expect(e2!.subtype).toBe('customer') expect((e2!.metadata as any).kind).toBeUndefined() }) it('readBoth: true preserves the source field for the deprecation window', async () => { const id = await brain.add({ data: 'a', type: NounType.Person, metadata: { kind: 'employee' } }) const result = await brain.migrateField({ from: 'metadata.kind', to: 'subtype', readBoth: true }) expect(result.migrated).toBe(1) const entity = await brain.get(id) expect(entity!.subtype).toBe('employee') expect((entity!.metadata as any).kind).toBe('employee') }) it('is idempotent — re-running on already-migrated data is a no-op', async () => { await brain.add({ data: 'a', type: NounType.Person, metadata: { kind: 'employee' } }) await brain.migrateField({ from: 'metadata.kind', to: 'subtype' }) const second = await brain.migrateField({ from: 'metadata.kind', to: 'subtype' }) // All entities have already migrated → every entity is skipped on the second pass expect(second.migrated).toBe(0) expect(second.skipped).toBeGreaterThan(0) }) it('migrates data.X → subtype (when data is an object)', async () => { const id = await brain.add({ data: { description: 'thing', kind: 'character' } as any, type: NounType.Person }) const result = await brain.migrateField({ from: 'data.kind', to: 'subtype' }) expect(result.migrated).toBe(1) expect(result.errors).toEqual([]) const entity = await brain.get(id) expect(entity!.subtype).toBe('character') expect((entity!.data as any).description).toBe('thing') expect((entity!.data as any).kind).toBeUndefined() }) it('throws on identical from/to', async () => { await expect( brain.migrateField({ from: 'subtype', to: 'subtype' }) ).rejects.toThrow(/identical/) }) it('rejects unsupported path prefixes', async () => { await expect( brain.migrateField({ from: 'metadata.kind', to: 'connections.foo' }) ).rejects.toThrow(/unsupported path prefix/) }) it('migrates with the rollup tracking the new subtype counts', async () => { await brain.add({ data: 'a', type: NounType.Person, metadata: { kind: 'employee' } }) await brain.add({ data: 'b', type: NounType.Person, metadata: { kind: 'customer' } }) await brain.migrateField({ from: 'metadata.kind', to: 'subtype' }) // Rollup should now reflect the new subtype values expect(brain.counts.bySubtype(NounType.Person)).toEqual({ employee: 1, customer: 1 }) }) }) })