import { describe, test, expect, beforeEach } from '@jest/globals'; import { RhizomeNode, Lossless, createDelta } from "../../../../src"; import { CollapsedDelta } from "../../../../src/views/lossless"; import { CustomResolver, ResolverPlugin, type DependencyStates } from "../../../../src/views/resolvers/custom-resolvers"; import type { LosslessViewOne } from '@src/views/resolvers/lossless-view'; type PropertyTypes = 'string' | 'number' | 'boolean' | 'object' | 'array'; import { LastWriteWinsPlugin, FirstWriteWinsPlugin, ConcatenationPlugin, MajorityVotePlugin, MinPlugin, MaxPlugin } from "../../../../src/views/resolvers/custom-resolvers"; type PropertyTypes = string | number | boolean | null; describe('Custom Resolvers', () => { let node: RhizomeNode; let lossless: Lossless; beforeEach(() => { node = new RhizomeNode(); lossless = new Lossless(node); }); describe('Built-in Plugins', () => { test('LastWriteWinsPlugin should resolve to most recent value', () => { // First delta with earlier timestamp lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'name', 'first', 'collection') .buildV1() ); // Second delta with later timestamp (should win) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'name', 'second', 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { name: new LastWriteWinsPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.name).toBe('second'); }); test('FirstWriteWinsPlugin should resolve to earliest value', () => { // Later delta (should be ignored by FirstWriteWins) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'name', 'second', 'collection') .buildV1() ); // Earlier delta (should win with FirstWriteWins) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'name', 'first', 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { name: new FirstWriteWinsPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.name).toBe('first'); }); test('ConcatenationPlugin should join string values chronologically', () => { // First tag lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'tags', 'red', 'collection') .buildV1() ); // Second tag (with later timestamp) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(3000) .setProperty('entity1', 'tags', 'blue', 'collection') .buildV1() ); // Third tag (with timestamp between first and second) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'tags', 'green', 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { tags: new ConcatenationPlugin(' ') }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.tags).toBe('red green blue'); }); test('ConcatenationPlugin should handle duplicates', () => { // First tag lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'tags', 'red', 'collection') .buildV1() ); // Duplicate tag with later timestamp lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'tags', 'red', 'collection') // duplicate .buildV1() ); const resolver = new CustomResolver(lossless, { tags: new ConcatenationPlugin(',') }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.tags).toBe('red'); // Should not duplicate }); test('MajorityVotePlugin should resolve to most voted value', () => { // Add 3 votes for 'red' lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'color', 'red', 'collection') .buildV1() ); lossless.ingestDelta( createDelta('user2', 'host1') .withTimestamp(1000) .setProperty('entity1', 'color', 'red', 'collection') .buildV1() ); lossless.ingestDelta( createDelta('user3', 'host1') .withTimestamp(1000) .setProperty('entity1', 'color', 'red', 'collection') .buildV1() ); // Add 2 votes for 'blue' lossless.ingestDelta( createDelta('user4', 'host1') .withTimestamp(1000) .setProperty('entity1', 'color', 'blue', 'collection') .buildV1() ); lossless.ingestDelta( createDelta('user5', 'host1') .withTimestamp(1000) .setProperty('entity1', 'color', 'blue', 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { color: new MajorityVotePlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.color).toBe('red'); // 3 votes vs 2 votes }); test('MinPlugin should resolve to minimum numeric value', () => { // First score (100) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'score', 100, 'collection') .buildV1() ); // Second score (50) - this is the minimum lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'score', 50, 'collection') .buildV1() ); // Third score (75) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(3000) .setProperty('entity1', 'score', 75, 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { score: new MinPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.score).toBe(50); }); test('MaxPlugin should resolve to maximum numeric value', () => { // First score (100) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'score', 100, 'collection') .buildV1() ); // Second score (150) - this is the maximum lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'score', 150, 'collection') .buildV1() ); // Third score (75) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(3000) .setProperty('entity1', 'score', 75, 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { score: new MaxPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.score).toBe(150); }); }); describe('Mixed Plugin Configurations', () => { test('should handle different plugins for different properties', () => { // Add name with different timestamps lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'name', 'old_name', 'collection') .buildV1() ); // Update name with newer timestamp lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'name', 'new_name', 'collection') .buildV1() ); // Add scores lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'score', 100, 'collection') .buildV1() ); // Add another score (MinPlugin will pick the smaller one) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'score', 50, 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { name: new LastWriteWinsPlugin(), // Should resolve to 'new_name' score: new MinPlugin() // Should resolve to 50 }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.name).toBe('new_name'); expect(result!['entity1'].properties.score).toBe(50); }); test('should only include entities with configured properties', () => { // Entity1 has configured property lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'name', 'test', 'collection') .buildV1() ); // Entity2 has non-configured property lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity2', 'other_prop', 'value', 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { name: new LastWriteWinsPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1']).toBeDefined(); expect(result!['entity1'].properties.name).toBe('test'); expect(result!['entity2']).toBeUndefined(); // No configured properties }); }); describe('Plugin Dependencies', () => { test('should detect circular dependencies', () => { // Define state interfaces interface PluginAState { value: string; } interface PluginBState { value: string; } // PluginA depends on PluginB class PluginA implements ResolverPlugin { readonly name = 'a' as const; readonly dependencies = ['b'] as const; initialize(): PluginAState { return { value: 'a' }; } update( currentState: PluginAState, _newValue: unknown, _delta: CollapsedDelta, _dependencies: { b: { value: string } } = { b: { value: '' } } ): PluginAState { return { ...currentState }; } resolve( _state: PluginAState, _dependencies: { b: { value: string } } = { b: { value: '' } } ): string { return 'a'; } } // PluginB depends on PluginA interface PluginBState { value: string; } class PluginB implements ResolverPlugin { readonly name = 'b' as const; readonly dependencies = ['a'] as const; initialize(): PluginBState { return { value: 'b' }; } update( currentState: PluginBState, _newValue: unknown, _delta: CollapsedDelta, _dependencies: { a: unknown } = { a: undefined } ): PluginBState { return currentState; } resolve( _state: PluginBState, _dependencies: { a: unknown } = { a: undefined } ): string { return 'b'; } } expect(() => { new CustomResolver(lossless, { 'a': new PluginA(), 'b': new PluginB() }); }).toThrow('Circular dependency detected'); }); describe('CustomResolver with plugin dependencies', () => { let lossless: LosslessViewOne; // Track plugin execution order const executionOrder: string[] = []; // Track plugin state updates and resolutions const pluginTracker = { first: { updated: false, resolved: false }, second: { updated: false, resolved: false } }; // Define plugin state interface interface PluginState { value: string; updated: boolean; resolved: boolean; } class FirstPlugin implements ResolverPlugin { readonly name = 'first' as const; readonly dependencies = [] as const; initialize(): PluginState { console.log('First plugin initialized'); executionOrder.push('first-init'); return { value: '', updated: false, resolved: false }; } update( currentState: PluginState, newValue: PropertyTypes, _delta: CollapsedDelta, _dependencies: Record = {} ): PluginState { console.log('First plugin updated with value:', newValue); executionOrder.push('first-update'); pluginTracker.first.updated = true; return { ...currentState, value: String(newValue), updated: true }; } resolve(state: PluginState): string { console.log('First plugin resolved with value:', state.value); executionOrder.push('first-resolve'); pluginTracker.first.resolved = true; return state.value; } } class SecondPlugin implements ResolverPlugin { readonly name = 'second' as const; readonly dependencies = ['first'] as const; initialize(): PluginState { console.log('Second plugin initialized'); executionOrder.push('second-init'); return { value: '', updated: false, resolved: false }; } update( currentState: PluginState, newValue: PropertyTypes, _delta: CollapsedDelta, dependencies: { first: unknown } ): PluginState { console.log('Second plugin updated with value:', newValue); executionOrder.push('second-update'); pluginTracker.second.updated = true; // Access the first plugin's resolved state const firstState = dependencies.first as PluginState; if (firstState) { executionOrder.push('second-has-first-state'); console.log('Second plugin has access to first plugin state:', firstState); } return { ...currentState, value: `${newValue}-${firstState?.value || 'unknown'}`, updated: true }; } resolve(state: PluginState): string { console.log('Second plugin resolved with value:', state.value); executionOrder.push('second-resolve'); pluginTracker.second.resolved = true; return state.value; } } // Create resolver with dependency order: first -> second console.log('Creating resolver with plugins'); // Create test plugins const firstPlugin = new FirstPlugin(); const secondPlugin = new SecondPlugin(); // Create resolver with test plugins const testResolver = new CustomResolver({ first: firstPlugin, second: secondPlugin }); // Verify the execution order array is empty before processing expect(executionOrder).not.toContain('first-init'); expect(executionOrder).not.toContain('second-init'); expect(executionOrder).toHaveLength(0); // Create and ingest test data const delta = createDelta('test1', 'host1') .withTimestamp(1000) .setProperty('test1', 'first', 'first', 'prop1') .setProperty('test1', 'second', 'second', 'prop2') .buildV1(); lossless.ingestDelta(delta); // Resolve the view const result = testResolver.resolve(); // Verify the result expect(result).toBeDefined(); if (!result) return; const testEntity = result['test1']; expect(testEntity).toBeDefined(); if (!testEntity) return; // Check if properties exist expect(testEntity.properties).toBeDefined(); // Check if plugins were resolved expect(pluginTracker.first.resolved).toBe(true); expect(pluginTracker.second.resolved).toBe(true); // Check if second plugin has access to first plugin's state expect(executionOrder).toContain('second-has-first-state'); // Check if first plugin was processed before second const firstUpdateIndex = executionOrder.indexOf('first-update'); const secondUpdateIndex = executionOrder.indexOf('second-update'); expect(firstUpdateIndex).not.toBe(-1); expect(secondUpdateIndex).not.toBe(-1); expect(firstUpdateIndex).toBeLessThan(secondUpdateIndex); // Verify initialization order (first should be initialized before second) const firstInitIndex = executionOrder.indexOf('first-init'); const secondInitIndex = executionOrder.indexOf('second-init'); expect(firstInitIndex).not.toBe(-1); expect(secondInitIndex).not.toBe(-1); expect(firstInitIndex).toBeLessThan(secondInitIndex); // Check if resolve was called in the right order const firstResolveIndex = executionOrder.indexOf('first-resolve'); const secondResolveIndex = executionOrder.indexOf('second-resolve'); expect(firstResolveIndex).not.toBe(-1); expect(secondResolveIndex).not.toBe(-1); expect(firstResolveIndex).toBeLessThan(secondResolveIndex); expect(firstInitIndex).toBeLessThan(secondInitIndex); // Verify update order (first should be updated before second) expect(firstUpdateIndex).toBeGreaterThanOrEqual(0); expect(secondUpdateIndex).toBeGreaterThanOrEqual(0); expect(firstUpdateIndex).toBeLessThan(secondUpdateIndex); // Verify resolve order (first should be resolved before second) expect(firstResolveIndex).toBeGreaterThanOrEqual(0); expect(secondResolveIndex).toBeGreaterThanOrEqual(0); expect(firstResolveIndex).toBeLessThan(secondResolveIndex); // Check if second plugin could access first plugin's state expect(executionOrder).toContain('second-has-first-state'); // Check resolved values if they exist if (testEntity.properties.first) { expect(testEntity.properties.first).toBe('first'); } if (testEntity.properties.second) { // Second plugin's value is 'second-' expect(testEntity.properties.second).toBe('second-first'); } }); test('should allow plugins to depend on other plugin states', () => { // A plugin that applies a discount to a price class DiscountedPricePlugin implements ResolverPlugin<{ price: number }> { name = 'discounted-price'; dependencies = ['discount']; initialize() { return { price: 0 }; } update( currentState: { price: number }, newValue: PropertyTypes, _delta: CollapsedDelta, _allStates?: Record ) { if (typeof newValue === 'number') { return { price: newValue }; } return currentState; } resolve( state: { price: number }, allStates?: Record ): number | undefined { // Get discount from another plugin's state const discountState = allStates?.['discount'] as { value: number } | undefined; if (discountState) { return state.price * (1 - (discountState.value / 100)); } return state.price; } } // A simple discount plugin class DiscountPlugin implements ResolverPlugin<{ value: number }> { name = 'discount'; initialize() { return { value: 0 }; } update( currentState: { value: number }, newValue: PropertyTypes, _delta: CollapsedDelta, _allStates?: Record ) { if (typeof newValue === 'number') { return { value: newValue }; } return currentState; } resolve( state: { value: number }, _allStates?: Record ): number { return state.value; } } // Set base price lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('product1', 'price', 100, 'products') .buildV1() ); // Set discount (20%) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('product1', 'discount', 20, 'products') .buildV1() ); const resolver = new CustomResolver(lossless, { price: new DiscountedPricePlugin(), discount: new DiscountPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['product1'].properties.price).toBe(80); // 100 - 20% expect(result!['product1'].properties.discount).toBe(20); }); }); describe('Custom Plugin Implementation', () => { test('should work with custom plugin', () => { // Custom plugin that counts the number of updates class CountPlugin implements ResolverPlugin<{count: number}> { name = 'count'; initialize() { return {count: 0}; } update(currentState: {count: number}, _newValue: PropertyTypes, _delta: CollapsedDelta) { return {count: currentState.count + 1}; } resolve(state: {count: number}): PropertyTypes { return state.count; } } // First update lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'updates', 'first', 'collection') .buildV1() ); // Second update lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'updates', 'second', 'collection') .buildV1() ); // Third update lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(3000) .setProperty('entity1', 'updates', 'third', 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { updates: new CountPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.updates).toBe(3); }); test('should work with stateful custom plugin', () => { // Custom plugin that calculates running average class RunningAveragePlugin implements ResolverPlugin<{sum: number, count: number}> { name = 'running-average'; initialize() { return {sum: 0, count: 0}; } update(currentState: {sum: number, count: number}, newValue: PropertyTypes, _delta: CollapsedDelta) { if (typeof newValue === 'number') { return { sum: currentState.sum + newValue, count: currentState.count + 1 }; } return currentState; } resolve(state: {sum: number, count: number}): PropertyTypes { return state.count > 0 ? state.sum / state.count : 0; } } // First score (10) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'score', 10, 'collection') .buildV1() ); // Second score (20) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(2000) .setProperty('entity1', 'score', 20, 'collection') .buildV1() ); // Third score (30) lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(3000) .setProperty('entity1', 'score', 30, 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { score: new RunningAveragePlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(result!['entity1'].properties.score).toBe(20); // (10 + 20 + 30) / 3 }); }); describe('Edge Cases', () => { test('should handle empty delta sets', () => { const resolver = new CustomResolver(lossless, { name: new LastWriteWinsPlugin() }); const result = resolver.resolve(); expect(result).toBeDefined(); expect(Object.keys(result!)).toHaveLength(0); }); test('should handle non-matching property types gracefully', () => { // Add string value to numeric plugin lossless.ingestDelta( createDelta('user1', 'host1') .withTimestamp(1000) .setProperty('entity1', 'score', 'not_a_number', 'collection') .buildV1() ); const resolver = new CustomResolver(lossless, { score: new MinPlugin() // Expects numeric values }); const result = resolver.resolve(); expect(result).toBeDefined(); // The entity might not be present in the result if no properties were resolved if (result!['entity1']) { expect(result!['entity1'].properties).toBeDefined(); expect(result!['entity1'].properties).not.toHaveProperty('score'); } }); }); });