diff --git a/README.md b/README.md index 0103aa1e..cf8f1603 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ For **detailed type definitions**, refer to the [data.d.ts](src/display/data-sch
### `update(options)` -Updates the properties of objects rendered on the canvas. By default, only the changed properties are applied, but you can precisely control the update behavior using the `refresh` or `arrayMerge` options. +Updates the properties of objects rendered on the canvas. By default, only the changed properties are applied, but you can precisely control the update behavior using the `refresh` or `mergeStrategy` options. #### **`Options`** - `path` (optional, string) - Selector for the object to which the event will be applied, following [jsonpath](https://github.com/JSONPath-Plus/JSONPath) syntax. @@ -234,9 +234,9 @@ Updates the properties of objects rendered on the canvas. By default, only the c - `changes` (optional, object) - New properties to apply (e.g., color, text visibility). If the `refresh` option is set to `true`, this can be omitted. - `history` (optional, boolean \| string) - Determines whether to record changes made by this `update` method in the `undoRedoManager`. If a string that matches the historyId of a previously saved record is provided, the two records will be merged into a single undo/redo step. - `relativeTransform` (optional, boolean) - Determines whether to use relative values for `position`, `rotation`, and `angle`. If `true`, the provided values will be added to the object's values. -- `arrayMerge` (optional, string) - Determines how to merge array properties. The default is `'merge'`. - - `'merge'` (default): Merges the target and source arrays. - - `'replace'`: Completely replaces the target array with the source array. Useful for forcing a specific state. +- `mergeStrategy` (optional, string) - Determines how to apply the `changes` object to the existing properties. The default is `'merge'`. + - `'merge'` (default): Deep merges the `changes` object into the existing properties. Individual properties within objects are updated. + - `'replace'`: Replaces the top-level properties specified in `changes` entirely. This is useful for undo operations or for completely resetting a complex property like `style` or `components` to a specific state. - `refresh` (optional, boolean) - If set to `true`, all property handlers are forcibly re-executed and the object is "refreshed" even if the values in `changes` are the same as before. This is useful when child objects need to be recalculated due to changes in the parent. Default is `false`. diff --git a/README_KR.md b/README_KR.md index 4ac15c13..07122590 100644 --- a/README_KR.md +++ b/README_KR.md @@ -225,7 +225,7 @@ draw method가 요구하는 **데이터 구조**입니다.
### `update(options)` -캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 arrayMerge 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다. +캔버스에 렌더링된 객체의 속성을 업데이트합니다. 기본적으로 변경된 속성만 반영하지만, refresh 또는 mergeStrategy 옵션을 통해 업데이트 동작을 정밀하게 제어할 수 있습니다. #### **`Options`** - `path` (optional, string) - [jsonpath](https://github.com/JSONPath-Plus/JSONPath) 문법에 따른 selector로, 이벤트가 적용될 객체를 선택합니다. @@ -233,9 +233,9 @@ draw method가 요구하는 **데이터 구조**입니다. - `changes` (optional, object) - 적용할 새로운 속성 (예: 색상, 텍스트 가시성). `refresh` 옵션을 `true`로 설정할 경우 생략할 수 있습니다. - `history` (optional, boolean \| string) - 해당 `update` 메소드에 의한 변경 사항을 `undoRedoManager`에 기록할 것인지 결정합니다. 이전에 저장된 기록의 historyId와 일치하는 문자열이 제공되면, 두 기록이 하나의 실행 취소/재실행 단계로 병합됩니다. - `relativeTransform` (optional, boolean) - `position`, `rotation`, `angle` 값에 대해서 상대값을 이용할 지 결정합니다. 만약, `true` 라면 전달된 값을 객체의 값에 더합니다. -- `arrayMerge` (optional, string) - 배열 속성을 병합하는 방식을 결정합니다. 기본값은 `'merge'` 입니다. - - `'merge'` (기본값): 대상 배열과 소스 배열을 병합합니다. - - `'replace'`: 대상 배열을 소스 배열로 완전히 교체하여, 특정 상태로 강제할 때 유용합니다. +- `mergeStrategy` (optional, string) - `changes` 객체를 기존 속성에 적용하는 방식을 결정합니다. 기본값은 `'merge'` 입니다. + - `'merge'` (기본값): `changes` 객체를 기존 속성에 깊게 병합(deep merge)합니다. 객체 내의 개별 속성이 업데이트됩니다. + - `'replace'`: `changes`에 지정된 최상위 속성을 통째로 교체합니다. `undo`를 실행하거나 `style`, `components`와 같은 복잡한 속성을 특정 상태로 완전히 리셋할 때 유용합니다. - `refresh` (optional, boolean) - `true`로 설정하면, `changes`의 속성 값이 이전과 동일하더라도 모든 속성 핸들러를 강제로 다시 실행하여 객체를 "새로고침"합니다. 부모의 상태 변화에 따라 자식 객체를 다시 계산해야 할 때 유용합니다. 기본값은 `false` 입니다. ```js diff --git a/src/command/commands/base.js b/src/command/commands/base.js index 410267f3..437a96f5 100644 --- a/src/command/commands/base.js +++ b/src/command/commands/base.js @@ -1,8 +1,3 @@ -/** - * @fileoverview Base command class for undo/redo operations. - */ -import { isSame } from '../../utils/diff/isSame'; - /** * Base Command class. * Represents an abstract command with execute, undo, and state change checking methods. @@ -33,14 +28,4 @@ export class Command { undo() { throw new Error('The undo() method must be implemented.'); } - - /** - * Checks if the command's state has changed. - * @returns {boolean} True if the state has changed, false otherwise. - */ - isStateChanged() { - return this.config && this.prevConfig - ? !isSame(this.config, this.prevConfig) - : true; - } } diff --git a/src/command/commands/bundle.js b/src/command/commands/bundle.js index 85f0a608..ab558283 100644 --- a/src/command/commands/bundle.js +++ b/src/command/commands/bundle.js @@ -39,7 +39,6 @@ export class BundleCommand extends Command { * @param {Command} command - The command to add and execute. */ addCommand(command) { - command.execute(); this.commands.push(command); } } diff --git a/src/command/commands/update.js b/src/command/commands/update.js new file mode 100644 index 00000000..bf6975a3 --- /dev/null +++ b/src/command/commands/update.js @@ -0,0 +1,62 @@ +import { Command } from './base'; + +export class UpdateCommand extends Command { + constructor(element, changes, options) { + super(options.historyId); + this.element = element; + this.changes = changes; + this.options = options; + this.previousProps = this._createPreviousState(this.changes); + } + + execute() { + this.element.update(this.changes, { + ...this.options, + historyId: false, + }); + } + + undo() { + this.element.update(this.previousProps, { + ...this.options, + mergeStrategy: 'replace', + historyId: false, + }); + } + + _createPreviousState(changes) { + const slice = {}; + if (!changes) { + return slice; + } + const currentProps = this.element.props; + + for (const key of Object.keys(changes)) { + if ( + key === 'attrs' && + changes.attrs !== null && + typeof changes.attrs === 'object' + ) { + const prevAttrs = {}; + for (const attrKey of Object.keys(changes.attrs)) { + prevAttrs[attrKey] = this._deepClone(this.element[attrKey]); + } + slice.attrs = prevAttrs; + } else { + slice[key] = this._deepClone(currentProps[key]); + } + } + return slice; + } + + _deepClone(value) { + if (value && typeof value.clone === 'function') { + return value.clone(); + } + try { + return structuredClone(value); + } catch (_) { + return value; + } + } +} diff --git a/src/command/undo-redo-manager.js b/src/command/undo-redo-manager.js index 50f12a22..ea71449d 100644 --- a/src/command/undo-redo-manager.js +++ b/src/command/undo-redo-manager.js @@ -19,9 +19,6 @@ export class UndoRedoManager { } execute(command, options = {}) { - if (!command.isStateChanged()) { - return; - } command.execute(); this._commands = this._commands.slice(0, this._index + 1); diff --git a/src/command/undo-redo-manager.test.js b/src/command/undo-redo-manager.test.js new file mode 100644 index 00000000..9c73e2a3 --- /dev/null +++ b/src/command/undo-redo-manager.test.js @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Command } from './commands/base'; +import { BundleCommand } from './commands/bundle'; +import { UndoRedoManager } from './undo-redo-manager'; + +/** + * A mock command for testing purposes. + * It tracks how many times execute and undo have been called. + */ +class MockCommand extends Command { + constructor(id = null) { + super(id); + this.executeCount = 0; + this.undoCount = 0; + } + + execute() { + this.executeCount++; + } + + undo() { + this.undoCount++; + } +} + +describe('UndoRedoManager', () => { + it('should correctly execute a command and add it to the history', () => { + const manager = new UndoRedoManager(); + const command = new MockCommand(); + + manager.execute(command); + + expect(command.executeCount).toBe(1); + expect(manager.commands).toHaveLength(1); + expect(manager.canUndo()).toBe(true); + expect(manager.canRedo()).toBe(false); + }); + + it('should correctly undo and redo a command', () => { + const manager = new UndoRedoManager(); + const command = new MockCommand(); + + manager.execute(command); + + // Undo + manager.undo(); + expect(command.undoCount).toBe(1); + expect(manager.canUndo()).toBe(false); + expect(manager.canRedo()).toBe(true); + + // Redo + manager.redo(); + expect(command.executeCount).toBe(2); // Initial execute + redo + expect(manager.canUndo()).toBe(true); + expect(manager.canRedo()).toBe(false); + }); + + it('should clear the redo stack after a new command is executed', () => { + const manager = new UndoRedoManager(); + const command1 = new MockCommand(); + const command2 = new MockCommand(); + + manager.execute(command1); + manager.undo(); + + expect(manager.canRedo()).toBe(true); + + manager.execute(command2); + + expect(command2.executeCount).toBe(1); + expect(manager.commands).toHaveLength(1); + expect(manager.commands[0]).toBe(command2); + expect(manager.canRedo()).toBe(false); + }); + + it('should respect the maxCommands limit', () => { + const manager = new UndoRedoManager(3); // Max 3 commands + const commands = [ + new MockCommand(), + new MockCommand(), + new MockCommand(), + new MockCommand(), + ]; + + manager.execute(commands[0]); + manager.execute(commands[1]); + manager.execute(commands[2]); + manager.execute(commands[3]); + + expect(manager.commands).toHaveLength(3); + expect(manager.commands[0]).toBe(commands[1]); // The first command should be discarded + expect(manager.canUndo()).toBe(true); + }); + + it('should clear the entire command history', () => { + const manager = new UndoRedoManager(); + manager.execute(new MockCommand()); + manager.execute(new MockCommand()); + + expect(manager.commands).toHaveLength(2); + + manager.clear(); + + expect(manager.commands).toHaveLength(0); + expect(manager.canUndo()).toBe(false); + expect(manager.canRedo()).toBe(false); + }); + + it('should bundle commands with the same historyId', () => { + const manager = new UndoRedoManager(); + const command1 = new MockCommand('bundle-1'); + const command2 = new MockCommand('bundle-1'); + const command3 = new MockCommand('bundle-2'); + + manager.execute(command1, { historyId: 'bundle-1' }); + manager.execute(command2, { historyId: 'bundle-1' }); + manager.execute(command3, { historyId: 'bundle-2' }); + + expect(manager.commands).toHaveLength(2); // Two bundles + expect(manager.commands[0]).toBeInstanceOf(BundleCommand); + expect(manager.commands[0].commands).toHaveLength(2); + expect(manager.commands[0].commands[0]).toBe(command1); + expect(manager.commands[0].commands[1]).toBe(command2); + expect(manager.commands[1]).toBeInstanceOf(BundleCommand); + }); + + it('should correctly undo/redo a bundle of commands', () => { + const manager = new UndoRedoManager(); + const command1 = new MockCommand('bundle-1'); + const command2 = new MockCommand('bundle-1'); + + manager.execute(command1, { historyId: 'bundle-1' }); + manager.execute(command2, { historyId: 'bundle-1' }); + + expect(command1.executeCount).toBe(1); + expect(command2.executeCount).toBe(1); + + // Undo the whole bundle + manager.undo(); + expect(command1.undoCount).toBe(1); + expect(command2.undoCount).toBe(1); + + // Redo the whole bundle + manager.redo(); + expect(command1.executeCount).toBe(2); + expect(command2.executeCount).toBe(2); + }); + + it('should correctly notify subscribers on state changes', () => { + const manager = new UndoRedoManager(); + const listener = vi.fn(); + const unsubscribe = manager.subscribe(listener); + + expect(listener).toHaveBeenCalledTimes(1); + + manager.execute(new MockCommand()); + expect(listener).toHaveBeenCalledTimes(2); + + manager.undo(); + expect(listener).toHaveBeenCalledTimes(3); + + manager.redo(); + expect(listener).toHaveBeenCalledTimes(4); + + manager.clear(); + expect(listener).toHaveBeenCalledTimes(5); + + unsubscribe(); + manager.execute(new MockCommand()); + expect(listener).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/display/components/Text.js b/src/display/components/Text.js index 3d559eb2..d03061f8 100644 --- a/src/display/components/Text.js +++ b/src/display/components/Text.js @@ -5,6 +5,7 @@ import { Placementable } from '../mixins/Placementable'; import { Showable } from '../mixins/Showable'; import { Textable } from '../mixins/Textable'; import { Textstyleable } from '../mixins/Textstyleable'; +import { Tintable } from '../mixins/Tintable'; import { mixins } from '../mixins/utils'; const EXTRA_KEYS = { @@ -17,17 +18,13 @@ const ComposedText = mixins( Showable, Textable, Textstyleable, + Tintable, Placementable, ); export class Text extends ComposedText { constructor(context) { - super({ - type: 'text', - context, - text: '', - style: { fontFamily: 'FiraCode regular', fill: 'black' }, - }); + super({ type: 'text', context, text: '' }); this.constructor.registerHandler( EXTRA_KEYS.PLACEMENT, diff --git a/src/display/data-schema/component-schema.js b/src/display/data-schema/component-schema.js index 9964a575..3db46e12 100644 --- a/src/display/data-schema/component-schema.js +++ b/src/display/data-schema/component-schema.js @@ -1,12 +1,12 @@ import { z } from 'zod'; import { Base, + Color, Margin, Placement, PxOrPercentSize, TextStyle, TextureStyle, - Tint, } from './primitive-schema'; /** @@ -24,7 +24,7 @@ export const backgroundSchema = Base.extend({ width: { value: 100, unit: '%' }, height: { value: 100, unit: '%' }, })), - tint: Tint.optional(), + tint: Color, }).strict(); /** @@ -38,7 +38,7 @@ export const barSchema = Base.extend({ size: PxOrPercentSize, placement: Placement.default('bottom'), margin: Margin.default(0), - tint: Tint.optional(), + tint: Color, animation: z.boolean().default(true), animationDuration: z.number().default(200), }).strict(); @@ -54,7 +54,7 @@ export const iconSchema = Base.extend({ size: PxOrPercentSize, placement: Placement.default('center'), margin: Margin.default(0), - tint: Tint.optional(), + tint: Color, }).strict(); /** @@ -66,9 +66,9 @@ export const textSchema = Base.extend({ type: z.literal('text'), placement: Placement.default('center'), margin: Margin.default(0), - tint: Tint.optional(), + tint: Color, text: z.string().default(''), - style: TextStyle.optional(), + style: TextStyle, split: z.number().int().default(0), }).strict(); diff --git a/src/display/data-schema/data.d.ts b/src/display/data-schema/data.d.ts index baf502c5..eeaab5ef 100644 --- a/src/display/data-schema/data.d.ts +++ b/src/display/data-schema/data.d.ts @@ -8,11 +8,11 @@ */ import type { - Color, HslColor, HslaColor, HsvColor, HsvaColor, + Color as PixiColor, RgbColor, RgbaColor, } from './color'; @@ -103,6 +103,7 @@ export interface Grid { item: { components?: Component[]; size: Size; + padding?: Margin; // Default: 0 }; attrs?: Record; } @@ -152,6 +153,7 @@ export interface Item { show?: boolean; // Default: true components?: Component[]; size: Size; + padding?: Margin; // Default: 0 attrs?: Record; } @@ -216,7 +218,7 @@ export interface Background { label?: string; show?: boolean; // Default: true source: TextureStyle | string; - tint?: Tint; + tint?: Color; attrs?: Record; } @@ -244,7 +246,7 @@ export interface Bar { size: PxOrPercentSize; placement?: Placement; // Default: 'bottom' margin?: Margin; // Default: 0 - tint?: Tint; + tint?: Color; animation?: boolean; // Default: true animationDuration?: number; // Default: 200 attrs?: Record; @@ -272,7 +274,7 @@ export interface Icon { size: PxOrPercentSize; placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 - tint?: Tint; + tint?: Color; attrs?: Record; } @@ -298,7 +300,7 @@ export interface Text { text?: string; // Default: '' placement?: Placement; // Default: 'center' margin?: Margin; // Default: 0 - tint?: Tint; + tint?: Color; style?: TextStyle; split?: number; // Default: 0 attrs?: Record; @@ -434,10 +436,10 @@ export type Margin = */ export interface TextureStyle { type: 'rect'; - fill?: string; - borderWidth?: number; - borderColor?: string; - radius?: number; + fill?: string; // Default: 'transparent' + borderWidth?: number; // Default: 0 + borderColor?: string; // Default: 'black' + radius?: number; // Default: 0 } /** @@ -453,7 +455,19 @@ export interface TextureStyle { * cap: 'square' * }; */ -export type RelationsStyle = Record; +export interface RelationsStyle { + /** + * The color of the line. Can be any valid PixiJS ColorSource. + * @default 'black' + */ + color?: Color; + + /** + * Allows any other properties compatible with PIXI.Graphics' stroke style, + * such as `width`, `cap`, `join`, etc. + */ + [key: string]: unknown; +} /** * Defines the text style for a Text component. @@ -485,6 +499,24 @@ export interface TextStyle { */ fontSize?: number | 'auto' | string; + /** + * The font family. + * @default 'FiraCode' + */ + fontFamily?: unknown; + + /** + * The font weight. + * @default 400 + */ + fontWeight?: unknown; + + /** + * The fill color. + * @default 'black' + */ + fill?: unknown; + /** * Configuration for the 'auto' font size mode. * This is only active when `fontSize` is 'auto'. @@ -509,23 +541,23 @@ export interface TextStyle { * * @example * // As a theme key (string) - * const tintThemeKey: Tint = 'primary.default'; + * const tintThemeKey: Color = 'primary.default'; * * @example * // As a hex string - * const tintHexString: Tint = '#ff0000'; + * const tintHexString: Color = '#ff0000'; * * @example * // As a hex number - * const tintHexNumber: Tint = 0xff0000; + * const tintHexNumber: Color = 0xff0000; * * @example * // As an RGB object - * const tintRgbObject: Tint = { r: 255, g: 0, b: 0 }; + * const tintRgbObject: Color = { r: 255, g: 0, b: 0 }; * * @see {@link https://pixijs.download/release/docs/color.ColorSource.html} */ -export type Tint = +export type Color = | string | number | number[] @@ -538,4 +570,4 @@ export type Tint = | HsvaColor | RgbColor | RgbaColor - | Color; + | PixiColor; diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index aa357cf6..28c8afba 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -23,7 +23,7 @@ export const gridSchema = Base.extend({ cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), gap: Gap, item: z.object({ - components: componentArraySchema, + components: componentArraySchema.default([]), size: Size, padding: Margin.default(0), }), @@ -37,7 +37,7 @@ export const gridSchema = Base.extend({ */ export const itemSchema = Base.extend({ type: z.literal('item'), - components: componentArraySchema, + components: componentArraySchema.default([]), size: Size, padding: Margin.default(0), }).strict(); @@ -51,7 +51,7 @@ export const itemSchema = Base.extend({ export const relationsSchema = Base.extend({ type: z.literal('relations'), links: z.array(z.object({ source: z.string(), target: z.string() })), - style: RelationsStyle.optional(), + style: RelationsStyle, }).strict(); export const elementTypes = z.discriminatedUnion('type', [ diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index 6d4958d6..0f637f0d 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -1,16 +1,41 @@ import { z } from 'zod'; import { uid } from '../../utils/uuid'; -import { ZERO_MARGIN } from '../mixins/constants'; import { - Color, + DEFAULT_AUTO_FONT_RANGE, + DEFAULT_PATHSTYLE, + DEFAULT_TEXTSTYLE, +} from '../mixins/constants'; +import { HslColor, HslaColor, HsvColor, HsvaColor, + Color as PixiColor, RgbColor, RgbaColor, } from './color-schema'; +/** + * @see {@link https://pixijs.download/release/docs/color.ColorSource.html} + */ +export const Color = z + .union([ + z.string(), + z.number(), + z.array(z.number()), + z.instanceof(Float32Array), + z.instanceof(Uint8Array), + z.instanceof(Uint8ClampedArray), + HslColor, + HslaColor, + HsvColor, + HsvaColor, + RgbColor, + RgbaColor, + PixiColor, + ]) + .default(0xffffff); + export const Base = z .object({ show: z.boolean().default(true), @@ -128,18 +153,19 @@ export const PxOrPercentSize = z.union([ }), ]); -export const Placement = z.enum([ - 'left', - 'left-top', - 'left-bottom', - 'top', - 'right', - 'right-top', - 'right-bottom', - 'bottom', - 'center', - 'none', -]); +export const Placement = z + .enum([ + 'left', + 'left-top', + 'left-bottom', + 'top', + 'right', + 'right-top', + 'right-bottom', + 'bottom', + 'center', + ]) + .default('left-top'); export const Gap = z.preprocess( (val) => (typeof val === 'number' ? { x: val, y: val } : val), @@ -169,25 +195,29 @@ export const Margin = z.preprocess( bottom: z.number().default(0), left: z.number().default(0), }) - .default(ZERO_MARGIN), + .default({}), ); export const TextureStyle = z .object({ type: z.enum(['rect']), - fill: z.string(), - borderWidth: z.number(), - borderColor: z.string(), - radius: z.number(), + fill: z.string().default('transparent'), + borderWidth: z.number().default(0), + borderColor: z.string().default('black'), + radius: z.number().default(0), }) .partial(); /** * @see {@link https://pixijs.download/release/docs/scene.ConvertedStrokeStyle.html} */ -export const RelationsStyle = z.record(z.string(), z.unknown()); +export const RelationsStyle = z + .object({ + color: Color.default(DEFAULT_PATHSTYLE.color), + }) + .passthrough() + .default({}); -export const DEFAULT_AUTO_FONT_RANGE = { min: 1, max: 100 }; /** * @see {@link https://pixijs.download/release/docs/text.TextStyleOptions.html} */ @@ -202,25 +232,10 @@ export const TextStyle = z .refine((data) => data.min <= data.max, { message: 'autoFont.min must not be greater than autoFont.max', }) - .optional(), + .default({}), + fontFamily: z.any().default(DEFAULT_TEXTSTYLE.fontFamily), + fontWeight: z.any().default(DEFAULT_TEXTSTYLE.fontWeight), + fill: z.any().default(DEFAULT_TEXTSTYLE.fill), }) - .passthrough(); - -/** - * @see {@link https://pixijs.download/release/docs/color.ColorSource.html} - */ -export const Tint = z.union([ - z.string(), - z.number(), - z.array(z.number()), - z.instanceof(Float32Array), - z.instanceof(Uint8Array), - z.instanceof(Uint8ClampedArray), - HslColor, - HslaColor, - HsvColor, - HsvaColor, - RgbColor, - RgbaColor, - Color, -]); + .passthrough() + .default({}); diff --git a/src/display/data-schema/primitive-schema.test.js b/src/display/data-schema/primitive-schema.test.js index 3ec81e25..8f517c11 100644 --- a/src/display/data-schema/primitive-schema.test.js +++ b/src/display/data-schema/primitive-schema.test.js @@ -3,6 +3,7 @@ import { uid } from '../../utils/uuid'; import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { Base, + Color, Gap, Margin, Placement, @@ -11,7 +12,6 @@ import { Size, TextStyle, TextureStyle, - Tint, pxOrPercentSchema, } from './primitive-schema'; @@ -19,7 +19,7 @@ vi.mock('../../utils/uuid'); vi.mocked(uid).mockReturnValue('mock-uid-123'); describe('Primitive Schema Tests', () => { - describe('Tint Schema', () => { + describe('Color Schema', () => { const validColorSourceCases = [ // CSS Color Names (pass as string) { case: 'CSS color name', value: 'red' }, @@ -57,8 +57,8 @@ describe('Primitive Schema Tests', () => { it.each(validColorSourceCases)( 'should correctly parse various color source types: $case', ({ value }) => { - expect(() => Tint.parse(value)).not.toThrow(); - const parsed = Tint.parse(value); + expect(() => Color.parse(value)).not.toThrow(); + const parsed = Color.parse(value); expect(parsed).toEqual(value); }, ); @@ -204,7 +204,6 @@ describe('Primitive Schema Tests', () => { 'right-bottom', 'bottom', 'center', - 'none', ])('should accept valid placement value: %s', (placement) => { expect(() => Placement.parse(placement)).not.toThrow(); }); @@ -344,19 +343,29 @@ describe('Primitive Schema Tests', () => { describe('RelationsStyle Schema', () => { it('should add default color if not provided', () => { const data = { lineWidth: 2 }; - expect(RelationsStyle.parse(data)).toEqual({ lineWidth: 2 }); + expect(RelationsStyle.parse(data)).toEqual({ + lineWidth: 2, + color: 'black', + }); }); }); describe('TextStyle Schema', () => { it('should apply default styles for a partial object', () => { const data = { fontSize: 16 }; - expect(TextStyle.parse(data)).toEqual({ fontSize: 16 }); + expect(TextStyle.parse(data)).toEqual({ + autoFont: { min: 1, max: 100 }, + fill: 'black', + fontFamily: 'FiraCode', + fontSize: 16, + fontWeight: 400, + }); }); it('should not override provided styles', () => { const data = { fontFamily: 'Arial', fill: 'red', fontWeight: 'bold' }; expect(TextStyle.parse(data)).toEqual({ + autoFont: { min: 1, max: 100 }, fontFamily: 'Arial', fontWeight: 'bold', fill: 'red', diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 9014188e..06e18217 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -25,7 +25,6 @@ export class Relations extends ComposedRelations { initPath() { const path = new Graphics(); - path.setStrokeStyle({ color: 'black' }); Object.assign(path, { type: 'path', links: [] }); this.addChild(path); return path; @@ -33,10 +32,10 @@ export class Relations extends ComposedRelations { _afterRender() { super._afterRender(); - this._onUpdate(); + this._refreshLink(); } - _onUpdate() { + _refreshLink() { if (this._renderDirty) { try { this.renderLink(); @@ -53,8 +52,8 @@ export class Relations extends ComposedRelations { let lastPoint = null; for (const link of links) { - const sourceObject = this.linkedObjects[link.source]; - const targetObject = this.linkedObjects[link.target]; + const sourceObject = this.linkedObjects?.[link.source]; + const targetObject = this.linkedObjects?.[link.target]; if ( !sourceObject || diff --git a/src/display/mixins/Base.js b/src/display/mixins/Base.js index 18b8a731..8d53010c 100644 --- a/src/display/mixins/Base.js +++ b/src/display/mixins/Base.js @@ -1,7 +1,8 @@ import { Matrix } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; +import { UpdateCommand } from '../../command/commands/update'; import { deepMerge } from '../../utils/deepmerge/deepmerge'; -import { diffJson } from '../../utils/diff/diff-json'; +import { diffReplace } from '../../utils/diff/diff-replace'; import { validate } from '../../utils/validator'; import { deepPartial } from '../../utils/zod-deep-strict-partial'; import { Type } from './Type'; @@ -66,21 +67,32 @@ export const Base = (superClass) => { } update(changes, schema, options = {}) { - const { arrayMerge = 'merge', refresh = false } = options; + const { mergeStrategy = 'merge', refresh = false } = options; const effectiveChanges = refresh && !changes ? {} : changes; const validatedChanges = validate(effectiveChanges, deepPartial(schema)); if (isValidationError(validatedChanges)) throw validatedChanges; - const prevProps = JSON.parse(JSON.stringify(this.props)); - this.props = deepMerge(prevProps, validatedChanges, { arrayMerge }); + const nextProps = + mergeStrategy === 'replace' + ? validate({ ...this.props, ...validatedChanges }, schema) + : deepMerge(this.props, validatedChanges); + if (isValidationError(nextProps)) throw nextProps; + const actualChanges = diffReplace(this.props, nextProps) ?? {}; + + if (options?.historyId && Object.keys(actualChanges).length > 0) { + const command = new UpdateCommand(this, changes, options); + this.context.undoRedoManager.execute(command, options); + return; + } + this.props = nextProps; const keysToProcess = refresh - ? Object.keys(this.props) - : Object.keys(diffJson(prevProps, this.props) ?? {}); + ? Object.keys(nextProps) + : Object.keys(actualChanges); const { id, label, attrs } = validatedChanges; if (id || label || attrs) { - this._applyRaw({ id, label, ...attrs }, arrayMerge); + this._applyRaw({ id, label, ...attrs }, mergeStrategy); } const tasks = new Map(); @@ -108,21 +120,22 @@ export const Base = (superClass) => { fullPayload[key] = this.props[key]; } }); - handler.call(this, fullPayload, { arrayMerge, refresh }); + handler.call(this, fullPayload, { mergeStrategy, refresh }); }); if (this.parent?._onChildUpdate) { - this.parent._onChildUpdate( - this.id, - diffJson(prevProps, this.props), - arrayMerge, - ); + this.parent._onChildUpdate(this.id, actualChanges, mergeStrategy); } } - _applyRaw(attrs, arrayMerge) { + _applyRaw(attrs, mergeStrategy) { for (const [key, value] of Object.entries(attrs)) { - if (value === undefined) continue; + if (value === undefined) { + if (key !== 'id') { + delete this[key]; + } + continue; + } if (key === 'x' || key === 'y') { const x = key === 'x' ? value : (attrs?.x ?? this.x); @@ -134,13 +147,13 @@ export const Base = (superClass) => { key === 'height' ? value : (attrs?.height ?? this.height); this.setSize(width, height); } else { - this._updateProperty(key, value, arrayMerge); + this._updateProperty(key, value, mergeStrategy); } } } - _updateProperty(key, value, arrayMerge) { - deepMerge(this, { [key]: value }, { arrayMerge }); + _updateProperty(key, value, mergeStrategy) { + deepMerge(this, { [key]: value }, { mergeStrategy }); } }; }; diff --git a/src/display/mixins/Cellsable.js b/src/display/mixins/Cellsable.js index 6a7f98e6..3ab44dc4 100644 --- a/src/display/mixins/Cellsable.js +++ b/src/display/mixins/Cellsable.js @@ -20,6 +20,7 @@ export const Cellsable = (superClass) => { if (!currentItemIds.has(id)) { const item = newElement('item', this.context); item.update({ + type: 'item', id, ...itemProps, attrs: { diff --git a/src/display/mixins/Childrenable.js b/src/display/mixins/Childrenable.js index d32be3e6..ddf4cda6 100644 --- a/src/display/mixins/Childrenable.js +++ b/src/display/mixins/Childrenable.js @@ -19,7 +19,7 @@ export const Childrenable = (superClass) => { mapDataSchema, ); - if (options.arrayMerge === 'replace') { + if (options.mergeStrategy === 'replace') { elements.forEach((element) => { this.removeChild(element); element.destroy({ children: true }); @@ -42,7 +42,7 @@ export const Childrenable = (superClass) => { } } - _onChildUpdate(childId, changes, arrayMerge) { + _onChildUpdate(childId, changes, mergeStrategy) { if (!this.props.children) return; const childIndex = this.props.children.findIndex((c) => c.id === childId); @@ -50,7 +50,7 @@ export const Childrenable = (superClass) => { const updatedChildProps = deepMerge( this.props.children[childIndex], changes, - { arrayMerge }, + { mergeStrategy }, ); this.props.children[childIndex] = updatedChildProps; } diff --git a/src/display/mixins/Componentsable.js b/src/display/mixins/Componentsable.js index e92f6dad..ed43fb70 100644 --- a/src/display/mixins/Componentsable.js +++ b/src/display/mixins/Componentsable.js @@ -19,7 +19,7 @@ export const Componentsable = (superClass) => { componentArraySchema, ); - if (options.arrayMerge === 'replace') { + if (options.mergeStrategy === 'replace') { components.forEach((component) => { this.removeChild(component); component.destroy({ children: true }); @@ -38,11 +38,14 @@ export const Componentsable = (superClass) => { component = newComponent(componentChange.type, this.context); this.addChild(component); } - component.update(componentChange, options); + component.update( + { type: componentChange.type, ...componentChange }, + options, + ); } } - _onChildUpdate(childId, changes, arrayMerge) { + _onChildUpdate(childId, changes, mergeStrategy) { if (!this.props.components) return; const childIndex = this.props.components.findIndex( @@ -52,7 +55,7 @@ export const Componentsable = (superClass) => { const updatedChildProps = deepMerge( this.props.components[childIndex], changes, - { arrayMerge }, + { mergeStrategy }, ); this.props.components[childIndex] = updatedChildProps; } diff --git a/src/display/mixins/Relationstyleable.js b/src/display/mixins/Relationstyleable.js index fdd84e8d..4d97e8ba 100644 --- a/src/display/mixins/Relationstyleable.js +++ b/src/display/mixins/Relationstyleable.js @@ -6,7 +6,7 @@ const KEYS = ['style']; export const Relationstyleable = (superClass) => { const MixedClass = class extends superClass { - _applyRelationstyle(relevantChanges) { + _applyRelationstyle(relevantChanges, options) { const { style } = relevantChanges; const path = selector(this, '$.children[?(@.type==="path")]')[0]; if (!path) return; @@ -14,7 +14,12 @@ export const Relationstyleable = (superClass) => { if ('color' in style) { style.color = getColor(this.context.theme, style.color); } - path.setStrokeStyle({ ...path.strokeStyle, ...style }); + + const newStrokeStyle = + options.mergeStrategy === 'replace' + ? style + : { ...path.strokeStyle, ...style }; + path.setStrokeStyle(newStrokeStyle); this._renderDirty = true; } }; diff --git a/src/display/mixins/Textstyleable.js b/src/display/mixins/Textstyleable.js index c3db5ce8..20558b9f 100644 --- a/src/display/mixins/Textstyleable.js +++ b/src/display/mixins/Textstyleable.js @@ -1,18 +1,27 @@ +import { TextStyle } from 'pixi.js'; import { getColor } from '../../utils/get'; -import { DEFAULT_AUTO_FONT_RANGE } from '../data-schema/primitive-schema'; -import { FONT_WEIGHT, UPDATE_STAGES } from './constants'; +import { + DEFAULT_AUTO_FONT_RANGE, + FONT_WEIGHT, + UPDATE_STAGES, +} from './constants'; const KEYS = ['text', 'split', 'style', 'margin']; export const Textstyleable = (superClass) => { const MixedClass = class extends superClass { - _applyTextstyle(relevantChanges) { + _applyTextstyle(relevantChanges, options) { const { style, margin } = relevantChanges; - const { theme } = this.context.theme; + const { theme } = this.context; + + if (options.mergeStrategy === 'replace') { + this.style = new TextStyle(); + } for (const key in style) { if (key === 'fontFamily' || key === 'fontWeight') { - this.style.fontFamily = `${style.fontFamily ?? this.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT[style.fontWeight ?? this.style.fontWeight]}`; + this.style.fontWeight = this._getFontWeight(style.fontWeight); + this.style.fontFamily = this._getFontFamily(style.fontFamily); } else if (key === 'fill') { this.style[key] = getColor(theme, style.fill); } else if (key === 'fontSize' && style[key] === 'auto') { @@ -23,6 +32,14 @@ export const Textstyleable = (superClass) => { } } } + + _getFontFamily(value) { + return `${value ?? this.style.fontFamily.split(' ')[0]} ${FONT_WEIGHT.STRING[this.style.fontWeight]}`; + } + + _getFontWeight(value) { + return FONT_WEIGHT.NUMBER[value ?? this.style.fontWeight]; + } }; MixedClass.registerHandler( KEYS, diff --git a/src/display/mixins/constants.js b/src/display/mixins/constants.js index 93eba2e7..50105693 100644 --- a/src/display/mixins/constants.js +++ b/src/display/mixins/constants.js @@ -8,24 +8,46 @@ export const UPDATE_STAGES = Object.freeze({ }); export const FONT_WEIGHT = { - 100: 'thin', - 200: 'extralight', - 300: 'light', - 400: 'regular', - 500: 'medium', - 600: 'semibold', - 700: 'bold', - 800: 'extrabold', - 900: 'black', - thin: 'thin', - extralight: 'extralight', - light: 'light', - regular: 'regular', - medium: 'medium', - semibold: 'semibold', - bold: 'bold', - extrabold: 'extrabold', - black: 'black', + STRING: { + 100: 'thin', + 200: 'extralight', + 300: 'light', + 400: 'regular', + 500: 'medium', + 600: 'semibold', + 700: 'bold', + 800: 'extrabold', + 900: 'black', + thin: 'thin', + extralight: 'extralight', + light: 'light', + regular: 'regular', + medium: 'medium', + semibold: 'semibold', + bold: 'bold', + extrabold: 'extrabold', + black: 'black', + }, + NUMBER: { + 100: '100', + 200: '200', + 300: '300', + 400: '400', + 500: '500', + 600: '600', + 700: '700', + 800: '800', + 900: '900', + thin: '100', + extralight: '200', + light: '300', + regular: '400', + medium: '500', + semibold: '600', + bold: '700', + extrabold: '800', + black: '900', + }, }; export const ZERO_MARGIN = Object.freeze({ @@ -34,3 +56,13 @@ export const ZERO_MARGIN = Object.freeze({ bottom: 0, left: 0, }); + +export const DEFAULT_AUTO_FONT_RANGE = { min: 1, max: 100 }; + +export const DEFAULT_TEXTSTYLE = { + fontFamily: 'FiraCode', + fill: 'black', + fontWeight: 400, +}; + +export const DEFAULT_PATHSTYLE = { color: 'black' }; diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index 1f1ea903..0c52198e 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -8,7 +8,7 @@ export const tweensOf = (object) => gsap.getTweensOf(object); export const killTweensOf = (object) => gsap.killTweensOf(object); -const parseCalcExpression = (expression, parentDimension) => { +export const parseCalcExpression = (expression, parentDimension) => { const innerExpression = expression.substring(5, expression.length - 1); const sanitizedExpression = innerExpression.replace(/\s-\s/g, ' + -'); const terms = sanitizedExpression.split(/\s\+\s/); diff --git a/src/display/mixins/utils.test.js b/src/display/mixins/utils.test.js new file mode 100644 index 00000000..173d3360 --- /dev/null +++ b/src/display/mixins/utils.test.js @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { calcSize, parseCalcExpression } from './utils'; + +describe('parseCalcExpression', () => { + const parentDimension = 200; + + const testCases = [ + { + name: 'a simple percentage value', + expression: 'calc(50%)', + expected: 100, + }, + { name: 'a simple pixel value', expression: 'calc(75px)', expected: 75 }, + { + name: 'subtraction of pixels from a percentage', + expression: 'calc(100% - 50px)', + expected: 150, + }, + { + name: 'addition of pixels to a percentage', + expression: 'calc(25% + 25px)', + expected: 75, + }, + { + name: 'multiple terms with mixed units', + expression: 'calc(50% - 20px + 10% + 5px)', + expected: 105, + }, + { + name: 'floating point numbers in percentages and pixels', + expression: 'calc(12.5% + 15.5px)', + expected: 40.5, + }, + { + name: 'negative values within the expression', + expression: 'calc(50% + -30px)', + expected: 70, + }, + { + name: 'an expression resulting in zero', + expression: 'calc(50% - 100px)', + expected: 0, + }, + { + name: 'an expression with only pixel values', + expression: 'calc(100px - 25px + 10px)', + expected: 85, + }, + { + name: 'an expression with only percentage values', + expression: 'calc(100% - 25% + 10%)', + expected: 170, + }, + ]; + + it.each(testCases)( + 'should correctly parse $name', + ({ expression, expected }) => { + expect(parseCalcExpression(expression, parentDimension)).toBe(expected); + }, + ); +}); + +describe('calcSize', () => { + const mockParent = { + props: { + size: { width: 400, height: 200 }, + padding: { top: 10, right: 20, bottom: 30, left: 40 }, + }, + }; + + const testCases = [ + { + name: 'with simple pixel values', + props: { + source: {}, + size: { + width: { value: 100, unit: 'px' }, + height: { value: 50, unit: 'px' }, + }, + }, + respectsPadding: true, + expected: { width: 100, height: 50, borderWidth: 0 }, + }, + { + name: 'with percentage values based on parent content area', + props: { + source: {}, + size: { + width: { value: 50, unit: '%' }, + height: { value: 25, unit: '%' }, + }, + }, + respectsPadding: true, + // Expected: width = 340 * 0.5 = 170, height = 160 * 0.25 = 40 + expected: { width: 170, height: 40, borderWidth: 0 }, + }, + { + name: 'with borderWidth, which is added to the final size', + props: { + source: { borderWidth: 5 }, + size: { + width: { value: 100, unit: 'px' }, + height: { value: 50, unit: 'px' }, + }, + }, + respectsPadding: true, + expected: { width: 105, height: 55, borderWidth: 5 }, + }, + { + name: 'with calc() expressions for width and height', + props: { + source: { borderWidth: 2 }, + size: { width: 'calc(100% - 40px)', height: 'calc(50% + 10px)' }, + }, + respectsPadding: true, + // Expected: width = (340 - 40) + 2 = 302, height = (160 * 0.5 + 10) + 2 = 92 + expected: { width: 302, height: 92, borderWidth: 2 }, + }, + { + name: "when respectsPadding is false, using parent's full dimensions", + props: { + source: {}, + size: { + width: { value: 50, unit: '%' }, + height: { value: 50, unit: '%' }, + }, + }, + respectsPadding: false, + // Expected: width = 400 * 0.5 = 200, height = 200 * 0.5 = 100 + expected: { width: 200, height: 100, borderWidth: 0 }, + }, + { + name: 'gracefully when the component has no parent', + props: { + source: {}, + size: { + width: { value: 50, unit: '%' }, + height: { value: 100, unit: 'px' }, + }, + }, + respectsPadding: true, + parent: null, + // Expected: width = 0 (since % of null is 0), height = 100 + expected: { width: 0, height: 100, borderWidth: 0 }, + }, + { + name: 'with pixel values and borderWidth', + props: { + source: { borderWidth: 5 }, + size: { + width: { value: 100, unit: 'px' }, + height: { value: 50, unit: 'px' }, + }, + }, + respectsPadding: true, + // Expected: width = 100 + 5, height = 50 + 5 + expected: { width: 105, height: 55, borderWidth: 5 }, + }, + { + name: 'with percentage values and borderWidth', + props: { + source: { borderWidth: 10 }, + size: { + width: { value: 50, unit: '%' }, + height: { value: 25, unit: '%' }, + }, + }, + respectsPadding: true, + // Expected: width = (340 * 0.5) + 10 = 180, height = (160 * 0.25) + 10 = 50 + expected: { width: 180, height: 50, borderWidth: 10 }, + }, + { + name: 'with calc() expressions and borderWidth', + props: { + source: { borderWidth: 2 }, + size: { width: 'calc(100% - 40px)', height: 'calc(50% + 10px)' }, + }, + respectsPadding: true, + // Expected: width = (340 - 40) + 2 = 302, height = (160 * 0.5 + 10) + 2 = 92 + expected: { width: 302, height: 92, borderWidth: 2 }, + }, + ]; + + it.each(testCases)( + 'should calculate size correctly $name', + ({ props, respectsPadding, parent = mockParent, expected }) => { + const mockComponent = { + constructor: { respectsPadding }, + parent, + }; + const result = calcSize(mockComponent, props); + expect(result).toEqual(expected); + }, + ); +}); diff --git a/src/display/update.js b/src/display/update.js index fec59f9c..9d9cae48 100644 --- a/src/display/update.js +++ b/src/display/update.js @@ -10,7 +10,7 @@ const updateSchema = z.object({ changes: z.record(z.unknown()).nullable().default(null), history: z.union([z.boolean(), z.string()]).default(false), relativeTransform: z.boolean().default(false), - arrayMerge: z.enum(['merge', 'replace']).default('merge'), + mergeStrategy: z.enum(['merge', 'replace']).default('merge'), refresh: z.boolean().default(false), }); @@ -34,7 +34,7 @@ export const update = (viewport, opts) => { } element.update(changes, { historyId, - arrayMerge: config.arrayMerge, + mergeStrategy: config.mergeStrategy, refresh: config.refresh, }); } diff --git a/src/init.js b/src/init.js index c1c9538f..6a1ebf17 100644 --- a/src/init.js +++ b/src/init.js @@ -5,6 +5,7 @@ import * as PIXI from 'pixi.js'; import { firaCode } from './assets/fonts'; import { icons } from './assets/icons'; import { Type } from './display/mixins/Type'; +import { FONT_WEIGHT } from './display/mixins/constants'; import { deepMerge } from './utils/deepmerge/deepmerge'; import { plugin } from './utils/event/viewport'; import { uid } from './utils/uuid'; @@ -45,7 +46,10 @@ const DEFAULT_INIT_OPTIONS = { items: Object.entries(firaCode).map(([key, font]) => ({ alias: `firaCode-${key}`, src: font, - data: { family: `FiraCode ${key}` }, + data: { + family: `FiraCode ${key}`, + weights: [FONT_WEIGHT.NUMBER[key]], + }, })), }, ], diff --git a/src/patchmap.js b/src/patchmap.js index a6409cac..6b063276 100644 --- a/src/patchmap.js +++ b/src/patchmap.js @@ -1,5 +1,5 @@ import gsap from 'gsap'; -import { Application, Graphics } from 'pixi.js'; +import { Application, Graphics, UPDATE_PRIORITY } from 'pixi.js'; import { isValidationError } from 'zod-validation-error'; import { UndoRedoManager } from './command/undo-redo-manager'; import { draw } from './display/draw'; @@ -149,7 +149,13 @@ class Patchmap { // Force a refresh of all relation elements after the initial draw. This ensures // that all link targets exist in the scene graph before the relations // attempt to draw their links. - this.update({ path: '$..children[?(@.type=="relations")]', refresh: true }); + this.app.ticker.addOnce( + () => { + this.update({ path: '$..[?(@.type=="relations")]', refresh: true }); + }, + undefined, + UPDATE_PRIORITY.UTILITY, + ); this.app.start(); return validatedData; diff --git a/src/tests/render/components/Background.test.js b/src/tests/render/components/Background.test.js index 6278a173..a9e659e4 100644 --- a/src/tests/render/components/Background.test.js +++ b/src/tests/render/components/Background.test.js @@ -99,7 +99,7 @@ describe('Background Component In Item', () => { }); }); - it('should replace the entire component array when arrayMerge is "replace"', () => { + it('should replace the entire component array when mergeStrategy is "replace"', () => { const patchmap = getPatchmap(); patchmap.draw([baseItemData]); @@ -113,7 +113,7 @@ describe('Background Component In Item', () => { changes: { components: [newBackground], }, - arrayMerge: 'replace', + mergeStrategy: 'replace', }); const item = patchmap.selector('$..[?(@.id=="item-with-background")]')[0]; diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 587a804c..bc01990a 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -144,7 +144,7 @@ describe('patchmap test', () => { expect(background.props.source.fill).toBe('blue'); }); - it('should replace an array completely when arrayMerge is "replace"', () => { + it('should replace an array completely when mergeStrategy is "replace"', () => { const initialGridItemCount = patchmap.selector( '$..[?(@.id=="grid-1")]', )[0].children.length; @@ -154,7 +154,7 @@ describe('patchmap test', () => { changes: { cells: [[1, 1, 1, 1]], }, - arrayMerge: 'replace', + mergeStrategy: 'replace', }); const gridItems = patchmap.selector('$..[?(@.id=="grid-1")]')[0].children; expect(gridItems.length).toBe(4); diff --git a/src/tests/undo-redo/Background.test.js b/src/tests/undo-redo/Background.test.js new file mode 100644 index 00000000..7aa5d646 --- /dev/null +++ b/src/tests/undo-redo/Background.test.js @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { deepMerge } from '../../utils/deepmerge/deepmerge'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: Background Component', () => { + const { getPatchmap } = setupPatchmapTests(); + + const baseItemData = { + type: 'item', + id: 'item-with-background', + size: { width: 100, height: 100 }, + components: [ + { + type: 'background', + id: 'background-1', + source: { + type: 'rect', + fill: 'white', + borderColor: 'black', + borderWidth: 2, + radius: 4, + }, + tint: 'gray.default', // 0xd9d9d9 + }, + ], + }; + + const getBackground = (patchmap) => { + return patchmap.selector('$..[?(@.id=="background-1")]')[0]; + }; + + it('should correctly undo/redo a single property change (tint)', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const background = getBackground(patchmap); + const originalTint = background.tint; + expect(originalTint).toBe(0xd9d9d9); + + patchmap.update({ + path: '$..[?(@.id=="background-1")]', + changes: { tint: 'primary.accent' }, // #EF4444 + history: true, + }); + const updatedTint = getBackground(patchmap).tint; + expect(updatedTint).toBe(0xef4444); + + patchmap.undoRedoManager.undo(); + expect(getBackground(patchmap).tint).toBe(originalTint); + + patchmap.undoRedoManager.redo(); + expect(getBackground(patchmap).tint).toBe(updatedTint); + }); + + it('should correctly undo/redo a nested property change (source.fill)', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const background = getBackground(patchmap); + const originalFill = background.props.source.fill; + expect(originalFill).toBe('white'); + + patchmap.update({ + path: '$..[?(@.id=="background-1")]', + changes: { source: { fill: 'red' } }, + history: true, + }); + const updatedFill = background.props.source.fill; + expect(updatedFill).toBe('red'); + + patchmap.undoRedoManager.undo(); + expect(getBackground(patchmap).props.source.fill).toBe(originalFill); + + patchmap.undoRedoManager.redo(); + expect(getBackground(patchmap).props.source.fill).toBe(updatedFill); + }); + + it('should correctly undo/redo multiple property changes simultaneously', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const background = getBackground(patchmap); + const originalState = { + tint: background.tint, + borderWidth: background.props.source.borderWidth, + fill: background.props.source.fill, + }; + + const changes = { + tint: 'primary.dark', // #083967 + source: { + borderWidth: 5, + fill: 'blue', + }, + }; + patchmap.update({ + path: '$..[?(@.id=="background-1")]', + changes, + history: true, + }); + + const updatedState = { + tint: background.tint, + borderWidth: background.props.source.borderWidth, + fill: background.props.source.fill, + }; + expect(updatedState.tint).toBe(0x083967); + expect(updatedState.borderWidth).toBe(5); + expect(updatedState.fill).toBe('blue'); + + patchmap.undoRedoManager.undo(); + const undoneState = { + tint: getBackground(patchmap).tint, + borderWidth: getBackground(patchmap).props.source.borderWidth, + fill: getBackground(patchmap).props.source.fill, + }; + expect(undoneState).toEqual(originalState); + + patchmap.undoRedoManager.redo(); + const redoneState = { + tint: getBackground(patchmap).tint, + borderWidth: getBackground(patchmap).props.source.borderWidth, + fill: getBackground(patchmap).props.source.fill, + }; + expect(redoneState).toEqual(updatedState); + }); + + it('should correctly undo/redo replacing the entire source object', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const background = getBackground(patchmap); + const originalSource = JSON.parse(JSON.stringify(background.props.source)); + + const newSource = { type: 'rect', fill: 'green', radius: 10 }; + const expectedMergedSource = deepMerge(originalSource, newSource); + + patchmap.update({ + path: '$..[?(@.id=="background-1")]', + changes: { source: newSource }, + history: true, + }); + expect(background.props.source).toEqual(expectedMergedSource); + + patchmap.undoRedoManager.undo(); + expect(background.props.source).toEqual(originalSource); + + patchmap.undoRedoManager.redo(); + expect(background.props.source).toEqual(expectedMergedSource); + }); + + it('should correctly undo/redo pixi attributes (alpha, angle) via attrs', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const background = getBackground(patchmap); + const originalAlpha = background.alpha; + const originalAngle = background.angle; + + expect(originalAlpha).toBe(1); + expect(originalAngle).toBe(0); + + const changes = { attrs: { alpha: 0.5, angle: 45 } }; + patchmap.update({ + path: '$..[?(@.id=="background-1")]', + changes, + history: true, + }); + + const updatedBackground = getBackground(patchmap); + expect(updatedBackground.alpha).toBe(0.5); + expect(updatedBackground.angle).toBe(45); + + // Undo + patchmap.undoRedoManager.undo(); + const undoneBackground = getBackground(patchmap); + expect(undoneBackground.alpha).toBe(originalAlpha); + expect(undoneBackground.angle).toBe(originalAngle); + + // Redo + patchmap.undoRedoManager.redo(); + const redoneBackground = getBackground(patchmap); + expect(redoneBackground.alpha).toBe(0.5); + expect(redoneBackground.angle).toBe(45); + }); + + it('should correctly undo/redo custom metadata via attrs', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const background = getBackground(patchmap); + expect(background?.customMeta).toBeUndefined(); + + const customData = { version: 1, author: 'test' }; + const changes = { attrs: { customMeta: customData } }; + + patchmap.update({ + path: '$..[?(@.id=="background-1")]', + changes, + history: true, + }); + + const updatedBackground = getBackground(patchmap); + expect(updatedBackground.customMeta).toEqual(customData); + + // Undo + patchmap.undoRedoManager.undo(); + const undoneBackground = getBackground(patchmap); + expect(undoneBackground?.customMeta).toBeUndefined(); + + // Redo + patchmap.undoRedoManager.redo(); + const redoneBackground = getBackground(patchmap); + expect(redoneBackground.customMeta).toEqual(customData); + }); +}); diff --git a/src/tests/undo-redo/Bar.test.js b/src/tests/undo-redo/Bar.test.js new file mode 100644 index 00000000..14295ce9 --- /dev/null +++ b/src/tests/undo-redo/Bar.test.js @@ -0,0 +1,305 @@ +import gsap from 'gsap'; +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: Bar Component', () => { + const { getPatchmap } = setupPatchmapTests(); + + const baseItemData = { + type: 'item', + id: 'item-with-bar', + size: { width: 200, height: 100 }, + components: [ + { + type: 'bar', + id: 'bar-1', + source: { type: 'rect', fill: 'blue', radius: 2 }, + size: { width: '50%', height: 20 }, // width: 100, height: 20 + placement: 'bottom', + margin: 5, + tint: 0xffffff, // white + animation: true, + animationDuration: 200, + }, + ], + }; + + const baseItemDataWithoutOptionals = { + type: 'item', + id: 'item-with-bar', + size: { width: 200, height: 100 }, + components: [ + { + type: 'bar', + id: 'bar-1', + source: { type: 'rect', fill: 'blue' }, + size: { width: '50%', height: 20 }, + }, + ], + }; + + const getBar = (patchmap) => { + return patchmap.selector('$..[?(@.id=="bar-1")]')[0]; + }; + + it('should correctly undo/redo a size change and verify rendered dimensions', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + gsap.exportRoot().totalProgress(1); + + const bar = getBar(patchmap); + const originalSizeProps = JSON.parse(JSON.stringify(bar.props.size)); + const originalDimensions = { width: bar.width, height: bar.height }; + expect(originalDimensions).toEqual({ width: 100, height: 20 }); + + const newSize = { width: '80%', height: 30 }; + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { size: newSize }, + history: true, + }); + gsap.exportRoot().totalProgress(1); + + const updatedBar = getBar(patchmap); + expect(updatedBar.props.size).toEqual({ + width: { value: 80, unit: '%' }, + height: { value: 30, unit: 'px' }, + }); + expect(updatedBar.width).toBe(160); // 200 * 80% + expect(updatedBar.height).toBe(30); + + patchmap.undoRedoManager.undo(); + gsap.exportRoot().totalProgress(1); + const undoneBar = getBar(patchmap); + expect(undoneBar.props.size).toEqual(originalSizeProps); + expect(undoneBar.width).toBe(originalDimensions.width); + expect(undoneBar.height).toBe(originalDimensions.height); + + patchmap.undoRedoManager.redo(); + gsap.exportRoot().totalProgress(1); + const redoneBar = getBar(patchmap); + expect(redoneBar.props.size).toEqual({ + width: { value: 80, unit: '%' }, + height: { value: 30, unit: 'px' }, + }); + expect(redoneBar.width).toBe(160); + expect(redoneBar.height).toBe(30); + }); + + it('should correctly undo/redo a placement change and verify rendered position', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + gsap.exportRoot().totalProgress(1); + + const bar = getBar(patchmap); + const originalPosition = { x: bar.x, y: bar.y }; + expect(originalPosition).toEqual({ x: 50, y: 75 }); + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { placement: 'left-top' }, + history: true, + }); + + const updatedBar = getBar(patchmap); + expect(updatedBar.props.placement).toBe('left-top'); + expect(updatedBar.x).toBe(5); + expect(updatedBar.y).toBe(5); + + patchmap.undoRedoManager.undo(); + const undoneBar = getBar(patchmap); + expect(undoneBar.props.placement).toBe('bottom'); + expect(undoneBar.x).toBe(originalPosition.x); + expect(undoneBar.y).toBe(originalPosition.y); + + patchmap.undoRedoManager.redo(); + const redoneBar = getBar(patchmap); + expect(redoneBar.props.placement).toBe('left-top'); + expect(redoneBar.x).toBe(5); + expect(redoneBar.y).toBe(5); + }); + + it('should correctly undo/redo a margin change and verify rendered position', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + gsap.exportRoot().totalProgress(1); + + const bar = getBar(patchmap); + const originalPosition = { x: bar.x, y: bar.y }; + expect(originalPosition).toEqual({ x: 50, y: 75 }); + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { margin: 20 }, + history: true, + }); + + const updatedBar = getBar(patchmap); + expect(updatedBar.props.margin).toEqual({ + top: 20, + right: 20, + bottom: 20, + left: 20, + }); + expect(updatedBar.x).toBe(50); + expect(updatedBar.y).toBe(60); + + patchmap.undoRedoManager.undo(); + const undoneBar = getBar(patchmap); + expect(undoneBar.props.margin).toEqual({ + top: 5, + right: 5, + bottom: 5, + left: 5, + }); + expect(undoneBar.x).toBe(originalPosition.x); + expect(undoneBar.y).toBe(originalPosition.y); + + patchmap.undoRedoManager.redo(); + const redoneBar = getBar(patchmap); + expect(redoneBar.props.margin).toEqual({ + top: 20, + right: 20, + bottom: 20, + left: 20, + }); + expect(redoneBar.x).toBe(50); + expect(redoneBar.y).toBe(60); + }); + + it('should correctly undo/redo a single property change (tint)', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const bar = getBar(patchmap); + const originalTint = bar.tint; + expect(originalTint).toBe(0xffffff); + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { tint: 0xff0000 }, + history: true, + }); + const updatedTint = getBar(patchmap).tint; + expect(updatedTint).toBe(0xff0000); + + patchmap.undoRedoManager.undo(); + expect(getBar(patchmap).tint).toBe(originalTint); + + patchmap.undoRedoManager.redo(); + expect(getBar(patchmap).tint).toBe(updatedTint); + }); + + it('should correctly undo/redo a nested property change (source.fill)', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const bar = getBar(patchmap); + const originalFill = bar.props.source.fill; + expect(originalFill).toBe('blue'); + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { source: { fill: 'green' } }, + history: true, + }); + const updatedFill = getBar(patchmap).props.source.fill; + expect(updatedFill).toBe('green'); + + patchmap.undoRedoManager.undo(); + expect(getBar(patchmap).props.source.fill).toBe(originalFill); + + patchmap.undoRedoManager.redo(); + expect(getBar(patchmap).props.source.fill).toBe(updatedFill); + }); + + describe('when optional properties are initially undefined', () => { + it('should correctly add a new property (margin) and undo to its default state', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemDataWithoutOptionals]); + gsap.exportRoot().totalProgress(1); + + const bar = getBar(patchmap); + const originalMargin = bar.props.margin; + const originalPosition = { x: bar.x, y: bar.y }; + + expect(originalMargin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); + expect(originalPosition).toEqual({ x: 50, y: 80 }); + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { margin: 15 }, + history: true, + }); + gsap.exportRoot().totalProgress(1); + + const updatedBar = getBar(patchmap); + expect(updatedBar.props.margin).toEqual({ + top: 15, + right: 15, + bottom: 15, + left: 15, + }); + expect(updatedBar.x).toBe(50); + expect(updatedBar.y).toBe(65); + + patchmap.undoRedoManager.undo(); + gsap.exportRoot().totalProgress(1); + const undoneBar = getBar(patchmap); + expect(undoneBar.props.margin).toEqual(originalMargin); + expect(undoneBar.x).toBe(originalPosition.x); + expect(undoneBar.y).toBe(originalPosition.y); + + patchmap.undoRedoManager.redo(); + gsap.exportRoot().totalProgress(1); + const redoneBar = getBar(patchmap); + expect(redoneBar.props.margin).toEqual({ + top: 15, + right: 15, + bottom: 15, + left: 15, + }); + expect(redoneBar.x).toBe(50); + expect(redoneBar.y).toBe(65); + }); + + it('should correctly add a new property (placement) and undo to its default state', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemDataWithoutOptionals]); + gsap.exportRoot().totalProgress(1); + + const bar = getBar(patchmap); + const originalPlacement = bar.props.placement; + const originalPosition = { x: bar.x, y: bar.y }; + + expect(originalPlacement).toBe('bottom'); + expect(originalPosition).toEqual({ x: 50, y: 80 }); + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { placement: 'center' }, + history: true, + }); + gsap.exportRoot().totalProgress(1); + + const updatedBar = getBar(patchmap); + expect(updatedBar.props.placement).toBe('center'); + expect(updatedBar.x).toBe(50); + expect(updatedBar.y).toBe(40); + + patchmap.undoRedoManager.undo(); + gsap.exportRoot().totalProgress(1); + const undoneBar = getBar(patchmap); + expect(undoneBar.props.placement).toBe(originalPlacement); + expect(undoneBar.x).toBe(originalPosition.x); + expect(undoneBar.y).toBe(originalPosition.y); + + patchmap.undoRedoManager.redo(); + gsap.exportRoot().totalProgress(1); + const redoneBar = getBar(patchmap); + expect(redoneBar.props.placement).toBe('center'); + expect(redoneBar.x).toBe(50); + expect(redoneBar.y).toBe(40); + }); + }); +}); diff --git a/src/tests/undo-redo/Grid.test.js b/src/tests/undo-redo/Grid.test.js new file mode 100644 index 00000000..db06833a --- /dev/null +++ b/src/tests/undo-redo/Grid.test.js @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: Grid Element', () => { + const { getPatchmap } = setupPatchmapTests(); + + const baseGridData = [ + { + type: 'grid', + id: 'grid-1', + cells: [ + [1, 0, 1], + [1, 1, 0], + ], + gap: 10, + item: { + size: { width: 50, height: 50 }, + components: [ + { + type: 'background', + id: 'bg-component', + label: 'bg-component', + source: { type: 'rect', fill: 'white' }, + tint: 'primary.default', + }, + ], + }, + attrs: { x: 100, y: 100 }, + }, + ]; + + const getGrid = (patchmap) => { + return patchmap.selector('$..[?(@.id=="grid-1")]')[0]; + }; + + const getGridItems = (patchmap) => { + return getGrid(patchmap).children; + }; + + it('should undo/redo changes to the "cells" property, altering the number of items', () => { + const patchmap = getPatchmap(); + patchmap.draw(baseGridData); + + expect(getGridItems(patchmap).length).toBe(4); + + const newCells = [ + [1, 1, 1], + [1, 1, 1], + ]; + patchmap.update({ + path: '$..[?(@.id=="grid-1")]', + changes: { cells: newCells }, + history: true, + }); + expect(getGridItems(patchmap).length).toBe(6); + + patchmap.undoRedoManager.undo(); + expect(getGridItems(patchmap).length).toBe(4); + + patchmap.undoRedoManager.redo(); + expect(getGridItems(patchmap).length).toBe(6); + }); + + it('should undo/redo changes to the "gap" property, altering item positions', () => { + const patchmap = getPatchmap(); + patchmap.draw(baseGridData); + + const item1_1 = getGrid(patchmap).children.find( + (item) => item.id === 'grid-1.1.1', + ); + const originalPosition = { x: item1_1.x, y: item1_1.y }; + // size (50) + gap (10) = 60. So position is (60, 60). + expect(originalPosition).toEqual({ x: 60, y: 60 }); + + patchmap.update({ + path: '$..[?(@.id=="grid-1")]', + changes: { gap: 20 }, + history: true, + }); + const updatedPosition = { x: item1_1.x, y: item1_1.y }; + // size (50) + gap (20) = 70. So position is (70, 70). + expect(updatedPosition).toEqual({ x: 70, y: 70 }); + + patchmap.undoRedoManager.undo(); + expect({ x: item1_1.x, y: item1_1.y }).toEqual(originalPosition); + + patchmap.undoRedoManager.redo(); + expect({ x: item1_1.x, y: item1_1.y }).toEqual(updatedPosition); + }); + + it('should undo/redo changes to the "item.size" property, altering item size and positions', () => { + const patchmap = getPatchmap(); + patchmap.draw(baseGridData); + + const items = getGridItems(patchmap); + const item1_1 = items.find((c) => c.id === 'grid-1.1.1'); + const originalSize = { width: items[0].width, height: items[0].height }; + const originalPosition = { x: item1_1.x, y: item1_1.y }; + expect(originalSize).toEqual({ width: 50, height: 50 }); + expect(originalPosition).toEqual({ x: 60, y: 60 }); + + patchmap.update({ + path: '$..[?(@.id=="grid-1")]', + changes: { item: { size: { width: 60, height: 40 } } }, + history: true, + }); + const updatedSize = { width: items[0].width, height: items[0].height }; + const updatedPosition = { x: item1_1.x, y: item1_1.y }; + expect(updatedSize).toEqual({ width: 60, height: 40 }); + // new pos: x = 60 + 10 = 70; y = 40 + 10 = 50 + expect(updatedPosition).toEqual({ x: 70, y: 50 }); + + patchmap.undoRedoManager.undo(); + expect({ width: items[0].width, height: items[0].height }).toEqual( + originalSize, + ); + expect({ x: item1_1.x, y: item1_1.y }).toEqual(originalPosition); + + patchmap.undoRedoManager.redo(); + expect({ width: items[0].width, height: items[0].height }).toEqual( + updatedSize, + ); + expect({ x: item1_1.x, y: item1_1.y }).toEqual(updatedPosition); + }); + + it('should undo/redo changes to "item.components", altering all items style', () => { + const patchmap = getPatchmap(); + patchmap.draw(baseGridData); + + const getItemBackground = (item) => item.getChildByLabel('bg-component'); + + const originalTint = getItemBackground(getGridItems(patchmap)[0]).tint; + expect(originalTint).toBe(0x0c73bf); // primary.default + + const newComponents = [ + { + type: 'background', + id: 'bg-component', + source: { type: 'rect', fill: 'red' }, + tint: 'primary.accent', // #EF4444 + }, + ]; + + patchmap.update({ + path: '$..[?(@.id=="grid-1")]', + changes: { item: { components: newComponents } }, + history: true, + }); + + const updatedTint = getItemBackground(getGridItems(patchmap)[0]).tint; + expect(updatedTint).toBe(0xef4444); + + patchmap.undoRedoManager.undo(); + expect(getItemBackground(getGridItems(patchmap)[0]).tint).toBe( + originalTint, + ); + + patchmap.undoRedoManager.redo(); + expect(getItemBackground(getGridItems(patchmap)[0]).tint).toBe(updatedTint); + }); + + it('should handle simultaneous changes to multiple properties correctly', () => { + const patchmap = getPatchmap(); + patchmap.draw(baseGridData); + + const originalState = { + itemCount: getGridItems(patchmap).length, + itemSize: { ...getGrid(patchmap).props.item.size }, + gap: getGrid(patchmap).props.gap, + tint: getGridItems(patchmap)[0].getChildByLabel('bg-component').tint, + }; + + patchmap.update({ + path: '$..[?(@.id=="grid-1")]', + changes: { + cells: [ + [1, 1], + [1, 1], + ], // Change structure + gap: 20, // Change layout + item: { + // Change item properties + size: { width: 40, height: 40 }, + components: [ + { + type: 'background', + id: 'bg-component', + source: { type: 'rect', fill: 'blue' }, + tint: 'primary.accent', + }, + ], + }, + }, + history: true, + }); + + // Verify updated state + const updatedGrid = getGrid(patchmap); + const updatedItems = updatedGrid.children; + expect(updatedItems.length).toBe(4); + expect(updatedGrid.props.gap).toStrictEqual({ x: 20, y: 20 }); + expect(updatedGrid.props.item.size.width).toBe(40); + expect(updatedItems[0].getChildByLabel('bg-component').tint).toBe(0xef4444); + + // --- Undo --- + patchmap.undoRedoManager.undo(); + const undoneGrid = getGrid(patchmap); + const undoneItems = undoneGrid.children; + expect(undoneItems.length).toBe(originalState.itemCount); + expect(undoneGrid.props.gap).toStrictEqual(originalState.gap); + expect(undoneGrid.props.item.size.width).toBe(originalState.itemSize.width); + expect(undoneItems[0].getChildByLabel('bg-component').tint).toBe( + originalState.tint, + ); + + // --- Redo --- + patchmap.undoRedoManager.redo(); + const redoneGrid = getGrid(patchmap); + const redoneItems = redoneGrid.children; + expect(redoneItems.length).toBe(4); + expect(redoneGrid.props.gap).toStrictEqual({ x: 20, y: 20 }); + expect(redoneGrid.props.item.size.width).toBe(40); + expect(redoneItems[0].getChildByLabel('bg-component').tint).toBe(0xef4444); + }); +}); diff --git a/src/tests/undo-redo/Group.test.js b/src/tests/undo-redo/Group.test.js new file mode 100644 index 00000000..f1ec03c0 --- /dev/null +++ b/src/tests/undo-redo/Group.test.js @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: Group Element', () => { + const { getPatchmap } = setupPatchmapTests(); + + // Base data structure with a Group containing other Elements. + const initialMapData = [ + { + type: 'group', + id: 'group-1', + children: [ + { type: 'item', id: 'item-1', size: 50, label: 'Initial Item 1' }, + { type: 'item', id: 'item-2', size: 60, label: 'Initial Item 2' }, + ], + attrs: { x: 100, y: 100 }, + }, + ]; + + const getGroup = (patchmap) => { + return patchmap.selector('$..[?(@.id=="group-1")]')[0]; + }; + + const getItem = (patchmap, id) => { + return patchmap.selector(`$..[?(@.id=="${id}")]`)[0]; + }; + + it('should undo/redo adding a new child Element to the group', () => { + const patchmap = getPatchmap(); + patchmap.draw(initialMapData); + + const group = getGroup(patchmap); + expect(group.children.length).toBe(2); + + const newItem = { type: 'item', id: 'item-3', size: 70 }; + patchmap.update({ + path: '$..[?(@.id=="group-1")]', + changes: { children: [newItem] }, + history: true, + }); + + const groupAfterUpdate = getGroup(patchmap); + expect(groupAfterUpdate.children.length).toBe(3); + expect(getItem(patchmap, 'item-3')).toBeDefined(); + + // --- Undo --- + patchmap.undoRedoManager.undo(); + const groupAfterUndo = getGroup(patchmap); + expect(groupAfterUndo.children.length).toBe(2); + expect(getItem(patchmap, 'item-3')).toBeUndefined(); + + // --- Redo --- + patchmap.undoRedoManager.redo(); + const groupAfterRedo = getGroup(patchmap); + expect(groupAfterRedo.children.length).toBe(3); + expect(getItem(patchmap, 'item-3')).toBeDefined(); + }); + + it('should undo/redo removing a child Element from the group', () => { + const patchmap = getPatchmap(); + patchmap.draw(initialMapData); + + expect(getGroup(patchmap).children.length).toBe(2); + + // Remove item-2 + const updatedChildren = [ + { type: 'item', id: 'item-1', size: 50, label: 'Initial Item 1' }, + ]; + + patchmap.update({ + path: '$..[?(@.id=="group-1")]', + changes: { children: updatedChildren }, + history: true, + mergeStrategy: 'replace', // Use replace to correctly handle array removal + }); + + expect(getGroup(patchmap).children.length).toBe(1); + expect(getItem(patchmap, 'item-2')).toBeUndefined(); + + // --- Undo --- + patchmap.undoRedoManager.undo(); + expect(getGroup(patchmap).children.length).toBe(2); + expect(getItem(patchmap, 'item-2')).toBeDefined(); + + // --- Redo --- + patchmap.undoRedoManager.redo(); + expect(getGroup(patchmap).children.length).toBe(1); + expect(getItem(patchmap, 'item-2')).toBeUndefined(); + }); + + it('should undo/redo a property change on a nested child Element', () => { + const patchmap = getPatchmap(); + patchmap.draw(initialMapData); + + const item1 = getItem(patchmap, 'item-1'); + const originalLabel = item1.label; + expect(originalLabel).toBe('Initial Item 1'); + + patchmap.update({ + path: '$..[?(@.id=="item-1")]', + changes: { label: 'Updated Label' }, + history: true, + }); + + expect(getItem(patchmap, 'item-1').label).toBe('Updated Label'); + // Ensure the group's internal props reflect this change for history + expect(getGroup(patchmap).props.children[0].label).toBe('Updated Label'); + + // --- Undo --- + patchmap.undoRedoManager.undo(); + expect(getItem(patchmap, 'item-1').label).toBe(originalLabel); + expect(getGroup(patchmap).props.children[0].label).toBe(originalLabel); + + // --- Redo --- + patchmap.undoRedoManager.redo(); + expect(getItem(patchmap, 'item-1').label).toBe('Updated Label'); + expect(getGroup(patchmap).props.children[0].label).toBe('Updated Label'); + }); + + it('should correctly handle undo/redo in a deeply nested group structure', () => { + const patchmap = getPatchmap(); + const deepData = [ + { + type: 'group', + id: 'group-1', + children: [ + { + type: 'group', + id: 'group-2', + children: [ + { type: 'item', id: 'item-deep', size: 30, label: 'Deep Item' }, + ], + }, + ], + }, + ]; + patchmap.draw(deepData); + + const deepItem = getItem(patchmap, 'item-deep'); + expect(deepItem.label).toBe('Deep Item'); + + patchmap.update({ + path: '$..[?(@.id=="item-deep")]', + changes: { label: 'Updated Deep' }, + history: true, + }); + + expect(getItem(patchmap, 'item-deep').label).toBe('Updated Deep'); + + patchmap.undoRedoManager.undo(); + expect(getItem(patchmap, 'item-deep').label).toBe('Deep Item'); + + patchmap.undoRedoManager.redo(); + expect(getItem(patchmap, 'item-deep').label).toBe('Updated Deep'); + }); +}); diff --git a/src/tests/undo-redo/Icon.test.js b/src/tests/undo-redo/Icon.test.js new file mode 100644 index 00000000..9b20903d --- /dev/null +++ b/src/tests/undo-redo/Icon.test.js @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: Icon Component', () => { + const { getPatchmap } = setupPatchmapTests(); + + // A reusable factory for creating item data to reduce duplication + const createItemWithIcon = (iconProps) => ({ + type: 'item', + id: 'item-with-icon', + size: { width: 200, height: 100 }, + padding: 10, // Add padding for more complex layout tests + components: [ + { + type: 'icon', + id: 'icon-1', + source: 'device', + size: 50, + placement: 'center', + ...iconProps, + }, + ], + }); + + const getIcon = (patchmap) => { + return patchmap.selector('$..[?(@.id=="icon-1")]')[0]; + }; + + it('should correctly undo/redo a simple property change (tint)', () => { + const patchmap = getPatchmap(); + patchmap.draw([createItemWithIcon({ tint: 'primary.default' })]); + + const icon = getIcon(patchmap); + const originalTint = icon.tint; + expect(originalTint).toBe(0x0c73bf); // 'primary.default' + + patchmap.update({ + path: '$..[?(@.id=="icon-1")]', + changes: { tint: 'primary.accent' }, // #EF4444 + history: true, + }); + const updatedTint = getIcon(patchmap).tint; + expect(updatedTint).toBe(0xef4444); + + patchmap.undoRedoManager.undo(); + expect(getIcon(patchmap).tint).toBe(originalTint); + + patchmap.undoRedoManager.redo(); + expect(getIcon(patchmap).tint).toBe(updatedTint); + }); + + it('should correctly undo/redo a source change', () => { + const patchmap = getPatchmap(); + patchmap.draw([createItemWithIcon({ source: 'device' })]); + const originalSource = getIcon(patchmap).props.source; + expect(originalSource).toBe('device'); + + patchmap.update({ + path: '$..[?(@.id=="icon-1")]', + changes: { source: 'wifi' }, + history: true, + }); + const updatedSource = getIcon(patchmap).props.source; + expect(updatedSource).toBe('wifi'); + + patchmap.undoRedoManager.undo(); + expect(getIcon(patchmap).props.source).toBe(originalSource); + + patchmap.undoRedoManager.redo(); + expect(getIcon(patchmap).props.source).toBe(updatedSource); + }); + + describe('Edge cases for the "size" property', () => { + it('should handle percentage-based size correctly within a padded parent', () => { + // Parent content size: width=180 (200-2*10), height=80 (100-2*10) + const patchmap = getPatchmap(); + patchmap.draw([createItemWithIcon({ size: '50%' })]); + + const icon = getIcon(patchmap); + expect(icon.width).toBe(90); // 180 * 0.5 + expect(icon.height).toBe(40); // 80 * 0.5 + + patchmap.update({ + path: '$..[?(@.id=="icon-1")]', + changes: { size: '100%' }, + history: true, + }); + + expect(getIcon(patchmap).width).toBe(180); + expect(getIcon(patchmap).height).toBe(80); + + patchmap.undoRedoManager.undo(); + expect(getIcon(patchmap).width).toBe(90); + expect(getIcon(patchmap).height).toBe(40); + }); + + it('should handle mixed unit size object correctly', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithIcon({ size: { width: 80, height: '25%' } }), + ]); + + const icon = getIcon(patchmap); + expect(icon.width).toBe(80); + expect(icon.height).toBe(20); // 80 * 0.25 + + patchmap.update({ + path: '$..[?(@.id=="icon-1")]', + changes: { size: { width: '20%', height: 30 } }, + history: true, + }); + + expect(getIcon(patchmap).width).toBe(36); // 180 * 0.2 + expect(getIcon(patchmap).height).toBe(30); + + patchmap.undoRedoManager.undo(); + expect(getIcon(patchmap).width).toBe(80); + expect(getIcon(patchmap).height).toBe(20); + }); + + it('should treat a partial size object as a merge, not a replacement', () => { + const patchmap = getPatchmap(); + patchmap.draw([createItemWithIcon({ size: { width: 80, height: 40 } })]); + + patchmap.update({ + path: '$..[?(@.id=="icon-1")]', + changes: { size: { width: '50%' } }, // Only width is provided + history: true, + }); + + const icon = getIcon(patchmap); + // Parent content width is 180, so 50% is 90 + expect(icon.width).toBe(90); + // Height should remain unchanged from the initial state + expect(icon.height).toBe(40); + + patchmap.undoRedoManager.undo(); + const undoneIcon = getIcon(patchmap); + expect(undoneIcon.width).toBe(80); + expect(undoneIcon.height).toBe(40); + }); + }); + + describe('Interaction between placement and margin', () => { + it('should correctly position the icon with right-bottom placement and margin inside a padded item', () => { + const patchmap = getPatchmap(); + patchmap.draw([ + createItemWithIcon({ + size: 40, + placement: 'right-bottom', + margin: 5, + }), + ]); + + const icon = getIcon(patchmap); + // Item inner size: 180x80. + // Right edge = padding.right(10) + contentWidth(180) = 190 + // Bottom edge = padding.top(10) + contentHeight(80) = 90 + // X = right_edge - margin.right(5) - icon.width(40) = 190 - 5 - 40 = 145 + // Y = bottom_edge - margin.bottom(5) - icon.height(40) = 90 - 5 - 40 = 45 + expect(icon.x).toBe(145); + expect(icon.y).toBe(45); + + patchmap.update({ + path: '$..[?(@.id=="icon-1")]', + changes: { margin: { x: 10, y: 15 } }, + history: true, + }); + // X = 190 - 10 - 40 = 140 + // Y = 90 - 15 - 40 = 35 + expect(getIcon(patchmap).x).toBe(140); + expect(getIcon(patchmap).y).toBe(35); + + patchmap.undoRedoManager.undo(); + expect(getIcon(patchmap).x).toBe(145); + expect(getIcon(patchmap).y).toBe(45); + }); + }); + + it('should correctly add a new property (margin) from an undefined state and undo to default', () => { + const patchmap = getPatchmap(); + // Create an icon without an explicit margin. + patchmap.draw([createItemWithIcon({ size: 50, placement: 'left-top' })]); + + const icon = getIcon(patchmap); + const originalPosition = { x: icon.x, y: icon.y }; + expect(icon.props.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); + // Position with no margin inside a padded (10px) item. + expect(originalPosition).toEqual({ x: 10, y: 10 }); + + patchmap.update({ + path: '$..[?(@.id=="icon-1")]', + changes: { margin: { left: 20, top: 5 } }, + history: true, + }); + + const updatedIcon = getIcon(patchmap); + expect(updatedIcon.props.margin).toEqual({ + top: 5, + right: 0, + bottom: 0, + left: 20, + }); + // Position is padding + margin + expect(updatedIcon.x).toBe(10 + 20); // 30 + expect(updatedIcon.y).toBe(10 + 5); // 15 + + patchmap.undoRedoManager.undo(); + const undoneIcon = getIcon(patchmap); + expect(undoneIcon.props.margin).toEqual({ + top: 0, + right: 0, + bottom: 0, + left: 0, + }); + expect(undoneIcon.x).toBe(originalPosition.x); + expect(undoneIcon.y).toBe(originalPosition.y); + + patchmap.undoRedoManager.redo(); + const redoneIcon = getIcon(patchmap); + expect(redoneIcon.x).toBe(30); + expect(redoneIcon.y).toBe(15); + }); +}); diff --git a/src/tests/undo-redo/Relations.test.js b/src/tests/undo-redo/Relations.test.js new file mode 100644 index 00000000..b58d7052 --- /dev/null +++ b/src/tests/undo-redo/Relations.test.js @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: Relations Element', () => { + const { getPatchmap } = setupPatchmapTests(); + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const baseComponents = [ + { + type: 'background', + source: { type: 'rect', fill: 'white' }, + tint: 'gray.default', + }, + ]; + + // Base data with items to be connected by the Relations element + const baseMapData = [ + { + type: 'item', + id: 'item-A', + size: 50, + attrs: { x: 100, y: 100 }, + components: baseComponents, + }, + { + type: 'item', + id: 'item-B', + size: 50, + attrs: { x: 300, y: 100 }, + components: baseComponents, + }, + { + type: 'item', + id: 'item-C', + size: 50, + attrs: { x: 200, y: 300 }, + components: baseComponents, + }, + { + type: 'relations', + id: 'rel-1', + links: [{ source: 'item-A', target: 'item-B' }], + style: { width: 2, color: 'primary.default' }, + }, + ]; + + const getRelations = (patchmap) => { + return patchmap.selector('$..[?(@.id=="rel-1")]')[0]; + }; + + const getPath = (patchmap) => { + // The actual line is a Graphics object child of the Relations element + return getRelations(patchmap).children[0]; + }; + + it('should undo/redo changes to the "links" property', () => { + const patchmap = getPatchmap(); + patchmap.draw(baseMapData); + + const originalLinks = getRelations(patchmap).props.links; + expect(originalLinks.length).toBe(1); + + const newLinks = [{ source: 'item-B', target: 'item-C' }]; + + patchmap.update({ + path: '$..[?(@.id=="rel-1")]', + changes: { links: newLinks }, + history: true, + }); + + const updatedLinks = getRelations(patchmap).props.links; + expect(updatedLinks.length).toBe(2); + + patchmap.undoRedoManager.undo(); + expect(getRelations(patchmap).props.links.length).toBe(1); + expect(getRelations(patchmap).props.links).toEqual(originalLinks); + + patchmap.undoRedoManager.redo(); + expect(getRelations(patchmap).props.links.length).toBe(2); + expect(getRelations(patchmap).props.links).toEqual([ + ...originalLinks, + ...newLinks, + ]); + }); + + it('should undo/redo changes to the "style" property', () => { + const patchmap = getPatchmap(); + patchmap.draw(baseMapData); + + const path = getPath(patchmap); + const originalStyle = { ...path.strokeStyle }; + + expect(originalStyle.width).toBe(2); + // Note: The color is resolved from the theme + expect(originalStyle.color).toBe(0x0c73bf); + + patchmap.update({ + path: '$..[?(@.id=="rel-1")]', + changes: { style: { width: 5, color: 'primary.accent' } }, + history: true, + }); + + const updatedStyle = getPath(patchmap).strokeStyle; + expect(updatedStyle.width).toBe(5); + expect(updatedStyle.color).toBe(0xef4444); + + patchmap.undoRedoManager.undo(); + const undoneStyle = getPath(patchmap).strokeStyle; + expect(undoneStyle.width).toBe(originalStyle.width); + expect(undoneStyle.color).toBe(originalStyle.color); + + patchmap.undoRedoManager.redo(); + const redoneStyle = getPath(patchmap).strokeStyle; + expect(redoneStyle.width).toBe(5); + expect(redoneStyle.color).toBe(0xef4444); + }); + + it('should correctly undo/redo adding a style property when it was initially undefined', async () => { + const patchmap = getPatchmap(); + const dataWithoutStyle = JSON.parse(JSON.stringify(baseMapData)); + dataWithoutStyle[3].style = undefined; + + patchmap.draw(dataWithoutStyle); + await vi.advanceTimersByTimeAsync(100); + + const relations = getRelations(patchmap); + const path = getPath(patchmap); + + expect(relations.props.style).toStrictEqual({ color: '#1A1A1A' }); + const initialStrokeStyle = { ...path.strokeStyle }; + expect(initialStrokeStyle.width).toBe(1); + + const newStyle = { width: 3, color: 'red', cap: 'round' }; + patchmap.update({ + path: '$..[?(@.id=="rel-1")]', + changes: { style: newStyle }, + history: true, + }); + + const updatedStrokeStyle = getPath(patchmap).strokeStyle; + expect(getRelations(patchmap).props.style).toEqual(newStyle); + expect(updatedStrokeStyle.width).toBe(3); + expect(updatedStrokeStyle.color).toBe(0xff0000); + expect(updatedStrokeStyle.cap).toBe('round'); + + patchmap.undoRedoManager.undo(); + const undoneRelations = getRelations(patchmap); + const undonePath = getPath(patchmap); + expect(undoneRelations.props.style).toStrictEqual({ color: '#1A1A1A' }); + expect(undonePath.strokeStyle.width).toBe(initialStrokeStyle.width); + expect(undonePath.strokeStyle.color).toBe(initialStrokeStyle.color); + expect(undonePath.strokeStyle.cap).not.toBe('round'); + + patchmap.undoRedoManager.redo(); + const redoneRelations = getRelations(patchmap); + const redonePath = getPath(patchmap); + expect(redoneRelations.props.style).toEqual(newStyle); + expect(redonePath.strokeStyle.width).toBe(3); + expect(redonePath.strokeStyle.color).toBe(0xff0000); + expect(redonePath.strokeStyle.cap).toBe('round'); + }); + + it('should redraw correctly after a linked item is moved and then undone', async () => { + const patchmap = getPatchmap(); + patchmap.draw(baseMapData); + await vi.advanceTimersByTimeAsync(100); + + const path = getPath(patchmap); + const originalPathSize = path.getSize(); + expect(originalPathSize.width).toBeGreaterThan(0); + expect(originalPathSize.height).toBeGreaterThan(0); + + patchmap.update({ + path: '$..[?(@.id=="item-B")]', + changes: { attrs: { x: 400, y: 200 } }, + history: true, + }); + + await vi.advanceTimersByTimeAsync(100); + const updatedPathSize = path.getSize(); + expect(updatedPathSize.width).not.toBe(originalPathSize.width); + expect(updatedPathSize.height).not.toBe(originalPathSize.height); + + // --- Undo the movement of item-B --- + patchmap.undoRedoManager.undo(); + await vi.advanceTimersByTimeAsync(100); + const undonePathSize = path.getSize(); + + const itemB = patchmap.selector('$..[?(@.id=="item-B")]')[0]; + expect(itemB.x).toBe(300); // Back to original position + + // The path should be redrawn to its original state. + // Due to graphics rendering intricacies, we check for approximate equality. + expect(undonePathSize.width).toBeCloseTo(originalPathSize.width); + expect(undonePathSize.height).toBeCloseTo(originalPathSize.height); + + // --- Redo the movement --- + patchmap.undoRedoManager.redo(); + await vi.advanceTimersByTimeAsync(100); + const redonePathSize = path.getSize(); + expect(redonePathSize.width).toBeCloseTo(updatedPathSize.width); + expect(redonePathSize.height).toBeCloseTo(updatedPathSize.height); + }); + + it('should handle additional stroke properties like "cap" and "join" in style', async () => { + const patchmap = getPatchmap(); + patchmap.draw(baseMapData); + await vi.advanceTimersByTimeAsync(100); + + const path = getPath(patchmap); + // PixiJS's default for cap is 'butt' + const originalCap = path.strokeStyle.cap; + const originalJoin = path.strokeStyle.join; + expect(originalCap).toBe('butt'); + expect(originalJoin).toBe('miter'); + + patchmap.update({ + path: '$..[?(@.id=="rel-1")]', + changes: { + style: { + cap: 'round', + join: 'round', + }, + }, + history: true, + }); + + const updatedStyle = getPath(patchmap).strokeStyle; + expect(updatedStyle.cap).toBe('round'); + expect(updatedStyle.join).toBe('round'); + + patchmap.undoRedoManager.undo(); + const undoneStyle = getPath(patchmap).strokeStyle; + expect(undoneStyle.cap).toBe(originalCap); + expect(undoneStyle.join).not.toBe('round'); // It should revert to its default + + patchmap.undoRedoManager.redo(); + const redoneStyle = getPath(patchmap).strokeStyle; + expect(redoneStyle.cap).toBe('round'); + expect(redoneStyle.join).toBe('round'); + }); +}); diff --git a/src/tests/undo-redo/Text.test.js b/src/tests/undo-redo/Text.test.js new file mode 100644 index 00000000..0969a594 --- /dev/null +++ b/src/tests/undo-redo/Text.test.js @@ -0,0 +1,360 @@ +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../render/patchmap.setup'; + +describe('Undo/Redo: Text Component', () => { + const { getPatchmap } = setupPatchmapTests(); + + const baseItemData = { + type: 'item', + id: 'item-with-text', + size: { width: 200, height: 100 }, + components: [ + { + type: 'text', + id: 'text-1', + text: 'Initial Text', + placement: 'center', + tint: 0xffffff, // white + style: { + fill: 'black', + fontSize: 24, + }, + }, + ], + }; + + const baseItemDataWithoutOptionals = { + type: 'item', + id: 'item-with-text-minimal', + size: { width: 200, height: 100 }, + components: [ + { + type: 'background', + source: { type: 'rect', borderWidth: 2, borderColor: 'black' }, + }, + { + type: 'text', + id: 'text-1', + text: 'Initial Text0', + }, + ], + }; + + const getText = (patchmap) => { + return patchmap.selector('$..[?(@.id=="text-1")]')[0]; + }; + + it('should correctly undo/redo a simple property change (text)', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const textComponent = getText(patchmap); + const originalText = textComponent.text; + expect(originalText).toBe('Initial Text'); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { text: 'Updated Text' }, + history: true, + }); + + const updatedText = getText(patchmap).text; + expect(updatedText).toBe('Updated Text'); + + // Undo + patchmap.undoRedoManager.undo(); + expect(getText(patchmap).text).toBe(originalText); + + // Redo + patchmap.undoRedoManager.redo(); + expect(getText(patchmap).text).toBe(updatedText); + }); + + it('should correctly undo/redo a placement change and verify rendered position', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const textComponent = getText(patchmap); + const originalPosition = { x: textComponent.x, y: textComponent.y }; + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { placement: 'left-top' }, + history: true, + }); + + const updatedPosition = { x: getText(patchmap).x, y: getText(patchmap).y }; + expect(updatedPosition.x).not.toBe(originalPosition.x); + expect(updatedPosition.y).not.toBe(originalPosition.y); + expect(getText(patchmap).props.placement).toBe('left-top'); + + // Undo + patchmap.undoRedoManager.undo(); + expect(getText(patchmap).props.placement).toBe('center'); + expect(getText(patchmap).x).toBe(originalPosition.x); + expect(getText(patchmap).y).toBe(originalPosition.y); + + // Redo + patchmap.undoRedoManager.redo(); + expect(getText(patchmap).props.placement).toBe('left-top'); + expect(getText(patchmap).x).toBe(updatedPosition.x); + expect(getText(patchmap).y).toBe(updatedPosition.y); + }); + + it('should correctly undo/redo a tint color change', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const textComponent = getText(patchmap); + const originalTint = textComponent.tint; + expect(originalTint).toBe(0xffffff); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { tint: 0xff0000 }, // red + history: true, + }); + expect(getText(patchmap).tint).toBe(0xff0000); + + // Undo + patchmap.undoRedoManager.undo(); + expect(getText(patchmap).tint).toBe(originalTint); + + // Redo + patchmap.undoRedoManager.redo(); + expect(getText(patchmap).tint).toBe(0xff0000); + }); + + it('should correctly undo/redo a split change', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const textComponent = getText(patchmap); + const originalText = textComponent.text; + expect(textComponent.props.split).toBe(0); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { split: 4 }, + history: true, + }); + const updatedText = getText(patchmap).text; + expect(updatedText).toBe('Init\nial \nText'); + + // Undo + patchmap.undoRedoManager.undo(); + expect(getText(patchmap).text).toBe(originalText); + + // Redo + patchmap.undoRedoManager.redo(); + expect(getText(patchmap).text).toBe(updatedText); + }); + + describe('style property undo/redo', () => { + it('should correctly undo/redo a nested style property change (fill)', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const originalFill = getText(patchmap).style.fill; + expect(originalFill).toBe('#1A1A1A'); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { style: { fill: 'red' } }, + history: true, + }); + expect(getText(patchmap).style.fill).toBe('red'); + + // Undo + patchmap.undoRedoManager.undo(); + expect(getText(patchmap).style.fill).toBe(originalFill); + + // Redo + patchmap.undoRedoManager.redo(); + expect(getText(patchmap).style.fill).toBe('red'); + }); + + it('should correctly undo/redo multiple style property changes', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const textComponent = getText(patchmap); + const originalStyle = { + fill: textComponent.style.fill, + fontSize: textComponent.style.fontSize, + fontWeight: textComponent.style.fontWeight, + }; + + expect(originalStyle.fill).toBe('#1A1A1A'); + expect(originalStyle.fontSize).toBe(24); + expect(originalStyle.fontWeight).toBe('400'); + + const newStyle = { fill: 'blue', fontSize: 48, fontWeight: 'bold' }; + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { style: newStyle }, + history: true, + }); + + const updatedStyle = getText(patchmap).style; + expect(updatedStyle.fill).toBe('blue'); + expect(updatedStyle.fontSize).toBe(48); + expect(updatedStyle.fontWeight).toBe('700'); + + // Undo + patchmap.undoRedoManager.undo(); + const undoneStyle = getText(patchmap).style; + expect(undoneStyle.fill).toBe(originalStyle.fill); + expect(undoneStyle.fontSize).toBe(originalStyle.fontSize); + expect(undoneStyle.fontWeight).toBe('400'); + + // Redo + patchmap.undoRedoManager.redo(); + const redoneStyle = getText(patchmap).style; + expect(redoneStyle.fill).toBe('blue'); + expect(redoneStyle.fontSize).toBe(48); + expect(redoneStyle.fontWeight).toBe('700'); + }); + }); + + describe('when optional properties are initially undefined', async () => { + it('should correctly add a new property (margin) and undo to its default state', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemDataWithoutOptionals]); + + const textComponent = getText(patchmap); + const originalMargin = textComponent.props.margin; + const originalPosition = { x: textComponent.x, y: textComponent.y }; + expect(originalMargin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { + margin: { left: 20, top: 10 }, + style: { fontWeight: 'bold' }, + }, + history: true, + }); + + const updatedBar = getText(patchmap); + expect(updatedBar.props.margin).toEqual({ + top: 10, + right: 0, + bottom: 0, + left: 20, + }); + expect(updatedBar.x).not.toBe(originalPosition.x); + expect(updatedBar.y).not.toBe(originalPosition.y); + + patchmap.undoRedoManager.undo(); + const undoneBar = getText(patchmap); + expect(undoneBar.props.margin).toEqual(originalMargin); + expect(undoneBar.x).toBe(originalPosition.x); + expect(undoneBar.y).toBe(originalPosition.y); + + patchmap.undoRedoManager.redo(); + const redoneBar = getText(patchmap); + expect(redoneBar.props.margin).toEqual({ + top: 10, + right: 0, + bottom: 0, + left: 20, + }); + expect(redoneBar.x).not.toBe(originalPosition.x); + expect(redoneBar.y).not.toBe(originalPosition.y); + }); + + it('should correctly add a new property (style) and undo to its initial state', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemDataWithoutOptionals]); + + const textComponent = getText(patchmap); + const originalFill = textComponent.style.fill; + const originalFontSize = textComponent.style.fontSize; + + const newStyle = { fill: 'green', fontSize: 16 }; + + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes: { style: newStyle }, + history: true, + }); + + const updatedStyle = getText(patchmap).style; + expect(updatedStyle.fill).toBe('green'); + expect(updatedStyle.fontSize).toBe(16); + expect(getText(patchmap).props.style).toBeDefined(); + + // Undo + patchmap.undoRedoManager.undo(); + const undoneStyle = getText(patchmap).style; + expect(undoneStyle.fill).toBe(originalFill); + expect(undoneStyle.fontSize).toBe(originalFontSize); + + // Redo + patchmap.undoRedoManager.redo(); + const redoneStyle = getText(patchmap).style; + expect(redoneStyle.fill).toBe('green'); + expect(redoneStyle.fontSize).toBe(16); + expect(getText(patchmap).props.style).toBeDefined(); + }); + }); + + it('should correctly undo/redo multiple different properties simultaneously', () => { + const patchmap = getPatchmap(); + patchmap.draw([baseItemData]); + + const textComponent = getText(patchmap); + const originalState = { + text: textComponent.text, + tint: textComponent.tint, + fill: textComponent.style.fill, + fontSize: textComponent.style.fontSize, + }; + + const changes = { + text: 'Multi-update', + tint: 0x0000ff, // blue + style: { fill: 'yellow', fontSize: 12 }, + }; + patchmap.update({ + path: '$..[?(@.id=="text-1")]', + changes, + history: true, + }); + + const updatedComponent = getText(patchmap); + const updatedState = { + text: updatedComponent.text, + tint: updatedComponent.tint, + fill: updatedComponent.style.fill, + fontSize: updatedComponent.style.fontSize, + }; + expect(updatedState.text).toBe('Multi-update'); + expect(updatedState.tint).toBe(255); + expect(updatedState.fill).toBe('yellow'); + expect(updatedState.fontSize).toBe(12); + + // Undo + patchmap.undoRedoManager.undo(); + const undoneComponent = getText(patchmap); + const undoneState = { + text: undoneComponent.text, + tint: undoneComponent.tint, + fill: undoneComponent.style.fill, + fontSize: undoneComponent.style.fontSize, + }; + expect(undoneState).toEqual(originalState); + + // Redo + patchmap.undoRedoManager.redo(); + const redoneComponent = getText(patchmap); + const redoneState = { + text: redoneComponent.text, + tint: redoneComponent.tint, + fill: redoneComponent.style.fill, + fontSize: redoneComponent.style.fontSize, + }; + expect(redoneState).toEqual(updatedState); + }); +}); diff --git a/src/utils/deepmerge/deepmerge.js b/src/utils/deepmerge/deepmerge.js index ee3835a4..a250a00d 100644 --- a/src/utils/deepmerge/deepmerge.js +++ b/src/utils/deepmerge/deepmerge.js @@ -56,9 +56,9 @@ const _deepMerge = (target, source, options, visited) => { }; const mergeArray = (target, source, options, visited) => { - const { mergeBy, arrayMerge = null } = options; + const { mergeBy, mergeStrategy = null } = options; - if (arrayMerge === 'replace') { + if (mergeStrategy === 'replace') { return source; } diff --git a/src/utils/deepmerge/deepmerge.test.js b/src/utils/deepmerge/deepmerge.test.js index 88062216..db0f772f 100644 --- a/src/utils/deepmerge/deepmerge.test.js +++ b/src/utils/deepmerge/deepmerge.test.js @@ -80,7 +80,7 @@ describe('deepMerge – edge-case behavior', () => { /* -------------------------------------------------------------------------- */ /* 3. Array/Object merge priority (id → label → type) */ /* -------------------------------------------------------------------------- */ -describe('deepMerge – arrayMerge by id → label → type', () => { +describe('deepMerge – mergeStrategy by id → label → type', () => { test.each([ [ { @@ -366,13 +366,13 @@ describe('deepMerge – additional edge‑case coverage', () => { }); }); -describe('deepMerge – arrayMerge option', () => { +describe('deepMerge – mergeStrategy option', () => { test.each([ { - name: 'should replace array when arrayMerge is "replace"', + name: 'should replace array when mergeStrategy is "replace"', left: { arr: [1, 2, 3] }, right: { arr: [4, 5] }, - options: { arrayMerge: 'replace' }, + options: { mergeStrategy: 'replace' }, expected: { arr: [4, 5] }, }, { @@ -383,12 +383,21 @@ describe('deepMerge – arrayMerge option', () => { expected: { arr: [4, 5, 3] }, }, { - name: 'should merge nested arrays when arrayMerge is "replace" at top level', + name: 'should merge nested arrays when mergeStrategy is "replace" at top level', left: { nested: { arr: ['a', 'b'] } }, right: { nested: { arr: ['c'] } }, - options: { arrayMerge: 'replace' }, + options: { mergeStrategy: 'replace' }, expected: { nested: { arr: ['c'] } }, }, + { + name: 'should merge nested arrays when mergeStrategy is "replace" at second level', + left: [{ source: '1', target: '2' }], + right: [{ source: '2', target: '3' }], + expected: [ + { source: '1', target: '2' }, + { source: '2', target: '3' }, + ], + }, ])('$name', ({ left, right, options, expected }) => { expect(deepMerge(left, right, options)).toEqual(expected); }); diff --git a/src/utils/diff/create-patch.js b/src/utils/diff/create-patch.js new file mode 100644 index 00000000..7d12dae2 --- /dev/null +++ b/src/utils/diff/create-patch.js @@ -0,0 +1,28 @@ +import { isPlainObject } from 'is-plain-object'; +import { isSame } from './is-same'; + +export const createPatch = (obj1, obj2) => { + if (isSame(obj1, obj2)) return {}; + + if ( + obj1 === null || + obj2 === null || + !isPlainObject(obj1) || + !isPlainObject(obj2) + ) { + return obj2; + } + + const result = {}; + for (const key of Object.keys(obj2)) { + if ( + !Object.prototype.hasOwnProperty.call(obj1, key) || + !isSame(obj1[key], obj2[key]) + ) { + const patchValue = createPatch(obj1[key], obj2[key]); + result[key] = patchValue; + } + } + + return result; +}; diff --git a/src/utils/diff/create-patch.test.js b/src/utils/diff/create-patch.test.js new file mode 100644 index 00000000..efc11312 --- /dev/null +++ b/src/utils/diff/create-patch.test.js @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'vitest'; +import { createPatch } from './create-patch'; + +// --- 테스트용 Mock 클래스 --- +class MockClass { + constructor(value) { + this.value = value; + } +} + +describe('createPatch function tests', () => { + test.each([ + { + name: 'should return an empty object for identical objects', + obj1: { a: 1, b: 2 }, + obj2: { a: 1, b: 2 }, + expected: {}, + }, + { + name: 'should return the new value for a changed primitive', + obj1: { a: 1, b: 'hello' }, + obj2: { a: 1, b: 'world' }, + expected: { b: 'world' }, + }, + { + name: 'should return an object with the new key', + obj1: { a: 1 }, + obj2: { a: 1, b: 2 }, + expected: { b: 2 }, + }, + { + name: 'should not return keys that were removed in obj2', + obj1: { a: 1, b: 2 }, + obj2: { a: 1 }, + expected: {}, + }, + ])('Basic Changes: $name', ({ obj1, obj2, expected }) => { + expect(createPatch(obj1, obj2)).toEqual(expected); + }); + + test.each([ + { + name: 'should return a patch for a changed nested property', + obj1: { nested: { one: 1, two: 2 } }, + obj2: { nested: { one: 1, two: 3 } }, + expected: { nested: { two: 3 } }, + }, + { + name: 'should return a patch for a new key in a nested object', + obj1: { nested: { x: 10 } }, + obj2: { nested: { x: 10, y: 20 } }, + expected: { nested: { y: 20 } }, + }, + { + name: 'should return a deeply nested patch', + obj1: { level1: { level2: { value: 10 } } }, + obj2: { level1: { level2: { value: 99 } } }, + expected: { level1: { level2: { value: 99 } } }, + }, + ])('Nested Objects: $name', ({ obj1, obj2, expected }) => { + expect(createPatch(obj1, obj2)).toEqual(expected); + }); + + test.each([ + { + name: 'should return an empty object for identical arrays', + obj1: { data: [1, { a: 2 }] }, + obj2: { data: [1, { a: 2 }] }, + expected: {}, + }, + { + name: 'should return the entire new array if length changes', + obj1: { data: ['Alice', 'Bob'] }, + obj2: { data: ['Alice', 'Bob', 'Charlie'] }, + expected: { data: ['Alice', 'Bob', 'Charlie'] }, + }, + { + name: 'should return the entire new array if an element changes', + obj1: { data: [{ id: 1 }, { id: 2 }] }, + obj2: { data: [{ id: 1 }, { id: 3 }] }, + expected: { data: [{ id: 1 }, { id: 3 }] }, + }, + ])('Arrays: $name', ({ obj1, obj2, expected }) => { + expect(createPatch(obj1, obj2)).toEqual(expected); + }); + + test.each([ + { + name: 'should return obj2 when obj1 is null', + obj1: null, + obj2: { a: 1 }, + expected: { a: 1 }, + }, + { + name: 'should return null when obj2 is null', + obj1: { a: 1 }, + obj2: null, + expected: null, + }, + { + name: 'should return undefined when obj2 is undefined', + obj1: { a: 1 }, + obj2: undefined, + expected: undefined, + }, + { + name: 'should return empty object for two null values', + obj1: null, + obj2: null, + expected: {}, + }, + { + name: 'should return empty object for two undefined values', + obj1: undefined, + obj2: undefined, + expected: {}, + }, + { + name: 'should return the new value when changing from null to undefined', + obj1: { value: null }, + obj2: { value: undefined }, + expected: { value: undefined }, + }, + { + name: 'should return the new value when changing from function to undefined', + obj1: { value: () => 1 }, + obj2: { value: undefined }, + expected: { value: undefined }, + }, + { + name: 'should return the new Date object if dates differ', + obj1: { date: new Date('2024-01-01') }, + obj2: { date: new Date('2025-01-01') }, + expected: { date: new Date('2025-01-01') }, + }, + { + name: 'should return empty object for identical Date objects', + obj1: { date: new Date('2024-01-01') }, + obj2: { date: new Date('2024-01-01') }, + expected: {}, + }, + { + name: 'should return the new class instance if values differ', + obj1: { instance: new MockClass(10) }, + obj2: { instance: new MockClass(20) }, + expected: { instance: new MockClass(20) }, + }, + { + name: 'should return empty object for identical class instances', + obj1: { instance: new MockClass(10) }, + obj2: { instance: new MockClass(10) }, + expected: {}, + }, + ])('Edge Cases: $name', ({ obj1, obj2, expected }) => { + expect(createPatch(obj1, obj2)).toEqual(expected); + }); + + test('[Function] should return the new function if functions differ', () => { + const func1 = () => 1; + const func2 = () => 2; + const obj1 = { action: func1 }; + const obj2 = { action: func2 }; + + const result = createPatch(obj1, obj2); + expect(result.action).toBe(func2); + }); +}); diff --git a/src/utils/diff/diff-json.js b/src/utils/diff/diff-json.js deleted file mode 100644 index ccef6e9a..00000000 --- a/src/utils/diff/diff-json.js +++ /dev/null @@ -1,45 +0,0 @@ -import { isPlainObject } from 'is-plain-object'; - -export const diffJson = (obj1, obj2) => { - if (obj1 === obj2) return {}; - if (obj1 != null && obj2 == null) return obj1; - if (obj1 == null && obj2 != null) return obj2; - - if (Array.isArray(obj1) && Array.isArray(obj2)) { - if (obj1.length !== obj2.length) { - return obj2; - } - - for (let i = 0; i < obj1.length; i++) { - const itemDiff = diffJson(obj1[i], obj2[i]); - if (!isPlainObject(itemDiff) || Object.keys(itemDiff).length > 0) { - return obj2; - } - } - return {}; - } - - if (!isPlainObject(obj1) || !isPlainObject(obj2)) { - return obj1 === obj2 ? {} : obj2; - } - - const result = {}; - for (const key of Object.keys(obj2)) { - if (!(key in obj1)) { - result[key] = obj2[key]; - } else { - const diffValue = diffJson(obj1[key], obj2[key]); - if (isPlainObject(diffValue)) { - if (Object.keys(diffValue).length > 0) { - result[key] = diffValue; - } - } else { - if (diffValue != null) { - result[key] = diffValue; - } - } - } - } - - return result; -}; diff --git a/src/utils/diff/diff-json.test.js b/src/utils/diff/diff-json.test.js deleted file mode 100644 index 57d9d48a..00000000 --- a/src/utils/diff/diff-json.test.js +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { diffJson } from './diff-json'; - -describe('diffJson function tests', () => { - test.each([ - { - name: 'Identical objects', - obj1: { a: 1, b: 2 }, - obj2: { a: 1, b: 2 }, - expected: {}, - }, - { - name: 'obj2 is null', - obj1: { a: 1, b: 2 }, - obj2: null, - expected: { a: 1, b: 2 }, - }, - { - name: 'Key only in obj2', - obj1: { a: 1 }, - obj2: { a: 1, b: 2 }, - expected: { b: 2 }, - }, - { - name: 'Different value for the same key', - obj1: { a: 1, b: 'hello' }, - obj2: { a: 1, b: 'world' }, - expected: { b: 'world' }, - }, - { - name: 'Nested object - some property differs', - obj1: { - nested: { one: 1, two: 2 }, - }, - obj2: { - nested: { one: 1, two: 3 }, - }, - expected: { - nested: { two: 3 }, - }, - }, - { - name: 'Nested object - obj2 has a new key', - obj1: { - nested: { x: 10 }, - }, - obj2: { - nested: { x: 10, y: 20 }, - }, - expected: { - nested: { y: 20 }, - }, - }, - { - name: 'Deeply nested - some property differs', - obj1: { - level1: { - level2: { value: 10, unchanged: 'same' }, - }, - }, - obj2: { - level1: { - level2: { value: 99, unchanged: 'same' }, - }, - }, - expected: { - level1: { - level2: { value: 99 }, - }, - }, - }, - { - name: 'Deeply nested - key only in obj2', - obj1: { - level1: { - level2: { name: 'Alice' }, - }, - }, - obj2: { - level1: { - level2: { name: 'Alice', age: 30 }, - }, - }, - expected: { - level1: { - level2: { age: 30 }, - }, - }, - }, - { - name: 'Array - new item in obj2', - obj1: { - level1: { - level2: { names: ['Alice', 'Bob'] }, - }, - }, - obj2: { - level1: { - level2: { names: ['Alice', 'Bob', 'Charlie'] }, - }, - }, - expected: { - level1: { - level2: { names: ['Alice', 'Bob', 'Charlie'] }, - }, - }, - }, - { - name: 'Array of objects - new item in obj2', - obj1: { - level1: { - level2: { people: [{ name: 'Alice' }, { name: 'Bob' }] }, - }, - }, - obj2: { - level1: { - level2: { - people: [{ name: '1234' }, { name: 'Bob' }, { name: 'Charlie' }], - }, - }, - }, - expected: { - level1: { - level2: { - people: [{ name: '1234' }, { name: 'Bob' }, { name: 'Charlie' }], - }, - }, - }, - }, - { - name: 'Identical arrays of objects should return an empty object', - obj1: { - data: [ - { id: 1, value: 'a' }, - { id: 2, value: 'b' }, - ], - }, - obj2: { - data: [ - { id: 1, value: 'a' }, - { id: 2, value: 'b' }, - ], - }, - expected: {}, - }, - { - name: 'Different arrays of objects should return the new array', - obj1: { - data: [ - { id: 1, value: 'a' }, - { id: 2, value: 'b' }, - ], - }, - obj2: { - data: [ - { id: 1, value: 'a' }, - { id: 2, value: 'c' }, - ], - }, - expected: { - data: [ - { id: 1, value: 'a' }, - { id: 2, value: 'c' }, - ], - }, - }, - ])('$name', ({ obj1, obj2, expected }) => { - const result = diffJson(obj1, obj2); - expect(result).toEqual(expected); - }); -}); diff --git a/src/utils/diff/diff-replace.js b/src/utils/diff/diff-replace.js new file mode 100644 index 00000000..a38b5b1b --- /dev/null +++ b/src/utils/diff/diff-replace.js @@ -0,0 +1,30 @@ +import { isPlainObject } from 'is-plain-object'; +import { isSame } from './is-same'; + +export const diffReplace = (obj1, obj2) => { + if (isSame(obj1, obj2)) { + return {}; + } + + if ( + !isPlainObject(obj1) || + !isPlainObject(obj2) || + Object.getPrototypeOf(obj1) !== Object.getPrototypeOf(obj2) + ) { + return obj2; + } + + const result = {}; + const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); + + for (const key of allKeys) { + const val1 = obj1[key]; + const val2 = obj2[key]; + + if (!isSame(val1, val2)) { + result[key] = val2; + } + } + + return result; +}; diff --git a/src/utils/diff/diff-replace.test.js b/src/utils/diff/diff-replace.test.js new file mode 100644 index 00000000..6c4754a4 --- /dev/null +++ b/src/utils/diff/diff-replace.test.js @@ -0,0 +1,270 @@ +import { describe, expect, test } from 'vitest'; +import { diffReplace } from './diff-replace'; + +class MockClass { + constructor(value) { + this.value = value; + } + getValue() { + return this.value; + } +} + +describe('diffReplace function tests', () => { + test.each([ + { + name: 'should replace the entire object if a property is removed', + obj1: { + style: { width: 2, color: '#0C73BF', cap: 'round' }, + meta: { unchanged: true }, + }, + obj2: { + style: { width: 2, color: '#0C73BF' }, + meta: { unchanged: true }, + }, + expected: { style: { width: 2, color: '#0C73BF' } }, + }, + { + name: 'should replace the entire object if a property is added', + obj1: { style: { width: 2, color: '#0C73BF' } }, + obj2: { style: { width: 2, color: '#0C73BF', cap: 'round' } }, + expected: { style: { width: 2, color: '#0C73BF', cap: 'round' } }, + }, + { + name: 'should replace a deeply nested object when its property changes', + obj1: { + level1: { + level2: { value: 10, config: { enabled: true } }, + static: 'A', + }, + }, + obj2: { + level1: { + level2: { value: 10, config: { enabled: false } }, + static: 'A', + }, + }, + expected: { + level1: { + level2: { value: 10, config: { enabled: false } }, + static: 'A', + }, + }, + }, + ])('$name', ({ obj1, obj2, expected }) => { + expect(diffReplace(obj1, obj2)).toEqual(expected); + }); + + test.each([ + { + name: 'Identical objects and primitives should return an empty object', + obj1: { a: 1, b: { c: 3 } }, + obj2: { a: 1, b: { c: 3 } }, + expected: {}, + }, + { + name: 'A new key at the root level should be added', + obj1: { a: 1 }, + obj2: { a: 1, b: 2 }, + expected: { b: 2 }, + }, + { + name: 'A changed primitive value should be updated', + obj1: { a: 1, b: 'hello' }, + obj2: { a: 1, b: 'world' }, + expected: { b: 'world' }, + }, + ])('$name', ({ obj1, obj2, expected }) => { + expect(diffReplace(obj1, obj2)).toEqual(expected); + }); + + test.each([ + { + name: 'should return an empty object for identical arrays', + obj1: { data: [{ id: 1, value: 'a' }] }, + obj2: { data: [{ id: 1, value: 'a' }] }, + expected: {}, + }, + { + name: 'should replace the entire array if an element inside it changes', + obj1: { + data: [ + { id: 1, value: 'a' }, + { id: 2, value: 'b' }, + ], + }, + obj2: { + data: [ + { id: 1, value: 'a' }, + { id: 2, value: 'c' }, + ], + }, + expected: { + data: [ + { id: 1, value: 'a' }, + { id: 2, value: 'c' }, + ], + }, + }, + { + name: 'should replace the entire array if its length changes', + obj1: { data: [{ id: 1, value: 'a' }] }, + obj2: { + data: [ + { id: 1, value: 'a' }, + { id: 2, value: 'b' }, + ], + }, + expected: { + data: [ + { id: 1, value: 'a' }, + { id: 2, value: 'b' }, + ], + }, + }, + ])('$name', ({ obj1, obj2, expected }) => { + expect(diffReplace(obj1, obj2)).toEqual(expected); + }); + + test.each([ + { + name: 'should return obj2 when obj1 is null', + obj1: null, + obj2: { a: 1 }, + expected: { a: 1 }, + }, + { + name: 'should return obj1 when obj2 is null', + obj1: { a: 1 }, + obj2: null, + expected: null, + }, + { + name: 'should handle replacement of an object with a primitive', + obj1: { data: { isObject: true } }, + obj2: { data: 'isPrimitive' }, + expected: { data: 'isPrimitive' }, + }, + { + name: 'should handle replacement of a primitive with an object', + obj1: { data: 'isPrimitive' }, + obj2: { data: { isObject: true } }, + expected: { data: { isObject: true } }, + }, + { + name: 'should handle null vs undefined in nested objects', + obj1: { config: { setting: 'on', value: null } }, + obj2: { config: { setting: 'on', value: undefined } }, + expected: { config: { setting: 'on', value: undefined } }, + }, + ])('$name', ({ obj1, obj2, expected }) => { + expect(diffReplace(obj1, obj2)).toEqual(expected); + }); + + test.each([ + { + name: '[Class Instance] should return empty object for identical class instances', + obj1: { data: new MockClass(10) }, + obj2: { data: new MockClass(10) }, + expected: {}, + }, + { + name: '[Class Instance] should return the new instance if properties differ', + obj1: { data: new MockClass(10) }, + obj2: { data: new MockClass(20) }, + expected: { data: new MockClass(20) }, + }, + { + name: '[Date Object] should return empty object for identical dates', + obj1: { timestamp: new Date('2024-01-01') }, + obj2: { timestamp: new Date('2024-01-01') }, + expected: {}, + }, + { + name: '[Date Object] should return the new date if dates differ', + obj1: { timestamp: new Date('2024-01-01') }, + obj2: { timestamp: new Date('2025-01-01') }, + expected: { timestamp: new Date('2025-01-01') }, + }, + { + name: '[Mixed Types] should handle mix of plain objects, instances, and primitives', + obj1: { id: 1, config: { a: 1 }, instance: new MockClass(1) }, + obj2: { id: 1, config: { a: 2 }, instance: new MockClass(2) }, + expected: { + config: { a: 2 }, + instance: new MockClass(2), + }, + }, + ])('$name', ({ obj1, obj2, expected }) => { + const result = diffReplace(obj1, obj2); + if (typeof expected.action === 'function') { + expect(result.action).toBe(expected.action); + } else { + expect(result).toEqual(expected); + } + }); + + test('[Function] should return the new function if functions differ', () => { + const func1 = () => 1; + const func2 = () => 2; + const obj1 = { action: func1 }; + const obj2 = { action: func2 }; + + const result = diffReplace(obj1, obj2); + expect(result.action).toBe(func2); + }); + + test('[Function] should return an empty object for identical function references', () => { + const func1 = () => 1; + const obj1 = { action: func1 }; + const obj2 = { action: func1 }; + expect(diffReplace(obj1, obj2)).toEqual({}); + }); + + describe('Critical Edge Cases for diffReplace', () => { + test('should NOT throw error on circular references and return correct diff', () => { + const obj1 = { name: 'obj1' }; + obj1.self = obj1; + + const obj2 = { name: 'obj2' }; + obj2.self = obj2; + + const obj3 = { name: 'obj1' }; + obj3.self = obj3; + + expect(() => diffReplace(obj1, obj2)).not.toThrow(); + expect(diffReplace(obj1, obj2)).toEqual({ name: 'obj2', self: obj2 }); + expect(diffReplace(obj1, obj3)).toEqual({}); + }); + + test('should replace object when a property is changed from a value to undefined', () => { + const obj1 = { a: 1, b: 2, c: 3 }; + const obj2 = { a: 1, b: undefined, c: 3 }; + + expect(diffReplace(obj1, obj2)).toEqual({ b: undefined }); + }); + + test('should replace object when a property is removed', () => { + const obj1 = { a: 1, b: 2 }; + const obj2 = { a: 1 }; + + expect(diffReplace(obj1, obj2)).toEqual({}); + expect(diffReplace(obj2, obj1)).toEqual({ b: 2 }); + }); + + test('should replace array when sparse vs undefined elements are present', () => { + const obj1 = { data: [1, null, 3] }; + const obj2 = { data: [1, undefined, 3] }; + + expect(diffReplace(obj1, obj2)).toEqual({ data: [1, undefined, 3] }); + }); + + test('should correctly diff objects with different prototypes', () => { + const obj1 = { a: 1 }; + const obj2 = Object.create(null); + obj2.a = 1; + + expect(diffReplace(obj1, obj2)).toEqual(obj2); + }); + }); +}); diff --git a/src/utils/diff/is-same.js b/src/utils/diff/is-same.js new file mode 100644 index 00000000..d324a312 --- /dev/null +++ b/src/utils/diff/is-same.js @@ -0,0 +1,74 @@ +function _isSame(a, b, visited) { + if (a === b) return true; + if (Number.isNaN(a) && Number.isNaN(b)) return true; + if ( + typeof a !== 'object' || + typeof b !== 'object' || + a === null || + b === null + ) { + return false; + } + + if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; + + if (visited.has(a)) return visited.get(a) === b; + visited.set(a, b); + + if (a instanceof Date) { + return a.getTime() === b.getTime(); + } + + if (a instanceof RegExp) { + return a.toString() === b.toString(); + } + + if (a instanceof Map) { + if (a.size !== b.size) return false; + const entriesA = Array.from(a.entries()); + const entriesB = Array.from(b.entries()); + return _isSame(entriesA, entriesB, visited); + } + + if (a instanceof Set) { + if (a.size !== b.size) return false; + const valuesA = Array.from(a.values()); + const valuesB = Array.from(b.values()); + return _isSame(valuesA, valuesB, visited); + } + + if (ArrayBuffer.isView(a) && !(a instanceof DataView)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + if (Array.isArray(a)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!_isSame(a[i], b[i], visited)) return false; + } + return true; + } + + const keysA = Reflect.ownKeys(a); + const keysB = Reflect.ownKeys(b); + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if ( + !Object.prototype.hasOwnProperty.call(b, key) || + !_isSame(a[key], b[key], visited) + ) { + return false; + } + } + + return true; +} + +export const isSame = (value1, value2) => { + return _isSame(value1, value2, new WeakMap()); +}; diff --git a/src/utils/diff/is-same.test.js b/src/utils/diff/is-same.test.js new file mode 100644 index 00000000..0a3d844d --- /dev/null +++ b/src/utils/diff/is-same.test.js @@ -0,0 +1,250 @@ +import { describe, expect, test } from 'vitest'; +import { isSame } from './is-same'; + +class MockClass { + constructor(value) { + this.value = value; + } +} + +describe('isSame function', () => { + // ... 기존 테스트 스위트는 그대로 유지 ... + describe('Plain Objects and Primitives', () => { + test.each([ + { name: 'identical numbers', v1: 1, v2: 1, expected: true }, + { name: 'different numbers', v1: 1, v2: 2, expected: false }, + { name: 'identical strings', v1: 'hello', v2: 'hello', expected: true }, + { name: 'different strings', v1: 'hello', v2: 'world', expected: false }, + { name: 'identical booleans', v1: true, v2: true, expected: true }, + { + name: 'deeply identical plain objects', + v1: { a: 1, b: { c: 3, d: [4, 5] } }, + v2: { a: 1, b: { c: 3, d: [4, 5] } }, + expected: true, + }, + { + name: 'deeply different plain objects', + v1: { a: 1, b: { c: 99 } }, + v2: { a: 1, b: { c: 3 } }, + expected: false, + }, + { + name: 'objects where one has extra properties (forward)', + v1: { a: 1, b: 2 }, + v2: { a: 1 }, + expected: false, + }, + { + name: 'objects where one has extra properties (backward)', + v1: { a: 1 }, + v2: { a: 1, b: 2 }, + expected: false, + }, + ])('should return $expected for $name', ({ v1, v2, expected }) => { + expect(isSame(v1, v2)).toBe(expected); + }); + }); + + describe('Null and Undefined Handling', () => { + test.each([ + { name: 'two nulls', v1: null, v2: null, expected: true }, + { name: 'two undefineds', v1: undefined, v2: undefined, expected: true }, + { name: 'null and undefined', v1: null, v2: undefined, expected: false }, + { name: 'object and null', v1: { a: 1 }, v2: null, expected: false }, + { + name: 'object and undefined', + v1: { a: 1 }, + v2: undefined, + expected: false, + }, + { + name: 'object properties with identical nulls', + v1: { a: null }, + v2: { a: null }, + expected: true, + }, + { + name: 'object properties with identical undefineds', + v1: { a: undefined }, + v2: { a: undefined }, + expected: true, + }, + { + name: 'object properties with null vs undefined', + v1: { a: null }, + v2: { a: undefined }, + expected: false, + }, + ])( + 'should return $expected when comparing $name', + ({ v1, v2, expected }) => { + expect(isSame(v1, v2)).toBe(expected); + }, + ); + }); + + describe('Non-Plain Object Handling (Arrays, Dates, Instances, Functions)', () => { + test.each([ + { + name: 'identical arrays', + v1: [1, 2, { a: 3 }], + v2: [1, 2, { a: 3 }], + expected: true, + }, + { + name: 'different arrays (length)', + v1: [1, 2], + v2: [1, 2, 3], + expected: false, + }, + { + name: 'different arrays (value)', + v1: [{ a: 1 }], + v2: [{ a: 2 }], + expected: false, + }, + { + name: 'identical Date objects', + v1: new Date('2024-01-01'), + v2: new Date('2024-01-01'), + expected: true, + }, + { + name: 'different Date objects', + v1: new Date('2024-01-01'), + v2: new Date('2024-01-02'), + expected: false, + }, + { + name: 'identical class instances (by value)', + v1: new MockClass(10), + v2: new MockClass(10), + expected: true, + }, + { + name: 'different class instances (by value)', + v1: new MockClass(10), + v2: new MockClass(20), + expected: false, + }, + ])('should return $expected for $name', ({ v1, v2, expected }) => { + expect(isSame(v1, v2)).toBe(expected); + }); + + test('should return true for identical function references', () => { + const func = () => {}; + expect(isSame(func, func)).toBe(true); + }); + + test('should return false for different function references', () => { + const func1 = () => {}; + const func2 = () => {}; + expect(isSame(func1, func2)).toBe(false); + }); + }); + + describe('Advanced Edge Cases', () => { + test('should handle NaN correctly, returning true', () => { + expect(isSame(Number.NaN, Number.NaN)).toBe(true); + expect(isSame({ a: Number.NaN }, { a: Number.NaN })).toBe(true); + expect(isSame({ a: Number.NaN }, { a: 1 })).toBe(false); + }); + + test('should differentiate between objects with explicit undefined and missing properties', () => { + expect(isSame({ a: 1, b: undefined }, { a: 1 })).toBe(false); + }); + + test('should differentiate between arrays and array-like objects', () => { + expect(isSame([1, 2, 3], { 0: 1, 1: 2, 2: 3, length: 3 })).toBe(false); + }); + + test('should handle objects created with Object.create(null)', () => { + const obj1 = Object.create(null); + obj1.a = 1; + const obj2 = Object.create(null); + obj2.a = 1; + const obj3 = { a: 1 }; + + expect(isSame(obj1, obj2)).toBe(true); + expect(isSame(obj1, obj3)).toBe(false); + }); + + test('should compare RegExp objects', () => { + expect(isSame(/abc/g, /abc/g)).toBe(true); + expect(isSame(/abc/g, /abc/i)).toBe(false); + expect(isSame(/abc/g, /abc/g)).toBe(true); + }); + + test('should handle circular references without crashing', () => { + const obj1 = {}; + const obj2 = {}; + obj1.a = obj2; + obj2.a = obj1; + + const obj3 = {}; + const obj4 = {}; + obj3.a = obj4; + obj4.a = obj3; + + expect(isSame(obj1, obj3)).toBe(true); + }); + }); + + describe('ES6+ Data Structures and Features', () => { + test('should handle Symbol properties correctly', () => { + const sym = Symbol('id'); + const obj1 = { [sym]: 1 }; + const obj2 = { [sym]: 1 }; + const obj3 = { [sym]: 2 }; + + expect(isSame(obj1, obj2)).toBe(true); + expect(isSame(obj1, obj3)).toBe(false); + expect(isSame({ a: 1, [sym]: 1 }, { a: 1 })).toBe(false); + }); + + test('should compare Map objects correctly', () => { + const map1 = new Map([ + ['a', 1], + ['b', { x: 2 }], + ]); + const map2 = new Map([ + ['a', 1], + ['b', { x: 2 }], + ]); + const map3 = new Map([ + ['b', { x: 2 }], + ['a', 1], + ]); + const map4 = new Map([ + ['a', 1], + ['b', { x: 99 }], + ]); + + expect(isSame(map1, map2)).toBe(true); + expect(isSame(map1, map3)).toBe(false); + expect(isSame(map1, map4)).toBe(false); + }); + + test('should compare Set objects correctly', () => { + const set1 = new Set([1, { a: 2 }]); + const set2 = new Set([1, { a: 2 }]); + const set3 = new Set([{ a: 2 }, 1]); + const set4 = new Set([1, { a: 99 }]); + + expect(isSame(set1, set2)).toBe(true); + expect(isSame(set1, set3)).toBe(false); + expect(isSame(set1, set4)).toBe(false); + }); + + test('should compare TypedArrays by value', () => { + const arr1 = new Uint8Array([1, 2, 3]); + const arr2 = new Uint8Array([1, 2, 3]); + const arr3 = new Uint8Array([1, 2, 4]); + const arr4 = new Float32Array([1, 2, 3]); + + expect(isSame(arr1, arr2)).toBe(true); + expect(isSame(arr1, arr3)).toBe(false); + expect(isSame(arr1, arr4)).toBe(false); + }); + }); +}); diff --git a/src/utils/diff/isSame.js b/src/utils/diff/isSame.js deleted file mode 100644 index fa9deae1..00000000 --- a/src/utils/diff/isSame.js +++ /dev/null @@ -1,11 +0,0 @@ -import { isPlainObject } from 'is-plain-object'; -import { diffJson } from './diff-json'; - -export const isSame = (value1, value2) => { - if (!isPlainObject(value1) || !isPlainObject(value2)) { - return value1 === value2; - } - - const json = diffJson(value1, value2); - return Object.keys(json).length === 0; -}; diff --git a/src/utils/diff/isSame.test.js b/src/utils/diff/isSame.test.js deleted file mode 100644 index 652689df..00000000 --- a/src/utils/diff/isSame.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isSame } from './isSame'; - -describe('isSame', () => { - it('should return true for identical plain objects', () => { - const obj1 = { a: 1, b: 2 }; - const obj2 = { a: 1, b: 2 }; - expect(isSame(obj1, obj2)).toBe(true); - }); - - it('should return false for different plain objects', () => { - const obj1 = { a: 1, b: 2 }; - const obj2 = { a: 1, b: 3 }; - expect(isSame(obj1, obj2)).toBe(false); - }); - - it('should return true for identical non-object values', () => { - expect(isSame(1, 1)).toBe(true); - expect(isSame('test', 'test')).toBe(true); - }); - - it('should return false for different non-object values', () => { - expect(isSame(1, 2)).toBe(false); - expect(isSame('test', 'diff')).toBe(false); - }); - - it('should return true for deeply identical objects', () => { - const obj1 = { a: { b: 2, c: 3 }, d: 4 }; - const obj2 = { a: { b: 2, c: 3 }, d: 4 }; - expect(isSame(obj1, obj2)).toBe(true); - }); - - it('should return false for deeply different objects', () => { - const obj1 = { a: { b: 2, c: 3 }, d: 4 }; - const obj2 = { a: { b: 2, c: 4 }, d: 4 }; - expect(isSame(obj1, obj2)).toBe(false); - }); - - it('should handle undefined and null values correctly', () => { - expect(isSame(undefined, undefined)).toBe(true); - expect(isSame(null, null)).toBe(true); - expect(isSame(undefined, null)).toBe(false); - expect(isSame(null, undefined)).toBe(false); - expect(isSame({ a: undefined }, { a: undefined })).toBe(true); - expect(isSame({ a: null }, { a: null })).toBe(true); - expect(isSame({ a: undefined }, { a: null })).toBe(true); - }); - - it('should handle objects with null and undefined properties correctly', () => { - const obj1 = { a: null, b: undefined, c: { d: null } }; - const obj2 = { a: null, b: undefined, c: { d: null } }; - const obj3 = { a: null, b: undefined, c: { d: undefined } }; - - expect(isSame(obj1, obj2)).toBe(true); - expect(isSame(obj1, obj3)).toBe(true); - }); - - it('should handle mixed object and primitive comparisons', () => { - expect(isSame({ a: 1 }, 1)).toBe(false); - expect(isSame({ a: 1 }, null)).toBe(false); - expect(isSame({ a: 1 }, undefined)).toBe(false); - expect(isSame(null, { a: 1 })).toBe(false); - expect(isSame(undefined, { a: 1 })).toBe(false); - }); -});