brainy/tests/unit/versioning/VersionDiff.test.ts
David Snelling c488fa82cc feat: add entity versioning system with critical bug fixes (v5.3.0)
Entity Versioning (NEW):
- Add complete entity versioning API (brain.versions.*) with 18 methods
- Content-addressable storage with SHA-256 deduplication
- Git-style version control: save, restore, compare, undo, prune
- Auto-versioning augmentation with pattern-based filtering
- Branch-isolated version histories
- Complete integration tests and API documentation

Critical Bug Fixes:
- Fix commit() not updating branch refs (brainy.ts:2385)
  - Root cause: Passed "heads/main" which normalized to "refs/heads/heads/main"
  - Impact: All Git-style versioning features were broken
  - Fix: Pass branch name directly for correct normalization
- Fix VFS entities missing isVFSEntity flag
  - Add isVFSEntity: true to all VFS files/folders for filtering
  - Resolves pollution of semantic search with filesystem entities
  - Updated in writeFile(), mkdir(), and root directory init

Implementation:
- src/versioning/VersionManager.ts - Core versioning engine
- src/versioning/VersionStorage.ts - Content-addressable storage
- src/versioning/VersionIndex.ts - Metadata indexing
- src/versioning/VersionDiff.ts - Version comparison
- src/versioning/VersioningAPI.ts - Public API interface
- src/augmentations/versioningAugmentation.ts - Auto-versioning
- tests/integration/versioning.test.ts - Full integration tests
- tests/unit/versioning/ - Unit test suite

Documentation:
- Complete Entity Versioning API section in docs/api/README.md
- VFS entity filtering guide with examples
- Updated "What's New" section for v5.3.0
- Strategy docs for both critical bugs

Test Results:
- 1168 tests passing
- Build: PASSING (no TypeScript errors)
- Integration tests: ALL PASSING

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:19:02 -08:00

538 lines
13 KiB
TypeScript

