Add tree state functionality
This commit is contained in:
+106
-1
@@ -8,7 +8,16 @@ globalThis.document = dom.window.document;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
|
||||
const { CheckboxTree } = await import('../src/checkbox-tree.js');
|
||||
const {
|
||||
CheckboxTree,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
decodeTreeState,
|
||||
getBit,
|
||||
loadManifest,
|
||||
setBit,
|
||||
validateManifestEvolution
|
||||
} = await import('../src/checkbox-tree.js');
|
||||
|
||||
function createTree(options = {}) {
|
||||
const container = document.createElement('div');
|
||||
@@ -103,3 +112,99 @@ test('rejects duplicate node ids', () => {
|
||||
/Duplicate CheckboxTree node id/
|
||||
);
|
||||
});
|
||||
|
||||
test('bit operations use least-significant-bit-first byte ordering', () => {
|
||||
const bytes = new Uint8Array(2);
|
||||
setBit(bytes, 0, true);
|
||||
setBit(bytes, 7, true);
|
||||
setBit(bytes, 8, true);
|
||||
assert.deepEqual(Array.from(bytes), [129, 1]);
|
||||
assert.equal(getBit(bytes, 0), true);
|
||||
assert.equal(getBit(bytes, 7), true);
|
||||
assert.equal(getBit(bytes, 8), true);
|
||||
assert.equal(getBit(bytes, 16), false);
|
||||
setBit(bytes, 7, false);
|
||||
assert.deepEqual(Array.from(bytes), [1, 1]);
|
||||
});
|
||||
|
||||
test('Base64URL encoding is unpadded, URL-safe, and round trips', () => {
|
||||
const bytes = Uint8Array.from([251, 255, 0]);
|
||||
const encoded = bytesToBase64Url(bytes);
|
||||
assert.equal(encoded, '-_8A');
|
||||
assert.deepEqual(Array.from(base64UrlToBytes(encoded)), Array.from(bytes));
|
||||
assert.equal(bytesToBase64Url(new Uint8Array()), '');
|
||||
assert.throws(() => base64UrlToBytes('not valid!'), /Invalid Base64URL/);
|
||||
});
|
||||
|
||||
test('loads text and JSON manifests while retaining tombstone slots', () => {
|
||||
const text = loadManifest('# tree-state-manifest\nroot\n!deleted\nroot>child\n');
|
||||
assert.equal(text.slots.length, 3);
|
||||
assert.equal(text.slots[1].deleted, true);
|
||||
assert.equal(text.pathToSlot.get('root>child'), 2);
|
||||
|
||||
const json = loadManifest({ schema: 1, nodes: ['renamed', null, 'renamed>child', 'new'] });
|
||||
assert.equal(json.pathToSlot.get('renamed>child'), 2);
|
||||
assert.equal(json.pathToSlot.get('new'), 3);
|
||||
assert.throws(() => loadManifest({ schema: 1, nodes: ['same', 'same'] }), /Duplicate/);
|
||||
});
|
||||
|
||||
test('manifest evolution rejects slot removal and tombstone reuse', () => {
|
||||
const original = { schema: 1, nodes: ['root', null, 'root>child'] };
|
||||
assert.equal(validateManifestEvolution(original, {
|
||||
schema: 1,
|
||||
nodes: ['root', null, 'root>child', 'new']
|
||||
}).valid, true);
|
||||
assert.match(validateManifestEvolution(original, {
|
||||
schema: 1,
|
||||
nodes: ['root', 'reused', 'root>child']
|
||||
}).errors[0], /cannot be reused/);
|
||||
assert.match(validateManifestEvolution(original, {
|
||||
schema: 1,
|
||||
nodes: ['root']
|
||||
}).errors[0], /cannot be removed/);
|
||||
});
|
||||
|
||||
test('serializes and restores checked and expanded state through a fragment', () => {
|
||||
const manifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', null, 'parent>second'] });
|
||||
const first = createTree();
|
||||
checkbox(first.container, 'first').click();
|
||||
first.container.querySelector('.checkbox-tree__toggle').click();
|
||||
const fragment = first.tree.serializeStateToFragment(manifest);
|
||||
assert.equal(fragment, '#v=1&c=Ag&x=AQ');
|
||||
|
||||
const second = createTree({ manifest });
|
||||
const result = second.tree.restoreStateFromLocation(manifest, { hash: fragment });
|
||||
assert.equal(result.applied, true);
|
||||
assert.deepEqual(second.tree.getSelectedIds(), ['first']);
|
||||
assert.equal(second.container.querySelector('.checkbox-tree__toggle').getAttribute('aria-expanded'), 'true');
|
||||
});
|
||||
|
||||
test('compatible manifest changes preserve slots and default appended nodes', () => {
|
||||
const oldManifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] });
|
||||
const first = createTree();
|
||||
checkbox(first.container, 'first').click();
|
||||
const fragment = first.tree.serializeStateToFragment(oldManifest);
|
||||
|
||||
const evolved = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', null, 'parent>second', 'future'] });
|
||||
const second = createTree();
|
||||
const result = second.tree.restoreStateFromLocation(evolved, { hash: fragment });
|
||||
assert.equal(result.applied, true);
|
||||
assert.deepEqual(second.tree.getSelectedIds(), ['first']);
|
||||
assert.deepEqual(second.tree.validateManifest(evolved), {
|
||||
valid: true,
|
||||
unregisteredPaths: [],
|
||||
missingPaths: ['future']
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed, oversized, and unsupported URL state fails safely', () => {
|
||||
const manifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] });
|
||||
assert.equal(decodeTreeState('#v=2&c=AQ', manifest).applied, false);
|
||||
assert.equal(decodeTreeState('#v=1&c=!', manifest).applied, false);
|
||||
assert.equal(decodeTreeState('#v=1&c=AQA', manifest).applied, false);
|
||||
|
||||
const { tree } = createTree({ initiallySelected: true });
|
||||
const result = tree.restoreStateFromLocation(manifest, { hash: '#v=1&c=!' });
|
||||
assert.equal(result.applied, false);
|
||||
assert.deepEqual(tree.getSelectedIds(), ['parent', 'first', 'second']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user