diff --git a/README.md b/README.md index b25f7a1..c9f1013 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ CDN usage: | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | | Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory`, `calculateDamage`, `createCooldownController`, `updateStatusEffects`, `createQuestMachine`, `createWaveSpawner`, `createSoundManager`, `createInputManager`, `createSaveManager`, `createScreenTransition` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts`, `gameplay/combat.ts`, `gameplay/questMachine.ts`, `gameplay/waveSpawner.ts`, `gameplay/soundManager.ts`, `gameplay/inputManager.ts`, `gameplay/saveManager.ts`, `gameplay/screenTransitions.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/waveSpawner.ts`, `examples/soundManager.ts`, `examples/inputManager.ts`, `examples/saveManager.ts`, `examples/screenTransitions.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance`, `kmpSearch`, `rabinKarp`, `boyerMooreSearch`, `buildSuffixArray`, `longestCommonSubsequence`, `diffStrings` | `search/*.ts` | `examples/search.ts` | -| Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff`, `flatten`, `unflatten` | `data/*.ts` | `examples/jsonDiff.ts` | +| Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `diffJsonAdvanced`, `applyJsonDiff`, `applyJsonDiffSelective`, `flatten`, `unflatten`, `diffTree`, `applyTreeDiff` | `data/*.ts` | `examples/jsonDiff.ts`, `examples/treeDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | | Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts` | diff --git a/ROADMAP.md b/ROADMAP.md index 05f721d..33ffa9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,7 +85,7 @@ - **Data pipelines & utilities** - [x] Flatten/unflatten helpers for nested structures - [x] Pagination utilities for client-side paging - - [ ] Advanced diff tooling (tree diff, selective patches) +- [x] Advanced diff tooling (tree diff, selective patches) - **Visual & simulation tools** - [ ] Color manipulation helpers (RGB/HSL conversion, blending) - [ ] Force-directed graph layout diff --git a/docs/index.d.ts b/docs/index.d.ts index 4265a17..8af024a 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -2697,6 +2697,15 @@ export function diffJsonAdvanced( next: JsonValue, options?: DiffJsonAdvancedOptions ): JsonDiffOperation[]; +export interface ApplyJsonDiffOptions { + shouldApply?: (operation: JsonDiffOperation) => boolean; + pathFilter?: (path: JsonPathSegment[]) => boolean; +} +export function applyJsonDiffSelective( + value: T, + diff: JsonDiffOperation[], + options?: ApplyJsonDiffOptions +): JsonValue; /** * Flattens nested structures into key/value pairs. @@ -2743,6 +2752,58 @@ export interface PaginationResult { } export function paginate(options: PaginateOptions): PaginationResult; +/** + * Diffs tree structures while preserving node identity. + * Use for: UI virtual DOM reconciliation, scene graphs, hierarchical state. + * Import: data/treeDiff.ts + */ +export interface TreeNode { + id: string; + value?: TValue; + children?: TreeNode[]; +} +export interface TreeInsertOperation { + type: 'insert'; + id: string; + parentId: string | null; + index: number; + node: TreeNode; +} +export interface TreeRemoveOperation { + type: 'remove'; + id: string; + parentId: string | null; +} +export interface TreeMoveOperation { + type: 'move'; + id: string; + parentId: string | null; + index: number; +} +export interface TreeUpdateOperation { + type: 'update'; + id: string; + value: TValue | undefined; + hasValue: boolean; +} +export type TreeDiffOperation = + | TreeInsertOperation + | TreeRemoveOperation + | TreeMoveOperation + | TreeUpdateOperation; +export interface TreeDiffOptions { + isEqual?: (previous: TreeNode, next: TreeNode) => boolean; +} +export function diffTree( + previous: ReadonlyArray>, + next: ReadonlyArray>, + options?: TreeDiffOptions +): TreeDiffOperation[]; +export function applyTreeDiff( + tree: ReadonlyArray>, + diff: ReadonlyArray> +): TreeNode[]; + /** * Deep clone structured data. * Use for: immutability, snapshots, undo buffers. diff --git a/docs/list.md b/docs/list.md index 7e5b039..cd0b018 100644 --- a/docs/list.md +++ b/docs/list.md @@ -104,6 +104,7 @@ Unflatten - Convert flat to nested structure Group By - Organize data by property Deep Clone - Recursive object copying Pagination - Client-side data paging +Tree Diff - Compare hierarchical structures 🎨 VISUAL & ANIMATION diff --git a/examples/jsonDiff.ts b/examples/jsonDiff.ts index 62b8348..df2b614 100644 --- a/examples/jsonDiff.ts +++ b/examples/jsonDiff.ts @@ -1,4 +1,11 @@ -import { applyJsonDiff, diffJson, diffJsonAdvanced, flatten, unflatten } from '../src/index.js'; +import { + applyJsonDiff, + applyJsonDiffSelective, + diffJson, + diffJsonAdvanced, + flatten, + unflatten, +} from '../src/index.js'; const previous = { status: 'idle', jobs: ['ingest', 'transform'] }; const next = { status: 'running', jobs: ['ingest', 'transform', 'export'] }; @@ -18,3 +25,8 @@ const selectivePatch = diffJsonAdvanced(previous, next, { ignoreKeys: ['jobs'], }); console.log('Selective patch (ignore jobs):', selectivePatch); + +const selectivelyApplied = applyJsonDiffSelective(previous, patch, { + pathFilter: (path) => path.join('.') !== 'jobs', +}); +console.log('Selective apply (ignore jobs):', selectivelyApplied); diff --git a/examples/treeDiff.ts b/examples/treeDiff.ts new file mode 100644 index 0000000..eed2742 --- /dev/null +++ b/examples/treeDiff.ts @@ -0,0 +1,34 @@ +import { applyTreeDiff, diffTree } from '../src/index.js'; + +const previous = [ + { + id: 'root', + value: { label: 'Root' }, + children: [ + { id: 'a', value: { label: 'A' }, children: [{ id: 'a-1', value: { label: 'A-1' } }] }, + { id: 'b', value: { label: 'B' } }, + ], + }, +]; + +const next = [ + { + id: 'root', + value: { label: 'Root (updated)' }, + children: [ + { id: 'b', value: { label: 'B' } }, + { + id: 'wrapper', + value: { label: 'Wrapper' }, + children: [{ id: 'a', value: { label: 'A', active: true }, children: [] }], + }, + { id: 'c', value: { label: 'C' } }, + ], + }, +]; + +const diff = diffTree(previous, next); +console.log('Tree diff operations:', diff); + +const patched = applyTreeDiff(previous, diff); +console.log('Patched tree:', JSON.stringify(patched, null, 2)); diff --git a/src/data/jsonDiff.ts b/src/data/jsonDiff.ts index 24d1cf4..6ef083e 100644 --- a/src/data/jsonDiff.ts +++ b/src/data/jsonDiff.ts @@ -101,9 +101,26 @@ export function diffJsonAdvanced( * Applies a JSON diff to a value and returns a new structure. * Useful for: reconstructing snapshots, applying remote patches, optimistic updates. */ +export interface ApplyJsonDiffOptions { + shouldApply?: (operation: JsonDiffOperation) => boolean; + pathFilter?: (path: JsonPathSegment[]) => boolean; +} + export function applyJsonDiff(value: T, diff: JsonDiffOperation[]): JsonValue { + return applyJsonDiffSelective(value, diff); +} + +export function applyJsonDiffSelective( + value: T, + diff: JsonDiffOperation[], + options: ApplyJsonDiffOptions = {} +): JsonValue { + const predicate = createDiffPredicate(options); let result: JsonValue = deepClone(value); for (const operation of diff) { + if (!predicate(operation)) { + continue; + } result = applyOperation(result, operation); } return result; @@ -197,6 +214,21 @@ function applyObjectOperation( return root; } +function createDiffPredicate(options: ApplyJsonDiffOptions): (operation: JsonDiffOperation) => boolean { + if (!options.shouldApply && !options.pathFilter) { + return () => true; + } + return (operation) => { + if (options.pathFilter && !options.pathFilter(operation.path)) { + return false; + } + if (options.shouldApply && !options.shouldApply(operation)) { + return false; + } + return true; + }; +} + function resolveParent(value: JsonValue, path: JsonPathSegment[], allowCreate: boolean): JsonValue | null { if (path.length === 0) { return value; diff --git a/src/data/treeDiff.ts b/src/data/treeDiff.ts new file mode 100644 index 0000000..056aca8 --- /dev/null +++ b/src/data/treeDiff.ts @@ -0,0 +1,545 @@ +import { deepClone } from './deepClone.js'; + +export interface TreeNode { + id: string; + value?: TValue; + children?: TreeNode[]; +} + +export interface TreeInsertOperation { + type: 'insert'; + id: string; + parentId: string | null; + index: number; + node: TreeNode; +} + +export interface TreeRemoveOperation { + type: 'remove'; + id: string; + parentId: string | null; +} + +export interface TreeMoveOperation { + type: 'move'; + id: string; + parentId: string | null; + index: number; +} + +export interface TreeUpdateOperation { + type: 'update'; + id: string; + value: TValue | undefined; + hasValue: boolean; +} + +export type TreeDiffOperation = + | TreeInsertOperation + | TreeRemoveOperation + | TreeMoveOperation + | TreeUpdateOperation; + +export interface TreeDiffOptions { + /** + * Custom equality comparator for node values. Defaults to deep structural comparison. + */ + isEqual?: (previous: TreeNode, next: TreeNode) => boolean; +} + +interface IndexedNode { + node: TreeNode; + parentId: string | null; + index: number; + depth: number; +} + +export function diffTree( + previous: ReadonlyArray>, + next: ReadonlyArray>, + options: TreeDiffOptions = {} +): TreeDiffOperation[] { + const previousIndex = indexTree(previous); + const nextIndex = indexTree(next); + + const previousIds = new Set(previousIndex.keys()); + const nextIds = new Set(nextIndex.keys()); + + const removedIds = new Set(); + for (const id of previousIds) { + if (!nextIds.has(id)) { + removedIds.add(id); + } + } + + const insertedIds = new Set(); + for (const id of nextIds) { + if (!previousIds.has(id)) { + insertedIds.add(id); + } + } + + const operations: TreeDiffOperation[] = []; + + const removeOps = buildRemovalOperations(previousIndex, removedIds); + operations.push(...removeOps); + + const insertOps = buildInsertOperations(nextIndex, insertedIds); + operations.push(...insertOps); + + const moveOps = buildMoveOperations(previousIndex, nextIndex, insertedIds, removedIds); + operations.push(...moveOps); + + const updateOps = buildUpdateOperations(previousIndex, nextIndex, insertedIds, removedIds, options.isEqual); + operations.push(...updateOps); + + return operations; +} + +export function applyTreeDiff( + tree: ReadonlyArray>, + diff: ReadonlyArray> +): TreeNode[] { + const result = cloneTree(tree); + + for (const operation of diff) { + switch (operation.type) { + case 'remove': + removeNode(result, operation); + break; + case 'insert': + insertNode(result, operation); + break; + case 'move': + moveNode(result, operation); + break; + case 'update': + updateNode(result, operation); + break; + default: { + // Exhaustive check to satisfy TypeScript. + const exhaustive: never = operation; + throw new Error(`Unsupported tree diff operation: ${JSON.stringify(exhaustive)}`); + } + } + } + + return result; +} + +function indexTree(nodes: ReadonlyArray>): Map> { + const index = new Map>(); + const stack: Array<{ nodes: ReadonlyArray>; parentId: string | null; depth: number }> = [ + { nodes, parentId: null, depth: 0 }, + ]; + + while (stack.length > 0) { + const { nodes: currentNodes, parentId, depth } = stack.pop()!; + currentNodes.forEach((node, nodeIndex) => { + if (index.has(node.id)) { + throw new Error(`Duplicate node id detected: ${node.id}`); + } + index.set(node.id, { + node, + parentId, + index: nodeIndex, + depth, + }); + + const children = node.children ?? []; + if (children.length > 0) { + stack.push({ nodes: children, parentId: node.id, depth: depth + 1 }); + } + }); + } + + return index; +} + +function buildRemovalOperations( + previousIndex: Map>, + removedIds: Set +): TreeRemoveOperation[] { + const ids: string[] = []; + for (const id of removedIds) { + const meta = previousIndex.get(id); + if (!meta) { + continue; + } + if (meta.parentId !== null && removedIds.has(meta.parentId)) { + continue; + } + ids.push(id); + } + + ids.sort((a, b) => { + const depthA = previousIndex.get(a)?.depth ?? 0; + const depthB = previousIndex.get(b)?.depth ?? 0; + if (depthA !== depthB) { + return depthB - depthA; + } + const parentA = previousIndex.get(a)?.parentId ?? ''; + const parentB = previousIndex.get(b)?.parentId ?? ''; + if (parentA !== parentB) { + return parentA.localeCompare(parentB); + } + const indexA = previousIndex.get(a)?.index ?? 0; + const indexB = previousIndex.get(b)?.index ?? 0; + return indexA - indexB; + }); + + return ids.map((id) => { + const meta = previousIndex.get(id)!; + return { + type: 'remove' as const, + id, + parentId: meta.parentId, + }; + }); +} + +function buildInsertOperations( + nextIndex: Map>, + insertedIds: Set +): TreeInsertOperation[] { + const ids: string[] = []; + for (const id of insertedIds) { + const meta = nextIndex.get(id); + if (!meta) { + continue; + } + if (meta.parentId !== null && insertedIds.has(meta.parentId)) { + continue; + } + ids.push(id); + } + + ids.sort((a, b) => { + const depthA = nextIndex.get(a)?.depth ?? 0; + const depthB = nextIndex.get(b)?.depth ?? 0; + if (depthA !== depthB) { + return depthA - depthB; + } + const parentA = nextIndex.get(a)?.parentId ?? ''; + const parentB = nextIndex.get(b)?.parentId ?? ''; + if (parentA !== parentB) { + return parentA.localeCompare(parentB); + } + const indexA = nextIndex.get(a)?.index ?? 0; + const indexB = nextIndex.get(b)?.index ?? 0; + return indexA - indexB; + }); + + return ids.map((id) => { + const meta = nextIndex.get(id)!; + const prunedNode = pruneInsertedNode(meta.node, insertedIds); + return { + type: 'insert' as const, + id, + parentId: meta.parentId, + index: meta.index, + node: prunedNode, + }; + }); +} + +function buildMoveOperations( + previousIndex: Map>, + nextIndex: Map>, + insertedIds: Set, + removedIds: Set +): TreeMoveOperation[] { + const moves: TreeMoveOperation[] = []; + + for (const [id, nextMeta] of nextIndex.entries()) { + if (!previousIndex.has(id)) { + continue; + } + if (insertedIds.has(id) || removedIds.has(id)) { + continue; + } + const prevMeta = previousIndex.get(id)!; + const parentChanged = prevMeta.parentId !== nextMeta.parentId; + const indexChanged = prevMeta.index !== nextMeta.index; + if (!parentChanged && !indexChanged) { + continue; + } + moves.push({ + type: 'move', + id, + parentId: nextMeta.parentId, + index: nextMeta.index, + }); + } + + moves.sort((a, b) => { + const depthA = nextIndex.get(a.id)?.depth ?? 0; + const depthB = nextIndex.get(b.id)?.depth ?? 0; + if (depthA !== depthB) { + return depthA - depthB; + } + const parentA = (nextIndex.get(a.id)?.parentId ?? ''); + const parentB = (nextIndex.get(b.id)?.parentId ?? ''); + if (parentA !== parentB) { + return parentA.localeCompare(parentB); + } + const indexA = nextIndex.get(a.id)?.index ?? 0; + const indexB = nextIndex.get(b.id)?.index ?? 0; + return indexA - indexB; + }); + + return moves; +} + +function buildUpdateOperations( + previousIndex: Map>, + nextIndex: Map>, + insertedIds: Set, + removedIds: Set, + isEqual?: (previous: TreeNode, next: TreeNode) => boolean +): TreeUpdateOperation[] { + const updates: TreeUpdateOperation[] = []; + const comparator = isEqual ?? defaultNodeEqual; + + for (const [id, nextMeta] of nextIndex.entries()) { + if (!previousIndex.has(id)) { + continue; + } + if (insertedIds.has(id) || removedIds.has(id)) { + continue; + } + const prevMeta = previousIndex.get(id)!; + if (comparator(prevMeta.node, nextMeta.node)) { + continue; + } + const hasValue = Object.prototype.hasOwnProperty.call(nextMeta.node, 'value'); + const value = hasValue ? deepClone(nextMeta.node.value) : undefined; + updates.push({ + type: 'update', + id, + value, + hasValue, + }); + } + + return updates; +} + +function pruneInsertedNode( + node: TreeNode, + insertedIds: Set +): TreeNode { + const cloned: TreeNode = { id: node.id }; + if (Object.prototype.hasOwnProperty.call(node, 'value')) { + cloned.value = deepClone(node.value); + } + + if (node.children && node.children.length > 0) { + const children = node.children + .filter((child) => insertedIds.has(child.id)) + .map((child) => pruneInsertedNode(child, insertedIds)); + if (children.length > 0) { + cloned.children = children; + } + } + + return cloned; +} + +function cloneTree(nodes: ReadonlyArray>): TreeNode[] { + return nodes.map((node) => cloneNode(node)); +} + +function cloneNode(node: TreeNode): TreeNode { + const cloned: TreeNode = { id: node.id }; + if (Object.prototype.hasOwnProperty.call(node, 'value')) { + cloned.value = deepClone(node.value); + } + if (node.children && node.children.length > 0) { + cloned.children = node.children.map((child) => cloneNode(child)); + } + return cloned; +} + +function removeNode(tree: TreeNode[], operation: TreeRemoveOperation): void { + const result = findNode(tree, operation.id); + if (!result) { + throw new Error(`Cannot remove node with id "${operation.id}" because it does not exist.`); + } + const { siblings, index } = result; + siblings.splice(index, 1); +} + +function insertNode(tree: TreeNode[], operation: TreeInsertOperation): void { + const targetSiblings = resolveSiblings(tree, operation.parentId); + const node = cloneNode(operation.node); + const index = clampIndex(operation.index, targetSiblings.length); + targetSiblings.splice(index, 0, node); +} + +function moveNode(tree: TreeNode[], operation: TreeMoveOperation): void { + const current = findNode(tree, operation.id); + if (!current) { + throw new Error(`Cannot move node with id "${operation.id}" because it does not exist.`); + } + current.siblings.splice(current.index, 1); + + const targetSiblings = resolveSiblings(tree, operation.parentId); + let targetIndex = clampIndex(operation.index, targetSiblings.length); + if (targetSiblings === current.siblings && current.index < targetIndex) { + targetIndex -= 1; + } + targetSiblings.splice(targetIndex, 0, current.node); +} + +function updateNode(tree: TreeNode[], operation: TreeUpdateOperation): void { + const current = findNode(tree, operation.id); + if (!current) { + throw new Error(`Cannot update node with id "${operation.id}" because it does not exist.`); + } + if (operation.hasValue) { + current.node.value = deepClone(operation.value); + } else { + delete current.node.value; + } +} + +interface LocatedNode { + node: TreeNode; + siblings: TreeNode[]; + index: number; +} + +function findNode( + nodes: TreeNode[], + id: string +): LocatedNode | null { + const stack: Array<{ siblings: TreeNode[]; index: number }> = []; + stack.push({ siblings: nodes, index: 0 }); + + while (stack.length > 0) { + const frame = stack.pop()!; + const { siblings, index } = frame; + for (let cursor = index; cursor < siblings.length; cursor += 1) { + const node = siblings[cursor]; + if (node.id === id) { + return { node, siblings, index: cursor }; + } + if (node.children && node.children.length > 0) { + stack.push({ siblings: node.children, index: 0 }); + } + } + } + + return null; +} + +function resolveSiblings( + tree: TreeNode[], + parentId: string | null +): TreeNode[] { + if (parentId === null) { + return tree; + } + + const located = findNode(tree, parentId); + if (!located) { + throw new Error(`Cannot resolve parent with id "${parentId}".`); + } + if (!located.node.children) { + located.node.children = []; + } + return located.node.children; +} + +function clampIndex(index: number, length: number): number { + if (!Number.isInteger(index) || index < 0) { + return 0; + } + if (index > length) { + return length; + } + return index; +} + +function defaultNodeEqual(prev: TreeNode, next: TreeNode): boolean { + return deepEqual(prev.value, next.value); +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) { + return true; + } + if (typeof a !== typeof b) { + return false; + } + if (a === null || b === null) { + return false; + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index += 1) { + if (!deepEqual(a[index], b[index])) { + return false; + } + } + return true; + } + + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) { + return false; + } + for (const [key, value] of a.entries()) { + if (!b.has(key) || !deepEqual(value, b.get(key))) { + return false; + } + } + return true; + } + + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) { + return false; + } + for (const value of a.values()) { + let found = false; + for (const candidate of b.values()) { + if (deepEqual(value, candidate)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + if (typeof a === 'object' && typeof b === 'object') { + const keysA = Object.keys(a as Record); + const keysB = Object.keys(b as Record); + if (keysA.length !== keysB.length) { + return false; + } + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key)) { + return false; + } + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false; + } + } + return true; + } + + return false; +} diff --git a/src/index.ts b/src/index.ts index e8de8ad..23b929c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,9 +82,12 @@ export const examples = { groupBy: 'examples/jsonDiff.ts', diffJson: 'examples/jsonDiff.ts', applyJsonDiff: 'examples/jsonDiff.ts', + applyJsonDiffSelective: 'examples/jsonDiff.ts', flatten: 'examples/jsonDiff.ts', unflatten: 'examples/jsonDiff.ts', paginate: 'examples/pagination.ts', + diffTree: 'examples/treeDiff.ts', + applyTreeDiff: 'examples/treeDiff.ts', }, performance: { debounce: 'examples/requestDedup.ts', @@ -932,7 +935,7 @@ export { groupBy } from './data/groupBy.js'; /** * JSON diff and patch helpers for nested structures. */ -export { diffJson, diffJsonAdvanced, applyJsonDiff } from './data/jsonDiff.js'; +export { diffJson, diffJsonAdvanced, applyJsonDiff, applyJsonDiffSelective } from './data/jsonDiff.js'; /** * Flatten/unflatten nested structures. @@ -957,8 +960,24 @@ export type { JsonPrimitive, JsonValue, DiffJsonAdvancedOptions, + ApplyJsonDiffOptions, } from './data/jsonDiff.js'; +/** + * Tree diff helpers for hierarchical data. + */ +export { diffTree, applyTreeDiff } from './data/treeDiff.js'; + +export type { + TreeNode, + TreeDiffOperation, + TreeInsertOperation, + TreeRemoveOperation, + TreeMoveOperation, + TreeUpdateOperation, + TreeDiffOptions, +} from './data/treeDiff.js'; + // ============================================================================ // 📈 GRAPH ALGORITHMS // ============================================================================ diff --git a/tests/index.test.ts b/tests/index.test.ts index b02d902..0b385a4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -24,6 +24,9 @@ describe('package entry point', () => { expect(examples.performance.createObjectPool).toBe('examples/objectPool.ts'); expect(examples.performance.fisherYatesShuffle).toBe('examples/fisherYates.ts'); expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts'); + expect(examples.data.applyJsonDiffSelective).toBe('examples/jsonDiff.ts'); + expect(examples.data.diffTree).toBe('examples/treeDiff.ts'); + expect(examples.data.applyTreeDiff).toBe('examples/treeDiff.ts'); expect(examples.gameplay.createDeltaTimeManager).toBe('examples/deltaTime.ts'); expect(examples.gameplay.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); expect(examples.gameplay.createCamera2D).toBe('examples/camera2D.ts'); @@ -100,6 +103,20 @@ describe('package entry point', () => { | 'fisherYatesShuffle' >(); + expectTypeOf>().toEqualTypeOf< + | 'diff' + | 'deepClone' + | 'groupBy' + | 'diffJson' + | 'applyJsonDiff' + | 'applyJsonDiffSelective' + | 'flatten' + | 'unflatten' + | 'paginate' + | 'diffTree' + | 'applyTreeDiff' + >(); + expectTypeOf>().toEqualTypeOf< | 'fuzzySearch' | 'fuzzyScore' diff --git a/tests/jsonDiff.test.ts b/tests/jsonDiff.test.ts index 1421aef..9b54146 100644 --- a/tests/jsonDiff.test.ts +++ b/tests/jsonDiff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { applyJsonDiff, diffJson, diffJsonAdvanced } from '../src/data/jsonDiff.js'; +import { applyJsonDiff, applyJsonDiffSelective, diffJson, diffJsonAdvanced } from '../src/data/jsonDiff.js'; describe('diffJson', () => { it('produces replace operations for primitive changes at root', () => { @@ -67,4 +67,35 @@ describe('diffJson', () => { }); expect(diff).toEqual([{ op: 'replace', path: ['metrics', 'cpu'], value: 15 }]); }); + + it('applies patches selectively based on path', () => { + const previous = { + settings: { theme: 'light', debug: false }, + counter: 1, + }; + const next = { + settings: { theme: 'dark', debug: true }, + counter: 2, + }; + + const diff = diffJson(previous, next); + + const filtered = applyJsonDiffSelective(previous, diff, { + pathFilter: (path) => !(path.length === 2 && path[0] === 'settings' && path[1] === 'debug'), + }); + + expect(filtered).toEqual({ + settings: { theme: 'dark', debug: false }, + counter: 2, + }); + + const allowed = applyJsonDiffSelective(previous, diff, { + shouldApply: (operation) => operation.path[0] === 'settings', + }); + + expect(allowed).toEqual({ + settings: { theme: 'dark', debug: true }, + counter: 1, + }); + }); }); diff --git a/tests/treeDiff.test.ts b/tests/treeDiff.test.ts new file mode 100644 index 0000000..7144db2 --- /dev/null +++ b/tests/treeDiff.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { applyTreeDiff, diffTree } from '../src/index.js'; + +describe('tree diff utilities', () => { + it('computes structural changes and applies patches', () => { + const previous = [ + { + id: 'root', + value: { label: 'Root' }, + children: [ + { id: 'a', value: { label: 'A' }, children: [{ id: 'a-1', value: { label: 'Leaf' } }] }, + { id: 'b', value: { label: 'B' } }, + ], + }, + ]; + + const next = [ + { + id: 'root', + value: { label: 'Root', version: 2 }, + children: [ + { id: 'b', value: { label: 'B' } }, + { + id: 'wrapper', + value: { label: 'Wrapper' }, + children: [{ id: 'a', value: { label: 'A', active: true }, children: [] }], + }, + { id: 'c', value: { label: 'C' } }, + ], + }, + ]; + + const diff = diffTree(previous, next); + + expect(diff).toEqual([ + { type: 'remove', id: 'a-1', parentId: 'a' }, + { + type: 'insert', + id: 'wrapper', + parentId: 'root', + index: 1, + node: { id: 'wrapper', value: { label: 'Wrapper' } }, + }, + { type: 'insert', id: 'c', parentId: 'root', index: 2, node: { id: 'c', value: { label: 'C' } } }, + { type: 'move', id: 'b', parentId: 'root', index: 0 }, + { type: 'move', id: 'a', parentId: 'wrapper', index: 0 }, + { type: 'update', id: 'root', value: { label: 'Root', version: 2 }, hasValue: true }, + { type: 'update', id: 'a', value: { label: 'A', active: true }, hasValue: true }, + ]); + + const patched = applyTreeDiff(previous, diff); + expect(patched).toEqual(next); + }); + + it('handles moves to newly inserted parents and value removals', () => { + const previous = [ + { + id: 'root', + children: [ + { id: 'orphan', value: { label: 'Orphan' } }, + { id: 'stable', value: { label: 'Stable', meta: { flag: true } } }, + ], + }, + ]; + + const next = [ + { + id: 'root', + children: [ + { + id: 'container', + children: [{ id: 'orphan' }], + }, + { id: 'stable', value: { label: 'Stable' } }, + ], + }, + ]; + + const diff = diffTree(previous, next); + + expect(diff).toEqual([ + { + type: 'insert', + id: 'container', + parentId: 'root', + index: 0, + node: { id: 'container' }, + }, + { type: 'move', id: 'orphan', parentId: 'container', index: 0 }, + { type: 'update', id: 'stable', value: { label: 'Stable' }, hasValue: true }, + { type: 'update', id: 'orphan', value: undefined, hasValue: false }, + ]); + + const patched = applyTreeDiff(previous, diff); + expect(patched).toEqual(next); + }); +});