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

472 lines
14 KiB
TypeScript

import {
RhizomeNode,
Lossless,
CustomResolver,
ResolverPlugin,
LastWriteWinsPlugin,
FirstWriteWinsPlugin,
ConcatenationPlugin,
MajorityVotePlugin,
MinPlugin,
MaxPlugin,
PropertyTypes,
CollapsedDelta,
createDelta
} from "../../../../src";
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('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');
}
});
});
});