336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
|
|
jest.mock('uuid', () => ({
|
||
|
|
v4: jest.fn(),
|
||
|
|
v7: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { v4 as uuidv4, v7 as uuidv7 } from 'uuid';
|
||
|
|
import type { LabelEntry, SessionTreeEntry } from '../src/modules/netaclaw/session-tree/types.js';
|
||
|
|
import {
|
||
|
|
createSessionTreeEntryId,
|
||
|
|
createSessionTreeSessionId,
|
||
|
|
} from '../src/modules/netaclaw/session-tree/id.js';
|
||
|
|
import {
|
||
|
|
ROOT_PARENT_KEY,
|
||
|
|
buildEntryIndex,
|
||
|
|
findCommonAncestorEntryId,
|
||
|
|
getPathToLeaf,
|
||
|
|
groupChildrenByParent,
|
||
|
|
resolveLatestLabels,
|
||
|
|
} from '../src/modules/netaclaw/session-tree/path.js';
|
||
|
|
|
||
|
|
describe('session tree path helpers', () => {
|
||
|
|
const mockedUuidV4 = uuidv4 as unknown as jest.Mock<string, []>;
|
||
|
|
const mockedUuidV7 = uuidv7 as unknown as jest.Mock<string, []>;
|
||
|
|
|
||
|
|
const createMessageEntry = (
|
||
|
|
id: string,
|
||
|
|
parentId: string | null,
|
||
|
|
timestamp: string,
|
||
|
|
): SessionTreeEntry => ({
|
||
|
|
id,
|
||
|
|
parentId,
|
||
|
|
timestamp,
|
||
|
|
type: 'message',
|
||
|
|
message: {
|
||
|
|
role: 'user',
|
||
|
|
content: `${id}-content`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
mockedUuidV4.mockReset();
|
||
|
|
mockedUuidV7.mockReset();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('createSessionTreeSessionId returns a non-empty string', () => {
|
||
|
|
mockedUuidV7.mockReturnValue('session-v7-id');
|
||
|
|
|
||
|
|
expect(createSessionTreeSessionId()).toBe('session-v7-id');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('createSessionTreeEntryId returns a non-empty string', () => {
|
||
|
|
mockedUuidV4.mockReturnValue('12345678-1234-1234-1234-1234567890ab');
|
||
|
|
|
||
|
|
const entryId = createSessionTreeEntryId();
|
||
|
|
|
||
|
|
expect(entryId).toBe('entry_123456781234');
|
||
|
|
expect(entryId.length).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('createSessionTreeEntryId retries when the generated id already exists', () => {
|
||
|
|
mockedUuidV4
|
||
|
|
.mockReturnValueOnce('12345678-1234-1234-1234-1234567890ab')
|
||
|
|
.mockReturnValueOnce('abcdefab-cdef-abcd-efab-cdefabcdefab');
|
||
|
|
|
||
|
|
const existingIds = new Set(['entry_123456781234']);
|
||
|
|
|
||
|
|
const entryId = createSessionTreeEntryId(existingIds);
|
||
|
|
|
||
|
|
expect(entryId).toBe('entry_abcdefabcdef');
|
||
|
|
expect(existingIds.has(entryId)).toBe(false);
|
||
|
|
expect(mockedUuidV4).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exports the canonical root parent key', () => {
|
||
|
|
expect(ROOT_PARENT_KEY).toBe('__root__');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('getPathToLeaf returns a root-to-leaf path', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('b', 'a', '2026-04-19T00:01:00.000Z'),
|
||
|
|
createMessageEntry('c', 'b', '2026-04-19T00:02:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(getPathToLeaf(entries, 'c').map(entry => entry.id)).toEqual(['a', 'b', 'c']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('getPathToLeaf throws when the leaf entry is missing', () => {
|
||
|
|
const entries = [createMessageEntry('a', null, '2026-04-19T00:00:00.000Z')];
|
||
|
|
|
||
|
|
expect(() => getPathToLeaf(entries, 'missing')).toThrow('Entry missing not found');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('getPathToLeaf throws when a cycle is detected', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry('a', 'c', '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('b', 'a', '2026-04-19T00:01:00.000Z'),
|
||
|
|
createMessageEntry('c', 'b', '2026-04-19T00:02:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(() => getPathToLeaf(entries, 'c')).toThrow('Cycle detected');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('getPathToLeaf throws when a parent entry is missing', () => {
|
||
|
|
const entries = [createMessageEntry('c', 'missing-parent', '2026-04-19T00:02:00.000Z')];
|
||
|
|
|
||
|
|
expect(() => getPathToLeaf(entries, 'c')).toThrow('Parent entry missing-parent not found');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('findCommonAncestorEntryId returns the deepest common ancestor', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('b', 'a', '2026-04-19T00:01:00.000Z'),
|
||
|
|
createMessageEntry('c', 'b', '2026-04-19T00:02:00.000Z'),
|
||
|
|
createMessageEntry('d', 'b', '2026-04-19T00:03:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(findCommonAncestorEntryId(entries, 'c', 'd')).toBe('b');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('groupChildrenByParent collects root entries under ROOT_PARENT_KEY', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('b', 'a', '2026-04-19T00:01:00.000Z'),
|
||
|
|
createMessageEntry('c', null, '2026-04-19T00:02:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(groupChildrenByParent(entries)).toEqual({
|
||
|
|
[ROOT_PARENT_KEY]: ['a', 'c'],
|
||
|
|
a: ['b'],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('groupChildrenByParent throws when a real entry id matches ROOT_PARENT_KEY', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry(ROOT_PARENT_KEY, null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('child', ROOT_PARENT_KEY, '2026-04-19T00:01:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(() => groupChildrenByParent(entries)).toThrow(
|
||
|
|
`Entry id ${ROOT_PARENT_KEY} conflicts with ROOT_PARENT_KEY`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('resolveLatestLabels keeps the later label for the same target', () => {
|
||
|
|
const entries: SessionTreeEntry[] = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
{
|
||
|
|
id: 'label-1',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: 'initial',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
{
|
||
|
|
id: 'label-2',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: 'latest',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(resolveLatestLabels(entries)).toEqual({
|
||
|
|
a: {
|
||
|
|
label: 'latest',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
entryId: 'label-2',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('resolveLatestLabels keeps the newest label when an older label appears later in the array', () => {
|
||
|
|
const entries: SessionTreeEntry[] = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
{
|
||
|
|
id: 'label-new',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: 'latest',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
{
|
||
|
|
id: 'label-old',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: 'stale',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(resolveLatestLabels(entries)).toEqual({
|
||
|
|
a: {
|
||
|
|
label: 'latest',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
entryId: 'label-new',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('resolveLatestLabels clears the target label when label is undefined', () => {
|
||
|
|
const entries: SessionTreeEntry[] = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
{
|
||
|
|
id: 'label-1',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:01:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: 'initial',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
{
|
||
|
|
id: 'label-3',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:03:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: undefined,
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(resolveLatestLabels(entries)).toEqual({});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('resolveLatestLabels ignores an older clear record that appears later in the array', () => {
|
||
|
|
const entries: SessionTreeEntry[] = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
{
|
||
|
|
id: 'label-new',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:03:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: 'latest',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
{
|
||
|
|
id: 'label-old-clear',
|
||
|
|
parentId: 'a',
|
||
|
|
timestamp: '2026-04-19T00:02:00.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'a',
|
||
|
|
label: undefined,
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(resolveLatestLabels(entries)).toEqual({
|
||
|
|
a: {
|
||
|
|
label: 'latest',
|
||
|
|
timestamp: '2026-04-19T00:03:00.000Z',
|
||
|
|
entryId: 'label-new',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('buildEntryIndex returns an id keyed map', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry('a', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('b', 'a', '2026-04-19T00:01:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
expect(buildEntryIndex(entries)).toEqual({
|
||
|
|
a: entries[0],
|
||
|
|
b: entries[1],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('buildEntryIndex stores __proto__ and constructor ids without prototype pollution', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry('__proto__', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('constructor', null, '2026-04-19T00:01:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
const index = buildEntryIndex(entries);
|
||
|
|
|
||
|
|
expect(index.__proto__).toBe(entries[0]);
|
||
|
|
expect(index.constructor).toBe(entries[1]);
|
||
|
|
expect(Object.getPrototypeOf(index)).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('groupChildrenByParent stores __proto__ and constructor parent ids without prototype pollution', () => {
|
||
|
|
const entries = [
|
||
|
|
createMessageEntry('__proto__', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('constructor', '__proto__', '2026-04-19T00:01:00.000Z'),
|
||
|
|
createMessageEntry('leaf', 'constructor', '2026-04-19T00:02:00.000Z'),
|
||
|
|
];
|
||
|
|
|
||
|
|
const groups = groupChildrenByParent(entries);
|
||
|
|
|
||
|
|
expect(groups[ROOT_PARENT_KEY]).toEqual(['__proto__']);
|
||
|
|
expect(groups.__proto__).toEqual(['constructor']);
|
||
|
|
expect(groups.constructor).toEqual(['leaf']);
|
||
|
|
expect(Object.getPrototypeOf(groups)).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('resolveLatestLabels stores __proto__ and constructor target ids without prototype pollution', () => {
|
||
|
|
const entries: SessionTreeEntry[] = [
|
||
|
|
createMessageEntry('__proto__', null, '2026-04-19T00:00:00.000Z'),
|
||
|
|
createMessageEntry('constructor', null, '2026-04-19T00:00:01.000Z'),
|
||
|
|
{
|
||
|
|
id: 'label-proto',
|
||
|
|
parentId: '__proto__',
|
||
|
|
timestamp: '2026-04-19T00:00:02.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: '__proto__',
|
||
|
|
label: 'proto-label',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
{
|
||
|
|
id: 'label-constructor',
|
||
|
|
parentId: 'constructor',
|
||
|
|
timestamp: '2026-04-19T00:00:03.000Z',
|
||
|
|
type: 'label',
|
||
|
|
targetId: 'constructor',
|
||
|
|
label: 'constructor-label',
|
||
|
|
} satisfies LabelEntry,
|
||
|
|
];
|
||
|
|
|
||
|
|
const labels = resolveLatestLabels(entries);
|
||
|
|
|
||
|
|
expect(labels.__proto__).toEqual({
|
||
|
|
label: 'proto-label',
|
||
|
|
timestamp: '2026-04-19T00:00:02.000Z',
|
||
|
|
entryId: 'label-proto',
|
||
|
|
});
|
||
|
|
expect(labels.constructor).toEqual({
|
||
|
|
label: 'constructor-label',
|
||
|
|
timestamp: '2026-04-19T00:00:03.000Z',
|
||
|
|
entryId: 'label-constructor',
|
||
|
|
});
|
||
|
|
expect(Object.getPrototypeOf(labels)).toBeNull();
|
||
|
|
});
|
||
|
|
});
|