rhizome-node/__tests__/unit/views/resolvers/custom-resolvers.test.ts

832 lines
26 KiB
TypeScript

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<PluginAState, 'b'> {
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<PluginBState, 'a'> {
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<PluginState, never> {
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<string, never> = {}
): 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<PluginState, 'first'> {
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-<first plugin's value>'
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<string, unknown>
) {
if (typeof newValue === 'number') {
return { price: newValue };
}
return currentState;
}
resolve(
state: { price: number },
allStates?: Record<string, unknown>
): 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<string, unknown>
) {
if (typeof newValue === 'number') {
return { value: newValue };
}
return currentState;
}
resolve(
state: { value: number },
_allStates?: Record<string, unknown>
): 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');
}
});
});
});