/**
* VersionDiff Unit Tests (v5.3.0)
*
* Tests deep object comparison:
* - Added fields
* - Removed fields
* - Modified fields
* - Type changes
* - Nested object comparison
* - Array comparison
*/
import { describe, it, expect } from 'vitest'
import { compareEntityVersions } from '../../../src/versioning/VersionDiff.js'
import type { NounMetadata } from '../../../src/coreTypes.js'
describe('VersionDiff', () => {
describe('compareEntityVersions()', () => {
it('should detect added fields', () => {
const from: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: { email: 'alice@example.com' }
}
const to: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: {
email: 'alice@example.com',
city: 'NYC',
age: 30
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'user-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.added.length).toBe(2)
expect(diff.added.some(c => c.path.includes('city'))).toBe(true)
expect(diff.added.some(c => c.path.includes('age'))).toBe(true)
expect(diff.added.find(c => c.path.includes('city'))?.newValue).toBe('NYC')
})
it('should detect removed fields', () => {
const from: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: {
email: 'alice@example.com',
city: 'NYC',
age: 30
}
}
const to: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: { email: 'alice@example.com' }
}
const diff = compareEntityVersions(from, to, {
entityId: 'user-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.removed.length).toBe(2)
expect(diff.removed.some(c => c.path.includes('city'))).toBe(true)
expect(diff.removed.some(c => c.path.includes('age'))).toBe(true)
expect(diff.removed.find(c => c.path.includes('city'))?.oldValue).toBe('NYC')
})
it('should detect modified fields', () => {
const from: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: {
email: 'alice@example.com',
status: 'active'
}
}
const to: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice Smith',
metadata: {
email: 'alice.smith@example.com',
status: 'active'
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'user-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.modified.length).toBe(2)
const nameChange = diff.modified.find(c => c.path.includes('name'))
expect(nameChange?.oldValue).toBe('Alice')
expect(nameChange?.newValue).toBe('Alice Smith')
const emailChange = diff.modified.find(c => c.path.includes('email'))
expect(emailChange?.oldValue).toBe('alice@example.com')
expect(emailChange?.newValue).toBe('alice.smith@example.com')
})
it('should detect type changes', () => {
const from: NounMetadata = {
id: 'config-1',
type: 'thing',
name: 'Config',
metadata: { value: '100' }
}
const to: NounMetadata = {
id: 'config-1',
type: 'thing',
name: 'Config',
metadata: { value: 100 }
}
const diff = compareEntityVersions(from, to, {
entityId: 'config-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.typeChanged.length).toBe(1)
const typeChange = diff.typeChanged[0]
expect(typeChange.path).toContain('value')
expect(typeChange.oldType).toBe('string')
expect(typeChange.newType).toBe('number')
expect(typeChange.oldValue).toBe('100')
expect(typeChange.newValue).toBe(100)
})
it('should detect identical versions', () => {
const entity: NounMetadata = {
id: 'doc-1',
type: 'document',
name: 'Doc',
metadata: { content: 'Unchanged' }
}
const diff = compareEntityVersions(entity, entity, {
entityId: 'doc-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.identical).toBe(true)
expect(diff.totalChanges).toBe(0)
expect(diff.added).toHaveLength(0)
expect(diff.removed).toHaveLength(0)
expect(diff.modified).toHaveLength(0)
})
it('should handle nested object changes', () => {
const from: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: {
address: {
street: '123 Main St',
city: 'NYC'
}
}
}
const to: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: {
address: {
street: '456 Oak Ave',
city: 'NYC',
zip: '10001'
}
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'user-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.modified.some(c => c.path.includes('address.street'))).toBe(true)
expect(diff.added.some(c => c.path.includes('address.zip'))).toBe(true)
const streetChange = diff.modified.find(c => c.path.includes('address.street'))
expect(streetChange?.oldValue).toBe('123 Main St')
expect(streetChange?.newValue).toBe('456 Oak Ave')
})
it('should handle array changes', () => {
const from: NounMetadata = {
id: 'doc-1',
type: 'document',
name: 'Doc',
metadata: {
tags: ['a', 'b', 'c']
}
}
const to: NounMetadata = {
id: 'doc-1',
type: 'document',
name: 'Doc',
metadata: {
tags: ['a', 'b', 'd']
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'doc-1',
fromVersion: 1,
toVersion: 2
})
// Array element change detected as modification
expect(diff.totalChanges).toBeGreaterThan(0)
})
it('should handle null and undefined', () => {
const from: NounMetadata = {
id: 'test-1',
type: 'thing',
name: 'Test',
metadata: {
a: null,
b: undefined,
c: 'value'
}
}
const to: NounMetadata = {
id: 'test-1',
type: 'thing',
name: 'Test',
metadata: {
a: 'now-value',
b: null,
c: 'value'
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'test-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.totalChanges).toBeGreaterThan(0)
})
it('should ignore specified fields', () => {
const from: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: {
email: 'alice@example.com',
lastModified: 1000,
internal: 'ignore-me'
}
}
const to: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice Smith',
metadata: {
email: 'alice@example.com',
lastModified: 2000,
internal: 'changed'
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'user-1',
fromVersion: 1,
toVersion: 2,
ignoreFields: ['lastModified', 'internal']
})
// Should only detect name change
expect(diff.totalChanges).toBe(1)
expect(diff.modified.some(c => c.path.includes('name'))).toBe(true)
expect(diff.modified.some(c => c.path.includes('lastModified'))).toBe(false)
expect(diff.modified.some(c => c.path.includes('internal'))).toBe(false)
})
it('should respect maxDepth option', () => {
const from: NounMetadata = {
id: 'test-1',
type: 'thing',
name: 'Test',
metadata: {
level1: {
level2: {
level3: {
level4: 'deep-value'
}
}
}
}
}
const to: NounMetadata = {
id: 'test-1',
type: 'thing',
name: 'Test',
metadata: {
level1: {
level2: {
level3: {
level4: 'changed-value'
}
}
}
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'test-1',
fromVersion: 1,
toVersion: 2,
maxDepth: 2 // Stop at level2
})
// Should detect change at metadata.level1.level2 level
expect(diff.totalChanges).toBeGreaterThan(0)
})
it('should handle empty objects', () => {
const from: NounMetadata = {
id: 'test-1',
type: 'thing',
name: 'Test',
metadata: {}
}
const to: NounMetadata = {
id: 'test-1',
type: 'thing',
name: 'Test',
metadata: { value: 123 }
}
const diff = compareEntityVersions(from, to, {
entityId: 'test-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.added.length).toBe(1)
expect(diff.added[0].newValue).toBe(123)
})
it('should calculate total changes correctly', () => {
const from: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice',
metadata: {
email: 'alice@example.com',
status: 'active',
age: 30
}
}
const to: NounMetadata = {
id: 'user-1',
type: 'user',
name: 'Alice Smith',
metadata: {
email: 'alice.smith@example.com',
city: 'NYC'
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'user-1',
fromVersion: 1,
toVersion: 2
})
const expectedTotal =
diff.added.length +
diff.removed.length +
diff.modified.length +
diff.typeChanged.length
expect(diff.totalChanges).toBe(expectedTotal)
})
it('should handle complex real-world changes', () => {
const from: NounMetadata = {
id: 'project-1',
type: 'thing',
name: 'My Project',
metadata: {
description: 'Old description',
status: 'draft',
team: ['alice', 'bob'],
config: {
theme: 'light',
notifications: true
},
createdAt: 1000
}
}
const to: NounMetadata = {
id: 'project-1',
type: 'thing',
name: 'My Awesome Project',
metadata: {
description: 'New description',
status: 'active',
team: ['alice', 'bob', 'charlie'],
config: {
theme: 'dark',
notifications: true,
language: 'en'
},
createdAt: 1000,
updatedAt: 2000
}
}
const diff = compareEntityVersions(from, to, {
entityId: 'project-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.totalChanges).toBeGreaterThan(0)
expect(diff.modified.length).toBeGreaterThan(0)
expect(diff.added.length).toBeGreaterThan(0)
const nameChange = diff.modified.find(c => c.path.includes('name'))
expect(nameChange?.newValue).toBe('My Awesome Project')
})
it('should handle boolean changes', () => {
const from: NounMetadata = {
id: 'feature-1',
type: 'thing',
name: 'Feature',
metadata: { enabled: false }
}
const to: NounMetadata = {
id: 'feature-1',
type: 'thing',
name: 'Feature',
metadata: { enabled: true }
}
const diff = compareEntityVersions(from, to, {
entityId: 'feature-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.modified.length).toBe(1)
expect(diff.modified[0].oldValue).toBe(false)
expect(diff.modified[0].newValue).toBe(true)
})
it('should handle number changes', () => {
const from: NounMetadata = {
id: 'counter-1',
type: 'thing',
name: 'Counter',
metadata: { count: 10 }
}
const to: NounMetadata = {
id: 'counter-1',
type: 'thing',
name: 'Counter',
metadata: { count: 20 }
}
const diff = compareEntityVersions(from, to, {
entityId: 'counter-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.modified.length).toBe(1)
expect(diff.modified[0].oldValue).toBe(10)
expect(diff.modified[0].newValue).toBe(20)
})
it('should handle date/timestamp changes', () => {
const from: NounMetadata = {
id: 'event-1',
type: 'thing',
name: 'Event',
metadata: { timestamp: 1000 }
}
const to: NounMetadata = {
id: 'event-1',
type: 'thing',
name: 'Event',
metadata: { timestamp: 2000 }
}
const diff = compareEntityVersions(from, to, {
entityId: 'event-1',
fromVersion: 1,
toVersion: 2
})
expect(diff.modified.length).toBe(1)
expect(diff.modified[0].path).toContain('timestamp')
})
})
})