Files
checkbox-tree/test/checkbox-tree.test.js
T

363 lines
15 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'https://example.test/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.Element = dom.window.Element;
globalThis.localStorage = dom.window.localStorage;
const {
CheckboxTree,
base64UrlToBytes,
bytesToBase64Url,
decodeTreeState,
decodeAdaptiveBitset,
encodeAdaptiveBitset,
getBit,
loadManifest,
setBit,
validateManifestEvolution
} = await import('../src/checkbox-tree.js');
function createTree(options = {}) {
const container = document.createElement('div');
document.body.replaceChildren(container);
const tree = new CheckboxTree(container, options);
tree.setData([
{
id: 'parent',
label: 'Parent',
metadata: { kind: 'parent' },
children: [
{ id: 'first', label: 'First', metadata: { kind: 'leaf' } },
{ id: 'second', label: 'Second', metadata: { kind: 'leaf' } }
]
}
]);
return { container, tree };
}
function checkbox(container, id) {
return container.querySelector(`.checkbox-tree__checkbox[data-node-id="${id}"]`);
}
test('selecting a parent selects all descendants and emits selected nodes', () => {
let change;
const { container, tree } = createTree({ onSelectionChange: event => { change = event; } });
const parent = checkbox(container, 'parent');
parent.checked = true;
parent.dispatchEvent(new window.Event('change', { bubbles: true }));
assert.deepEqual(tree.getSelectedIds(), ['parent', 'first', 'second']);
assert.deepEqual(change.selectedIds, ['parent', 'first', 'second']);
assert.equal(change.selectedNodes[1].metadata.kind, 'leaf');
});
test('partial child selection makes the parent indeterminate', () => {
const { container, tree } = createTree();
const first = checkbox(container, 'first');
first.checked = true;
first.dispatchEvent(new window.Event('change', { bubbles: true }));
const parent = checkbox(container, 'parent');
assert.equal(parent.checked, false);
assert.equal(parent.indeterminate, true);
assert.deepEqual(tree.getSelectedIds(), ['first']);
});
test('can initially select every selectable node', () => {
const { tree } = createTree({ initiallySelected: true });
assert.deepEqual(tree.getSelectedIds(), ['parent', 'first', 'second']);
});
test('persists selection and collapsed branches per configured key', () => {
localStorage.clear();
const first = createTree({ storageKey: 'example-tree' });
checkbox(first.container, 'first').click();
const toggle = first.container.querySelector('.checkbox-tree__toggle');
toggle.click();
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
const second = createTree({ storageKey: 'example-tree' });
assert.deepEqual(second.tree.getSelectedIds(), ['first']);
assert.equal(second.container.querySelector('.checkbox-tree__toggle').getAttribute('aria-expanded'), 'true');
});
test('supports independent instances and removes listeners on destroy', () => {
const firstContainer = document.createElement('div');
const secondContainer = document.createElement('div');
document.body.replaceChildren(firstContainer, secondContainer);
const first = new CheckboxTree(firstContainer);
const second = new CheckboxTree(secondContainer);
const data = [{ id: 'node', label: 'Node' }];
first.setData(data);
second.setData(data);
checkbox(firstContainer, 'node').click();
assert.deepEqual(first.getSelectedIds(), ['node']);
assert.deepEqual(second.getSelectedIds(), []);
first.destroy();
assert.equal(firstContainer.childElementCount, 0);
assert.equal(firstContainer.classList.contains('checkbox-tree'), false);
});
test('rejects duplicate node ids', () => {
const container = document.createElement('div');
const tree = new CheckboxTree(container);
assert.throws(
() => tree.setData([{ id: 'same', label: 'One' }, { id: 'same', label: 'Two' }]),
/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('adaptive bitsets choose sparse enabled and disabled representations', () => {
const mostlyDisabled = new Map(Array.from(
{ length: 628 },
(_, slot) => [slot, slot === 2 || slot === 400]
));
const sparseEnabled = encodeAdaptiveBitset(mostlyDisabled, 628);
assert.equal(sparseEnabled[0], 1);
const enabled = decodeAdaptiveBitset(sparseEnabled, 628);
assert.equal(enabled(2), true);
assert.equal(enabled(3), false);
assert.equal(enabled(400), true);
const nearlyAllEnabled = new Map(Array.from({ length: 100 }, (_, slot) => [slot, slot !== 7]));
const sparseDisabled = encodeAdaptiveBitset(nearlyAllEnabled, 100);
assert.equal(sparseDisabled[0], 2);
const enabledExcept = decodeAdaptiveBitset(sparseDisabled, 105);
assert.equal(enabledExcept(6), true);
assert.equal(enabledExcept(7), false);
assert.equal(enabledExcept(99), true);
assert.equal(enabledExcept(100), false);
});
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=AAI&x=AAE');
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('tiered encoding stores only child differences and restores nested state', () => {
const manifest = loadManifest({
schema: 1,
nodes: ['parent', 'parent>first', 'parent>second']
});
const first = createTree({ stateEncoding: 'tiered' });
checkbox(first.container, 'first').click();
first.container.querySelector('.checkbox-tree__toggle').click();
const fragment = first.tree.serializeStateToFragment(manifest);
const params = new URLSearchParams(fragment.slice(1));
assert.equal(params.has('c1'), false);
assert.equal(params.has('c2'), true);
assert.equal(params.has('x1'), true);
assert.equal(params.has('x2'), false);
assert.equal(params.has('c'), false);
const second = createTree({ manifest, stateEncoding: 'tiered' });
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('tiered expansion treats collapsed as the baseline at every depth', () => {
const data = [{
id: 'root',
label: 'Root',
children: [{
id: 'branch',
label: 'Branch',
children: [{ id: 'leaf', label: 'Leaf' }]
}]
}];
const manifest = loadManifest({
schema: 1,
nodes: ['root', 'root>branch', 'root>branch>leaf']
});
const container = document.createElement('div');
document.body.replaceChildren(container);
const first = new CheckboxTree(container, { stateEncoding: 'tiered' });
first.setData(data);
first.itemForNode('root').querySelector('.checkbox-tree__toggle').click();
const fragment = first.serializeStateToFragment(manifest);
const params = new URLSearchParams(fragment.slice(1));
assert.equal(params.has('x1'), true);
assert.equal(params.has('x2'), false);
const restoredContainer = document.createElement('div');
document.body.replaceChildren(restoredContainer);
const restored = new CheckboxTree(restoredContainer, { manifest, stateEncoding: 'tiered' });
restored.setData(data);
restored.restoreStateFromLocation(manifest, { hash: fragment });
assert.equal(restored.itemForNode('root').querySelector('.checkbox-tree__toggle').getAttribute('aria-expanded'), 'true');
assert.equal(restored.itemForNode('branch').querySelector('.checkbox-tree__toggle').getAttribute('aria-expanded'), 'false');
});
test('tiered encoding uses false beneath a non-selectable parent', () => {
const container = document.createElement('div');
document.body.replaceChildren(container);
const manifest = loadManifest({
schema: 1,
nodes: ['root', 'root>group', 'root>group>leaf']
});
const data = [{
id: 'root',
label: 'Root',
children: [{
id: 'group',
label: 'Group',
selectable: false,
children: [{ id: 'leaf', label: 'Leaf' }]
}]
}];
const first = new CheckboxTree(container, { stateEncoding: 'tiered' });
first.setData(data);
checkbox(container, 'leaf').click();
const fragment = first.serializeStateToFragment(manifest);
assert.equal(new URLSearchParams(fragment.slice(1)).has('c3'), true);
const restoredContainer = document.createElement('div');
document.body.replaceChildren(restoredContainer);
const restored = new CheckboxTree(restoredContainer, { manifest, stateEncoding: 'tiered' });
restored.setData(data);
restored.restoreStateFromLocation(manifest, { hash: fragment });
assert.deepEqual(restored.getSelectedIds(), ['leaf']);
});
test('tiered URLs default nodes appended beyond their coverage to false', () => {
const originalManifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] });
const first = createTree({ initiallySelected: true, stateEncoding: 'tiered' });
const fragment = first.tree.serializeStateToFragment(originalManifest);
const container = document.createElement('div');
document.body.replaceChildren(container);
const evolvedManifest = loadManifest({
schema: 1,
nodes: ['parent', 'parent>first', 'parent>second', 'parent>new']
});
const restored = new CheckboxTree(container, { manifest: evolvedManifest, stateEncoding: 'tiered' });
restored.setData([{
id: 'parent',
label: 'Parent',
children: [
{ id: 'first', label: 'First' },
{ id: 'second', label: 'Second' },
{ id: 'new', label: 'New' }
]
}]);
restored.restoreStateFromLocation(evolvedManifest, { hash: fragment });
assert.deepEqual(restored.getSelectedIds(), ['first', 'second']);
assert.equal(checkbox(container, 'parent').indeterminate, true);
});
test('dense encoding can be selected independently of the URL', () => {
const manifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] });
const first = createTree({ stateEncoding: 'dense' });
checkbox(first.container, 'first').click();
const fragment = first.tree.serializeStateToFragment(manifest);
assert.equal(fragment, '#v=1&c=Ag');
const second = createTree({ manifest, stateEncoding: 'dense' });
second.tree.restoreStateFromLocation(manifest, { hash: fragment });
assert.deepEqual(second.tree.getSelectedIds(), ['first']);
assert.throws(() => createTree({ stateEncoding: 'unknown' }), /Unsupported CheckboxTree state encoding/);
});
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=Aw', manifest).applied, false);
assert.equal(decodeTreeState('#v=1&n=3&c1=AQA', manifest).applied, false);
assert.equal(decodeTreeState('#v=1&c=AQA', manifest, 'tiered').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']);
});