From c327d31c2a7cf45e58955c544d60661bda1299eb Mon Sep 17 00:00:00 2001 From: CheerC Date: Tue, 23 Jun 2026 22:01:27 +0800 Subject: [PATCH] test: add undo/redo state integrity + ensureDataIds idempotency tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #135, Closes #137 - Extract undo/redo stack logic from History.js.html as DI pure module in tests/lib/undoRedoHelpers.js (saveState/undo/redo/resetHistory) - Extract ensureDataIds + generateUniqueId from JavaScript.html L881-900 as DI pure functions in tests/lib/dataIdHelpers.js - Add 27 tests in tests/unit/undoRedoState.test.js covering: - saveState dedup, redo truncation, 50-entry cap - undo/redo boundary guards, interleaved cycles - resetHistory, canUndo/canRedo, state isolation (structuredClone) - Complex nested scheduleData through undo/redo - Add 23 tests in tests/unit/ensureDataIds.test.js covering: - Adding IDs to items without them, idempotency across multiple calls - Mixed partial IDs, edge cases (null/undefined/empty/non-array) - ID format validation, mutation behavior - Update wiringContracts module count 13 → 15 Closes t-20260623135418587642-97909-0 Co-authored-by: Claude Opus 4.6 Agend-Agent: cb-team-impl Agend-Task: t-20260623135418587642-97909-0 Agend-Branch: test/wave3a-undo-redo-ensure-ids --- tests/lib/dataIdHelpers.js | 53 ++++ tests/lib/undoRedoHelpers.js | 107 ++++++++ tests/unit/ensureDataIds.test.js | 255 ++++++++++++++++++ tests/unit/undoRedoState.test.js | 413 +++++++++++++++++++++++++++++ tests/unit/wiringContracts.test.js | 6 +- 5 files changed, 832 insertions(+), 2 deletions(-) create mode 100644 tests/lib/dataIdHelpers.js create mode 100644 tests/lib/undoRedoHelpers.js create mode 100644 tests/unit/ensureDataIds.test.js create mode 100644 tests/unit/undoRedoState.test.js diff --git a/tests/lib/dataIdHelpers.js b/tests/lib/dataIdHelpers.js new file mode 100644 index 0000000..10d35e6 --- /dev/null +++ b/tests/lib/dataIdHelpers.js @@ -0,0 +1,53 @@ +// Extracted ensureDataIds + generateUniqueId for testing. +// Ref: #137 — ensureDataIds idempotency tests. +// Source: JavaScript.html L881-900 (App.ensureDataIds, App.generateUniqueId). +// +// The original `ensureDataIds` uses `this.generateUniqueId()` for ID generation. +// This extraction accepts an optional `idGenerator` parameter (DI) so tests +// can inject a deterministic generator for reproducible assertions. +// +// Key behavioral contracts preserved: +// - Traverses scheduleData → classroom → day → classItem +// - Only adds ID when classItem exists AND has no `.id` +// - Returns scheduleData (mutates in place) +// - Null/falsy scheduleData → returns {} +// - Null/falsy classroom/daySchedule → skipped gracefully + +/** + * Generate a unique ID (mirrors App.generateUniqueId). + * Original: JavaScript.html L898-900. + * + * @returns {string} A time-based + random ID string. + */ +export function generateUniqueId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2, 9); +} + +/** + * Ensure every class item in scheduleData has an `id` property. + * + * Original: App.ensureDataIds (JavaScript.html L881-896). + * Mutates in place — items without `.id` get one assigned. + * Items already having `.id` are left unchanged (idempotent). + * + * @param {object|null|undefined} scheduleData - The schedule data object. + * Shape: { [classroom: string]: { [day: string]: Array<{ id?: string, ... }> } } + * @param {function} [idGenerator=generateUniqueId] - DI for ID generation. + * @returns {object} The (possibly mutated) scheduleData, or {} if falsy input. + */ +export function ensureDataIds(scheduleData, idGenerator = generateUniqueId) { + if (!scheduleData) return {}; + Object.values(scheduleData).forEach(classroom => { + if (!classroom) return; + Object.values(classroom).forEach(daySchedule => { + if (Array.isArray(daySchedule)) { + daySchedule.forEach(classItem => { + if (classItem && !classItem.id) { + classItem.id = idGenerator(); + } + }); + } + }); + }); + return scheduleData; +} diff --git a/tests/lib/undoRedoHelpers.js b/tests/lib/undoRedoHelpers.js new file mode 100644 index 0000000..bfa49e7 --- /dev/null +++ b/tests/lib/undoRedoHelpers.js @@ -0,0 +1,107 @@ +// Extracted Undo/Redo stack logic for testing. +// Ref: #135 — Undo/Redo state integrity tests. +// Source: History.js.html L2-62 (createHistoryModule: saveState, undo, redo, resetHistory). +// +// The original is a closure-based module factory (createHistoryModule) with +// private `history` array and `historyIndex`. This extraction exposes the +// stack management as a pure-ish class with explicit state, making it +// testable without DOM or app object dependencies. +// +// Key behavioral contracts preserved: +// - saveState: dedup identical consecutive states, truncate redo branch, +// cap at 50 entries (shift oldest) +// - undo: decrement index if > 0, load state via structuredClone +// - redo: increment index if < length-1, load state via structuredClone +// - resetHistory: replace stack with single current state, index = 0 + +/** + * Create a testable history module with injected dependencies. + * + * Original: createHistoryModule(app) in History.js.html L2-120. + * + * @param {object} opts + * @param {function} opts.getCurrentState - Returns { classrooms, scheduleData, tags } snapshot. + * @param {function} [opts.onLoadState] - Called with (state) when undo/redo loads a state. + * @param {function} [opts.onUpdateButtons] - Called after stack changes. + * @param {function} [opts.onCheckDirty] - Called after stack changes. + * @param {function} [opts.onUpdateCleanSnapshot] - Called on resetHistory. + * @returns {object} History module with saveState, undo, redo, resetHistory, getStack, getIndex. + */ +export function createTestableHistoryModule(opts) { + const { + getCurrentState, + onLoadState = () => {}, + onUpdateButtons = () => {}, + onCheckDirty = () => {}, + onUpdateCleanSnapshot = () => {}, + } = opts; + + let history = []; + let historyIndex = -1; + + const module = { + saveState() { + const currentState = getCurrentState(); + // Dedup: skip if identical to last entry + if (history.length > 0 && JSON.stringify(currentState) === JSON.stringify(history[historyIndex])) { + return; + } + + // Truncate redo branch + history = history.slice(0, historyIndex + 1); + history.push(currentState); + historyIndex = history.length - 1; + + // Cap at 50 entries + if (history.length > 50) { + history.shift(); + historyIndex--; + } + + onUpdateButtons(); + onCheckDirty(); + }, + + resetHistory() { + const initialState = getCurrentState(); + history = [initialState]; + historyIndex = 0; + onUpdateButtons(); + onUpdateCleanSnapshot(); + onCheckDirty(); + }, + + undo() { + if (historyIndex > 0) { + historyIndex--; + onLoadState(history[historyIndex]); + } + }, + + redo() { + if (historyIndex < history.length - 1) { + historyIndex++; + onLoadState(history[historyIndex]); + } + }, + + // Test-only accessors + getStack() { + return history; + }, + + getIndex() { + return historyIndex; + }, + + canUndo() { + return historyIndex > 0; + }, + + canRedo() { + return historyIndex < history.length - 1; + }, + }; + + return module; +} diff --git a/tests/unit/ensureDataIds.test.js b/tests/unit/ensureDataIds.test.js new file mode 100644 index 0000000..235f054 --- /dev/null +++ b/tests/unit/ensureDataIds.test.js @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ensureDataIds, generateUniqueId } from '../lib/dataIdHelpers.js'; + +describe('ensureDataIds Idempotency (#137)', () => { + // Deterministic ID generator for testing + let idCounter; + function deterministicId() { + return `test-id-${++idCounter}`; + } + + beforeEach(() => { + idCounter = 0; + }); + + describe('adding IDs to items without them', () => { + it('should add ID to a single item without id', () => { + const data = { + Room1: { + 0: [{ name: 'Math', timeStart: '08:00', timeEnd: '09:00' }], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0][0].id).toBe('test-id-1'); + }); + + it('should add IDs to multiple items without ids', () => { + const data = { + Room1: { + 0: [ + { name: 'Math', timeStart: '08:00', timeEnd: '09:00' }, + { name: 'Science', timeStart: '09:00', timeEnd: '10:00' }, + ], + 1: [ + { name: 'English', timeStart: '10:00', timeEnd: '11:00' }, + ], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0][0].id).toBe('test-id-1'); + expect(result.Room1[0][1].id).toBe('test-id-2'); + expect(result.Room1[1][0].id).toBe('test-id-3'); + }); + + it('should add IDs across multiple classrooms', () => { + const data = { + Room1: { + 0: [{ name: 'Math' }], + }, + Room2: { + 0: [{ name: 'Art' }], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0][0].id).toBe('test-id-1'); + expect(result.Room2[0][0].id).toBe('test-id-2'); + }); + }); + + describe('preserving existing IDs (idempotency)', () => { + it('should not change items that already have IDs', () => { + const data = { + Room1: { + 0: [{ id: 'existing-1', name: 'Math' }], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0][0].id).toBe('existing-1'); + // deterministicId should NOT have been called + expect(idCounter).toBe(0); + }); + + it('should be idempotent — multiple calls yield same result', () => { + const data = { + Room1: { + 0: [{ name: 'Math' }], + }, + }; + // First call adds IDs + ensureDataIds(data, deterministicId); + const idAfterFirst = data.Room1[0][0].id; + + // Reset counter + idCounter = 100; + + // Second call should not change existing IDs + ensureDataIds(data, deterministicId); + expect(data.Room1[0][0].id).toBe(idAfterFirst); + // Counter should not have advanced (no new IDs generated) + expect(idCounter).toBe(100); + }); + + it('should be idempotent after three consecutive calls', () => { + const data = { + Room1: { + 0: [{ name: 'A' }, { name: 'B' }], + 1: [{ name: 'C' }], + }, + }; + ensureDataIds(data, deterministicId); + const snapshot1 = JSON.stringify(data); + + ensureDataIds(data, deterministicId); + const snapshot2 = JSON.stringify(data); + + ensureDataIds(data, deterministicId); + const snapshot3 = JSON.stringify(data); + + expect(snapshot1).toBe(snapshot2); + expect(snapshot2).toBe(snapshot3); + }); + }); + + describe('mixed — partial IDs', () => { + it('should only add IDs to items missing them', () => { + const data = { + Room1: { + 0: [ + { id: 'keep-me', name: 'Math' }, + { name: 'Science' }, // No id + ], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0][0].id).toBe('keep-me'); + expect(result.Room1[0][1].id).toBe('test-id-1'); + }); + + it('should handle mix across classrooms and days', () => { + const data = { + Room1: { + 0: [{ id: 'r1d0', name: 'A' }], + 1: [{ name: 'B' }], // No id + }, + Room2: { + 0: [{ name: 'C' }], // No id + 2: [{ id: 'r2d2', name: 'D' }], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0][0].id).toBe('r1d0'); + expect(result.Room1[1][0].id).toBe('test-id-1'); + expect(result.Room2[0][0].id).toBe('test-id-2'); + expect(result.Room2[2][0].id).toBe('r2d2'); + }); + }); + + describe('edge cases', () => { + it('should return {} for null input', () => { + expect(ensureDataIds(null, deterministicId)).toEqual({}); + }); + + it('should return {} for undefined input', () => { + expect(ensureDataIds(undefined, deterministicId)).toEqual({}); + }); + + it('should return {} for falsy input (empty string)', () => { + expect(ensureDataIds('', deterministicId)).toEqual({}); + }); + + it('should return {} for falsy input (0)', () => { + expect(ensureDataIds(0, deterministicId)).toEqual({}); + }); + + it('should handle empty scheduleData object', () => { + const data = {}; + const result = ensureDataIds(data, deterministicId); + expect(result).toEqual({}); + expect(idCounter).toBe(0); + }); + + it('should handle classroom with no days', () => { + const data = { Room1: {} }; + const result = ensureDataIds(data, deterministicId); + expect(result).toEqual({ Room1: {} }); + expect(idCounter).toBe(0); + }); + + it('should handle empty day array', () => { + const data = { Room1: { 0: [] } }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0]).toEqual([]); + expect(idCounter).toBe(0); + }); + + it('should skip null classroom values gracefully', () => { + const data = { Room1: null, Room2: { 0: [{ name: 'A' }] } }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1).toBeNull(); + expect(result.Room2[0][0].id).toBe('test-id-1'); + }); + + it('should skip non-array day values gracefully', () => { + const data = { + Room1: { + 0: 'not-an-array', + 1: [{ name: 'A' }], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0]).toBe('not-an-array'); + expect(result.Room1[1][0].id).toBe('test-id-1'); + }); + + it('should skip null items in day array', () => { + const data = { + Room1: { + 0: [null, { name: 'A' }, null], + }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result.Room1[0][0]).toBeNull(); + expect(result.Room1[0][1].id).toBe('test-id-1'); + expect(result.Room1[0][2]).toBeNull(); + }); + }); + + describe('ID format (generateUniqueId)', () => { + it('should generate non-empty string IDs', () => { + const id = generateUniqueId(); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); + + it('should generate unique IDs on consecutive calls', () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateUniqueId()); + } + expect(ids.size).toBe(100); + }); + + it('should generate IDs with base-36 characters', () => { + const id = generateUniqueId(); + // base-36 = [0-9a-z] + expect(id).toMatch(/^[0-9a-z]+$/); + }); + }); + + describe('mutation behavior', () => { + it('should mutate the input object in place', () => { + const data = { + Room1: { 0: [{ name: 'A' }] }, + }; + const result = ensureDataIds(data, deterministicId); + expect(result).toBe(data); // Same reference + expect(data.Room1[0][0].id).toBe('test-id-1'); + }); + + it('should return the same reference as input', () => { + const data = { Room1: { 0: [{ id: 'x', name: 'A' }] } }; + const result = ensureDataIds(data, deterministicId); + expect(result).toBe(data); + }); + }); +}); diff --git a/tests/unit/undoRedoState.test.js b/tests/unit/undoRedoState.test.js new file mode 100644 index 0000000..a96f2dc --- /dev/null +++ b/tests/unit/undoRedoState.test.js @@ -0,0 +1,413 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createTestableHistoryModule } from '../lib/undoRedoHelpers.js'; + +describe('Undo/Redo State Integrity (#135)', () => { + // Helper: create a simple state factory with mutable app state + function createTestContext(initialState = { classrooms: ['A'], scheduleData: {}, tags: [] }) { + let appState = structuredClone(initialState); + const callbacks = { + onLoadState: vi.fn((state) => { + appState = structuredClone(state); + }), + onUpdateButtons: vi.fn(), + onCheckDirty: vi.fn(), + onUpdateCleanSnapshot: vi.fn(), + }; + const module = createTestableHistoryModule({ + getCurrentState: () => structuredClone(appState), + ...callbacks, + }); + return { module, getAppState: () => appState, setAppState: (s) => { appState = s; }, callbacks }; + } + + describe('saveState', () => { + it('should save initial state to stack', () => { + const { module } = createTestContext(); + module.saveState(); + expect(module.getStack()).toHaveLength(1); + expect(module.getIndex()).toBe(0); + }); + + it('should save multiple distinct states', () => { + const { module, setAppState } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['A', 'B'], scheduleData: {}, tags: [] }); + module.saveState(); + expect(module.getStack()).toHaveLength(2); + expect(module.getIndex()).toBe(1); + }); + + it('should dedup identical consecutive states', () => { + const { module } = createTestContext(); + module.saveState(); + module.saveState(); // Same state + module.saveState(); // Same state again + expect(module.getStack()).toHaveLength(1); + expect(module.getIndex()).toBe(0); + }); + + it('should truncate redo branch on new action', () => { + const { module, setAppState } = createTestContext(); + // Save 3 states + module.saveState(); // state 0 + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); // state 1 + setAppState({ classrooms: ['C'], scheduleData: {}, tags: [] }); + module.saveState(); // state 2 + + // Undo twice (back to state 0) + module.undo(); + module.undo(); + expect(module.getIndex()).toBe(0); + + // New action should truncate the redo branch + setAppState({ classrooms: ['D'], scheduleData: {}, tags: [] }); + module.saveState(); + + expect(module.getStack()).toHaveLength(2); // state 0 + new state D + expect(module.getIndex()).toBe(1); + expect(module.canRedo()).toBe(false); // Redo branch truncated + }); + + it('should cap history at 50 entries', () => { + const { module, setAppState } = createTestContext(); + for (let i = 0; i < 55; i++) { + setAppState({ classrooms: [`Room-${i}`], scheduleData: {}, tags: [] }); + module.saveState(); + } + expect(module.getStack()).toHaveLength(50); + // Index should be at the end + expect(module.getIndex()).toBe(49); + }); + + it('should shift oldest when exceeding cap', () => { + const { module, setAppState } = createTestContext(); + for (let i = 0; i < 52; i++) { + setAppState({ classrooms: [`Room-${i}`], scheduleData: {}, tags: [] }); + module.saveState(); + } + // The first 2 entries should have been shifted out + const stack = module.getStack(); + expect(stack[0].classrooms[0]).toBe('Room-2'); + expect(stack[49].classrooms[0]).toBe('Room-51'); + }); + + it('should call onUpdateButtons and onCheckDirty', () => { + const { module, callbacks } = createTestContext(); + module.saveState(); + expect(callbacks.onUpdateButtons).toHaveBeenCalledTimes(1); + expect(callbacks.onCheckDirty).toHaveBeenCalledTimes(1); + }); + }); + + describe('undo', () => { + it('should restore previous state', () => { + const { module, setAppState, getAppState, callbacks } = createTestContext(); + const originalState = { classrooms: ['A'], scheduleData: {}, tags: [] }; + module.saveState(); // state 0 = A + setAppState({ classrooms: ['A', 'B'], scheduleData: {}, tags: [] }); + module.saveState(); // state 1 = A,B + + module.undo(); + expect(callbacks.onLoadState).toHaveBeenCalledTimes(1); + expect(getAppState().classrooms).toEqual(['A']); + expect(module.getIndex()).toBe(0); + }); + + it('should handle consecutive undos', () => { + const { module, setAppState, getAppState } = createTestContext(); + module.saveState(); // 0: A + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); // 1: B + setAppState({ classrooms: ['C'], scheduleData: {}, tags: [] }); + module.saveState(); // 2: C + + module.undo(); // → 1: B + expect(getAppState().classrooms).toEqual(['B']); + module.undo(); // → 0: A + expect(getAppState().classrooms).toEqual(['A']); + expect(module.getIndex()).toBe(0); + }); + + it('should not go below index 0 (empty stack boundary)', () => { + const { module, callbacks } = createTestContext(); + module.saveState(); // Only one state + module.undo(); // Should be no-op (already at 0) + expect(callbacks.onLoadState).not.toHaveBeenCalled(); + expect(module.getIndex()).toBe(0); + }); + + it('should not call onLoadState when at boundary', () => { + const { module, callbacks } = createTestContext(); + module.saveState(); + module.undo(); + module.undo(); // Extra undo at boundary + module.undo(); // Another extra + expect(callbacks.onLoadState).not.toHaveBeenCalled(); + }); + }); + + describe('redo', () => { + it('should restore next state after undo', () => { + const { module, setAppState, getAppState, callbacks } = createTestContext(); + module.saveState(); // 0: A + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); // 1: B + + module.undo(); // → 0: A + expect(getAppState().classrooms).toEqual(['A']); + + module.redo(); // → 1: B + expect(getAppState().classrooms).toEqual(['B']); + expect(module.getIndex()).toBe(1); + }); + + it('should handle consecutive redos', () => { + const { module, setAppState, getAppState } = createTestContext(); + module.saveState(); // 0: A + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); // 1: B + setAppState({ classrooms: ['C'], scheduleData: {}, tags: [] }); + module.saveState(); // 2: C + + module.undo(); // → 1 + module.undo(); // → 0 + + module.redo(); // → 1: B + expect(getAppState().classrooms).toEqual(['B']); + module.redo(); // → 2: C + expect(getAppState().classrooms).toEqual(['C']); + }); + + it('should not go beyond stack length (boundary)', () => { + const { module, setAppState, callbacks } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); + + // Already at end — redo should be no-op + module.redo(); + expect(callbacks.onLoadState).not.toHaveBeenCalled(); + expect(module.getIndex()).toBe(1); + }); + + it('should not call onLoadState when at end boundary', () => { + const { module, callbacks } = createTestContext(); + module.saveState(); + module.redo(); // No redo available + module.redo(); // Extra + expect(callbacks.onLoadState).not.toHaveBeenCalled(); + }); + }); + + describe('undo + redo interleaved', () => { + it('should maintain state integrity through undo/redo cycles', () => { + const { module, setAppState, getAppState } = createTestContext(); + const states = [ + { classrooms: ['A'], scheduleData: {}, tags: [] }, + { classrooms: ['B'], scheduleData: {}, tags: ['x'] }, + { classrooms: ['C'], scheduleData: { r1: {} }, tags: ['y'] }, + ]; + + // Save all 3 states + module.saveState(); // 0: A + setAppState(structuredClone(states[1])); + module.saveState(); // 1: B + setAppState(structuredClone(states[2])); + module.saveState(); // 2: C + + // Undo to B + module.undo(); + expect(getAppState().classrooms).toEqual(['B']); + expect(getAppState().tags).toEqual(['x']); + + // Redo to C + module.redo(); + expect(getAppState().classrooms).toEqual(['C']); + expect(getAppState().tags).toEqual(['y']); + + // Undo to B again + module.undo(); + expect(getAppState().classrooms).toEqual(['B']); + + // Undo to A + module.undo(); + expect(getAppState().classrooms).toEqual(['A']); + + // Redo all the way + module.redo(); // B + module.redo(); // C + expect(getAppState().classrooms).toEqual(['C']); + + // One more redo should be no-op + module.redo(); + expect(getAppState().classrooms).toEqual(['C']); + }); + + it('should not corrupt stack with rapid undo/redo', () => { + const { module, setAppState } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['X'], scheduleData: {}, tags: [] }); + module.saveState(); + + // Rapid back and forth + for (let i = 0; i < 10; i++) { + module.undo(); + module.redo(); + } + expect(module.getStack()).toHaveLength(2); + expect(module.getIndex()).toBe(1); + }); + }); + + describe('resetHistory', () => { + it('should clear stack and set single entry', () => { + const { module, setAppState } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); + setAppState({ classrooms: ['C'], scheduleData: {}, tags: [] }); + module.saveState(); + + expect(module.getStack()).toHaveLength(3); + + module.resetHistory(); + expect(module.getStack()).toHaveLength(1); + expect(module.getIndex()).toBe(0); + expect(module.getStack()[0].classrooms).toEqual(['C']); // Current state + }); + + it('should call onUpdateButtons, onUpdateCleanSnapshot, onCheckDirty', () => { + const { module, callbacks } = createTestContext(); + module.resetHistory(); + expect(callbacks.onUpdateButtons).toHaveBeenCalled(); + expect(callbacks.onUpdateCleanSnapshot).toHaveBeenCalled(); + expect(callbacks.onCheckDirty).toHaveBeenCalled(); + }); + + it('should disable undo and redo after reset', () => { + const { module, setAppState } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); + + module.resetHistory(); + expect(module.canUndo()).toBe(false); + expect(module.canRedo()).toBe(false); + }); + }); + + describe('canUndo / canRedo', () => { + it('canUndo false with empty/single-entry stack', () => { + const { module } = createTestContext(); + expect(module.canUndo()).toBe(false); + module.saveState(); + expect(module.canUndo()).toBe(false); // Only 1 entry + }); + + it('canUndo true with multiple entries', () => { + const { module, setAppState } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); + expect(module.canUndo()).toBe(true); + }); + + it('canRedo false at end of stack', () => { + const { module, setAppState } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); + expect(module.canRedo()).toBe(false); + }); + + it('canRedo true after undo', () => { + const { module, setAppState } = createTestContext(); + module.saveState(); + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); + module.undo(); + expect(module.canRedo()).toBe(true); + }); + }); + + describe('state isolation (structuredClone)', () => { + it('saved states should be independent copies', () => { + const { module, setAppState, getAppState } = createTestContext(); + const shared = { classrooms: ['Shared'], scheduleData: {}, tags: [] }; + setAppState(shared); + module.saveState(); + + // Mutate the original object + shared.classrooms.push('Mutated'); + + // Stack entry should not be affected + expect(module.getStack()[0].classrooms).toEqual(['Shared']); + }); + + it('undo/redo loaded states should be independent copies', () => { + const { module, setAppState, getAppState } = createTestContext(); + module.saveState(); // 0: A + setAppState({ classrooms: ['B'], scheduleData: {}, tags: [] }); + module.saveState(); // 1: B + + module.undo(); // Load state 0 + const loadedState = getAppState(); + loadedState.classrooms.push('Mutated'); + + // Redo should still get the original state 1, not affected by mutation + module.redo(); + expect(getAppState().classrooms).toEqual(['B']); + }); + }); + + describe('complex scheduleData through undo/redo', () => { + it('should correctly restore complex nested scheduleData', () => { + const { module, setAppState, getAppState } = createTestContext(); + const state0 = { + classrooms: ['Room1'], + scheduleData: { + Room1: { + 0: [{ id: '1', name: 'Math', timeStart: '08:00', timeEnd: '09:00' }], + }, + }, + tags: ['math'], + }; + const state1 = { + classrooms: ['Room1', 'Room2'], + scheduleData: { + Room1: { + 0: [ + { id: '1', name: 'Math', timeStart: '08:00', timeEnd: '09:00' }, + { id: '2', name: 'Science', timeStart: '09:00', timeEnd: '10:00' }, + ], + }, + Room2: { + 1: [{ id: '3', name: 'English', timeStart: '10:00', timeEnd: '11:00' }], + }, + }, + tags: ['math', 'science'], + }; + + setAppState(structuredClone(state0)); + module.saveState(); + setAppState(structuredClone(state1)); + module.saveState(); + + // Undo should restore state0 + module.undo(); + const restored = getAppState(); + expect(restored.classrooms).toEqual(['Room1']); + expect(restored.scheduleData.Room1[0]).toHaveLength(1); + expect(restored.scheduleData.Room1[0][0].name).toBe('Math'); + expect(restored.scheduleData.Room2).toBeUndefined(); + + // Redo should restore state1 + module.redo(); + const redone = getAppState(); + expect(redone.classrooms).toEqual(['Room1', 'Room2']); + expect(redone.scheduleData.Room1[0]).toHaveLength(2); + expect(redone.scheduleData.Room2[1][0].name).toBe('English'); + }); + }); +}); diff --git a/tests/unit/wiringContracts.test.js b/tests/unit/wiringContracts.test.js index 1f5ab20..de64544 100644 --- a/tests/unit/wiringContracts.test.js +++ b/tests/unit/wiringContracts.test.js @@ -194,7 +194,9 @@ describe('程式碼.js wiring contracts (#114)', () => { // historyHelpers, integrationHelpers, appLifecycleHelpers, // scheduleListHelpers (#131 — frontend DI extraction from JavaScript.html), // filterHelpers (#132 — filter pipeline DI extraction from JavaScript.html), - // lockHelpers (#136 — lock management DI extraction from JavaScript.html) - expect(libFiles.length).toBe(14); + // lockHelpers (#136 — lock management DI extraction from JavaScript.html), + // undoRedoHelpers (#135 — undo/redo stack logic DI extraction from History.js.html), + // dataIdHelpers (#137 — ensureDataIds DI extraction from JavaScript.html) + expect(libFiles.length).toBe(16); }); });