472 lines
14 KiB
TypeScript
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');
|
|
}
|
|
});
|
|
});
|
|
}); |