import { beforeEach, describe, expect, test } from 'bun:test'; import { App } from './index'; import type { AppState } from './index'; import { UndoManager } from './undoManager'; import { VALUES } from './values'; // Import value counts for tests const ALL_VALUES_COUNT = VALUES.length; const LIMITED_VALUES_COUNT = 10; // Mock necessary DOM elements and event listeners beforeEach(() => { // Mock window.alert to prevent blocking tests window.alert = () => { /* no op */ }; // Add comment to satisfy linter // Mock window.confirm to default to true (yes) for tests, unless overridden window.confirm = () => true; // The happy-dom environment should provide localStorage automatically. // We still need to clear it before each test. window.localStorage.clear(); document.body.innerHTML = `
Part 1 Content
`; }); describe('Values Exercise App', () => { let app: App; let initialState: AppState; beforeEach(() => { // Initialize app before each test, using the mocked DOM app = new App(); initialState = app.defaultState(); // Get initial state structure for comparison }); test('Initial state should be correct', () => { const state = app.undoManager.getState(); expect(state.currentPart).toBe('part1'); expect(state.cards.length).toBeGreaterThan(0); expect(state.cards.every((c) => c.column === 'unassigned')).toBe(true); }); test('Part 1 to Part 2 transition moves only veryImportant cards', () => { // Arrange: Move some cards const state = app.undoManager.getState(); // Add checks to ensure cards exist at these indices before modification if (state.cards.length < 3) { throw new Error('Test setup failed: Initial state should have at least 3 cards for this test.'); } // Assign ALL cards to avoid blocking the transition due to the unassigned check state.cards.forEach((card, index) => { if (index === 0) { card.column = 'veryImportant'; } else if (index === 1) { card.column = 'important'; } else if (index === 2) { card.column = 'veryImportant'; } else { card.column = 'notImportant'; // Assign all others } }); app.updateState(state); // Act: Simulate clicking the button const button = document.getElementById('toPart2'); button?.click(); // Assert: Check the state after transition const part2State = app.undoManager.getState(); expect(part2State.currentPart).toBe('part2'); expect(part2State.cards.length).toBe(2); expect(part2State.cards.every((c) => c.column === 'unassigned')).toBe(true); // Check names to be sure const cardNames = part2State.cards.map((c) => c.name); // Add checks here too, although the length check above makes these safe if (initialState.cards.length < 3) { throw new Error('Test setup failed: Initial state lost cards unexpectedly.'); } // Assign to variables first to help type inference const card0 = initialState.cards[0]; const card1 = initialState.cards[1]; const card2 = initialState.cards[2]; // Add explicit checks for the variables (though length check implies they exist) if (!card0 || !card1 || !card2) { throw new Error('Test setup failed: Card elements are unexpectedly undefined.'); } expect(cardNames).toContain(card0.name); expect(cardNames).toContain(card2.name); expect(cardNames).not.toContain(card1.name); }); test('Part 1 to Part 2 transition BLOCKS if unassigned cards exist', () => { // Arrange: Leave some cards unassigned (initial state) const initialStateCards = app.undoManager.getState().cards; // Ensure there is at least one unassigned card expect(initialStateCards.some((c) => c.column === 'unassigned')).toBe(true); // Act: Simulate clicking the button const button = document.getElementById('toPart2'); button?.click(); // Assert: Check that the state did NOT change const stateAfterClick = app.undoManager.getState(); expect(stateAfterClick.currentPart).toBe('part1'); // Should still be in part1 expect(stateAfterClick.cards).toEqual(initialStateCards); // Cards state should be unchanged // We could also spy on window.alert if needed }); test('Part 2 to Part 4 transition SKIPS Part 3 if <= 5 veryImportant cards', () => { // Arrange: Setup state in Part 2 with 4 'veryImportant' cards const state = app.undoManager.getState(); state.currentPart = 'part2'; // Manually set part for setup state.cards = [ { id: 1, name: 'V1', column: 'veryImportant', order: 0 }, { id: 2, name: 'V2', column: 'veryImportant', order: 1 }, { id: 3, name: 'V3', column: 'veryImportant', order: 2 }, { id: 4, name: 'V4', column: 'veryImportant', order: 3 }, { id: 5, name: 'I1', column: 'important', order: 4 }, // Need some non-veryImportant too ]; app.updateState(state); // Note: Need to re-bind listeners if updateState doesn't do it, // but our simple listener binding in constructor might suffice if DOM isn't fully replaced. // For robustness, re-creating App instance or re-binding might be better in complex scenarios. app = new App(); // Re-create to ensure listeners are bound to correct state/DOM // Act: Click the 'Next' button (toPart3) const button = document.getElementById('toPart3'); button?.click(); // Assert: Should be in Part 4, and cards should be 'core' const part4State = app.undoManager.getState(); expect(part4State.currentPart).toBe('part4'); // Should skip to part4 expect(part4State.cards.length).toBe(4); // Only the 4 veryImportant cards remain expect(part4State.cards.every((c) => c.column === 'core')).toBe(true); }); test('Part 3 to Part 4 transition BLOCKS if > 5 core cards', () => { // Arrange: Setup state in Part 3 with 6 'core' cards const state = app.undoManager.getState(); state.currentPart = 'part3'; state.cards = [ { id: 1, name: 'C1', column: 'core', order: 0 }, { id: 2, name: 'C2', column: 'core', order: 1 }, { id: 3, name: 'C3', column: 'core', order: 2 }, { id: 4, name: 'C4', column: 'core', order: 3 }, { id: 5, name: 'C5', column: 'core', order: 4 }, { id: 6, name: 'C6', column: 'core', order: 5 }, ]; app.updateState(state); app = new App(); // Re-create for listeners // Act: Click the 'Next' button (toPart4) const button = document.getElementById('toPart4'); button?.click(); // Assert: Should still be in Part 3 const stateAfterClick = app.undoManager.getState(); expect(stateAfterClick.currentPart).toBe('part3'); }); test('Part 3 to Part 4 transition SUCCEEDS if <= 5 core cards', () => { // Arrange: Setup state in Part 3 with 5 'core' cards const state = app.undoManager.getState(); state.currentPart = 'part3'; state.cards = [ { id: 1, name: 'C1', column: 'core', order: 0 }, { id: 2, name: 'C2', column: 'core', order: 1 }, { id: 3, name: 'C3', column: 'core', order: 2 }, { id: 4, name: 'C4', column: 'core', order: 3 }, { id: 5, name: 'C5', column: 'core', order: 4 }, { id: 6, name: 'A1', column: 'additional', order: 5 }, // One additional ]; app.updateState(state); app = new App(); // Re-create for listeners // Act: Click the 'Next' button (toPart4) const button = document.getElementById('toPart4'); button?.click(); // Assert: Should be in Part 4 const part4State = app.undoManager.getState(); expect(part4State.currentPart).toBe('part4'); // Cards state should remain the same (core/additional separation is kept) expect(part4State.cards.filter((c) => c.column === 'core').length).toBe(5); expect(part4State.cards.filter((c) => c.column === 'additional').length).toBe(1); }); // --- Tests for Value Set Toggling --- test('Initial state uses limited value set', () => { const state = app.undoManager.getState(); expect(state.valueSet).toBe('limited'); expect(state.cards.length).toBe(LIMITED_VALUES_COUNT); }); test('Clicking "Use All Values" switches set and resets state', () => { // Arrange: Start in default limited state const initialState = app.undoManager.getState(); expect(initialState.valueSet).toBe('limited'); // Optional: Advance state to ensure reset happens initialState.currentPart = 'part2'; initialState.finalStatements = { 1: 'test' }; app.updateState(initialState); // Act: Call the method directly instead of simulating click app.toggleValueSet(); // Assert const newState = app.undoManager.getState(); expect(newState.valueSet).toBe('all'); expect(newState.cards.length).toBe(ALL_VALUES_COUNT); expect(newState.currentPart).toBe('part1'); // Should reset to part1 expect(Object.keys(newState.finalStatements).length).toBe(0); // Statements cleared }); test('Clicking "Use 10 Values" switches set back and resets state', () => { // Arrange: Start, switch to All using direct call // const buttonAll = document.getElementById('useAllValuesBtn'); // buttonAll?.click(); // Replace click simulation app.toggleValueSet(); // Call directly to set state to 'all' for setup const allState = app.undoManager.getState(); expect(allState.valueSet).toBe('all'); // Verify setup worked // Optional: Advance state to ensure reset happens allState.currentPart = 'part3'; app.updateState(allState); // Act: Call the method directly instead of simulating click app.toggleValueSet(); // Assert const newState = app.undoManager.getState(); expect(newState.valueSet).toBe('limited'); expect(newState.cards.length).toBe(LIMITED_VALUES_COUNT); expect(newState.currentPart).toBe('part1'); expect(Object.keys(newState.finalStatements).length).toBe(0); }); test('Clicking the active value set button does nothing', () => { const initialState = app.undoManager.getState(); expect(initialState.valueSet).toBe('limited'); // Act: Click the already active limited button const buttonLimited = document.getElementById('useLimitedValuesBtn'); buttonLimited?.click(); // Assert: State should not have changed const stateAfterClick = app.undoManager.getState(); expect(stateAfterClick).toEqual(initialState); }); test('Switching value set does NOT happen if confirm returns false', () => { const initialState = app.undoManager.getState(); expect(initialState.valueSet).toBe('limited'); // Arrange: Mock confirm to return false for this test const originalConfirm = window.confirm; window.confirm = () => false; // Act: Click the button to switch to all values const buttonAll = document.getElementById('useAllValuesBtn'); buttonAll?.click(); // Assert: State should not have changed const stateAfterClick = app.undoManager.getState(); expect(stateAfterClick).toEqual(initialState); // Cleanup: Restore original confirm window.confirm = originalConfirm; }); // --- Tests for Adding Custom Values --- test('saveNewValue adds a custom value card', () => { // Arrange: Need access to the private method or trigger via UI // We'll simulate the input values and call the method directly for simplicity const name = 'MY CUSTOM VALUE'; const description = 'This is important to me.'; (document.getElementById('newValueName') as HTMLInputElement).value = name; (document.getElementById('newValueDesc') as HTMLTextAreaElement).value = description; const initialCardCount = app.undoManager.getState().cards.length; // Act app.saveNewValue(); // Call public method directly // Assert const newState = app.undoManager.getState(); expect(newState.cards.length).toBe(initialCardCount + 1); const newCard = newState.cards[newState.cards.length - 1]; if (!newCard) throw new Error('Test failed: New card not found after add'); expect(newCard.name).toBe(name); expect(newCard.description).toBe(description); expect(newCard.isCustom).toBe(true); expect(newCard.column).toBe('unassigned'); expect(newCard.id).toBeLessThan(0); // Custom IDs are negative }); test('saveNewValue prevents duplicate names', () => { // Arrange: Add one custom card first const name = 'MY CUSTOM VALUE'; (document.getElementById('newValueName') as HTMLInputElement).value = name; (document.getElementById('newValueDesc') as HTMLTextAreaElement).value = 'Desc 1'; app.saveNewValue(); // Call public method directly const stateAfterFirstAdd = app.undoManager.getState(); const cardCountAfterFirst = stateAfterFirstAdd.cards.length; // Act: Try to add another with the same name (case-insensitive) (document.getElementById('newValueName') as HTMLInputElement).value = name.toLowerCase(); (document.getElementById('newValueDesc') as HTMLTextAreaElement).value = 'Desc 2'; app.saveNewValue(); // Call public method directly // Assert: State should not have changed, card count same const stateAfterSecondAttempt = app.undoManager.getState(); expect(stateAfterSecondAttempt.cards.length).toBe(cardCountAfterFirst); // Alert would have been called - we could spy on it if needed }); // --- Tests for Editing Descriptions --- test('startEditingDescription sets editingDescriptionCardId', () => { const cardToEditId = app.undoManager.getState().cards[0]!.id; // Use non-null assertion expect(app.undoManager.getState().editingDescriptionCardId).toBeNull(); // Act app.startEditingDescription(cardToEditId); // Call public method directly // Assert expect(app.undoManager.getState().editingDescriptionCardId).toBe(cardToEditId); }); test('saveDescriptionEdit updates card description and clears editingId', () => { // Arrange: Start editing the first card const cards = app.undoManager.getState().cards; const cardToEdit = cards[0]!; // Use non-null assertion const cardId = cardToEdit.id; const newDesc = 'My edited description.'; app.startEditingDescription(cardId); // Call public method directly // Act app.saveDescriptionEdit(cardId, newDesc); // Call public method directly // Assert const newState = app.undoManager.getState(); expect(newState.editingDescriptionCardId).toBeNull(); const updatedCard = newState.cards.find((c) => c.id === cardId); if (!updatedCard) throw new Error('Test failed: Updated card not found'); expect(updatedCard.description).toBe(newDesc); // Ensure other card descriptions weren't affected (simple check) if (cards.length > 1) { const otherCard = newState.cards.find((c) => c.id === cards[1]!.id); // Use non-null assertion if (!otherCard) throw new Error('Test setup failed: Second card not found'); expect(otherCard.description).not.toBe(newDesc); } }); test('cancelDescriptionEdit clears editingId without saving', () => { // Arrange: Start editing the first card const cards = app.undoManager.getState().cards; const cardToEdit = cards[0]!; // Use non-null assertion const cardId = cardToEdit.id; app.startEditingDescription(cardId); // Call public method directly // Simulate typing something into the textarea (though it's not rendered here) // Act app.cancelDescriptionEdit(); // Call public method directly // Assert const newState = app.undoManager.getState(); expect(newState.editingDescriptionCardId).toBeNull(); const notUpdatedCard = newState.cards.find((c) => c.id === cardId); if (!notUpdatedCard) throw new Error('Test failed: Card not found after cancel'); // Explicitly handle potential undefined originalDesc if (notUpdatedCard.description === undefined) { expect(notUpdatedCard.description).toBeUndefined(); } else { expect(notUpdatedCard.description).toBe(notUpdatedCard.description); } }); // --- Test Review Page Rendering with Custom/Edited Descriptions --- test('Review page renders custom and edited descriptions correctly', () => { // Arrange: // 1. Add a custom value const customName = 'CUSTOM VAL'; const customDesc = 'My custom description'; (document.getElementById('newValueName') as HTMLInputElement).value = customName; (document.getElementById('newValueDesc') as HTMLTextAreaElement).value = customDesc; app.saveNewValue(); // Call public method directly // 2. Edit description of a built-in value (e.g., ACCEPTANCE) let state = app.undoManager.getState(); const builtInCard = state.cards.find((c) => c.name === 'ACCEPTANCE')!; const builtInCardId = builtInCard.id; const editedBuiltInDesc = 'My edited acceptance'; app.saveDescriptionEdit(builtInCardId, editedBuiltInDesc); // Call public method directly // 3. Move cards to core/additional for review page state = app.undoManager.getState(); state.currentPart = 'review'; state.cards.forEach((card) => { if (card.name === 'ACCEPTANCE' || card.name === customName) { card.column = 'core'; } else { card.column = 'additional'; // Move others somewhere else } }); app.updateState(state); app = new App(); // Re-render with final state // Act: Trigger render (implicitly done by new App() or manually if needed) // (app as any).render(); // Usually constructor calls render // Assert: Check the rendered HTML in the review section const reviewContent = document.getElementById('reviewContent'); expect(reviewContent).not.toBeNull(); const coreSection = reviewContent!.querySelector('.grid-section:first-child'); // Assuming core is first expect(coreSection).not.toBeNull(); const coreNames = Array.from(coreSection!.querySelectorAll('.review-value-name')).map((el) => el.textContent); const coreDescs = Array.from(coreSection!.querySelectorAll('.review-value-description')).map( (el) => el.textContent, ); // Find the indices based on names (order might vary slightly if sorting changes) const acceptanceIndex = coreNames.indexOf('ACCEPTANCE'); const customIndex = coreNames.indexOf(customName); expect(acceptanceIndex).toBeGreaterThan(-1); expect(customIndex).toBeGreaterThan(-1); expect(coreDescs[acceptanceIndex]).toBe(editedBuiltInDesc); expect(coreDescs[customIndex]).toBe(customDesc); }); // Add more tests for other transitions (Part 2 -> 3, 3 -> 4, etc.) // Add tests for card movement logic (moveCard) // Add tests for final statement input // Add tests for review screen rendering }); describe('UndoManager', () => { let initialState: AppState; let um: UndoManager; beforeEach(() => { // Create a sample initial state for testing UndoManager independently initialState = { currentPart: 'part1', cards: [ { id: 1, name: 'TEST1', column: 'unassigned', order: 0 }, { id: 2, name: 'TEST2', column: 'unassigned', order: 1 }, ], finalStatements: {}, valueSet: 'limited', editingDescriptionCardId: null, // Add missing property }; um = new UndoManager(initialState); }); test('should return the initial state', () => { expect(um.getState()).toEqual(initialState); expect(um.canUndo()).toBe(false); expect(um.canRedo()).toBe(false); }); test('should execute a state change and update current state', () => { const newState = { ...initialState, currentPart: 'part2' } as const; // Use as const um.execute(newState); expect(um.getState()).toEqual(newState); expect(um.canUndo()).toBe(true); expect(um.canRedo()).toBe(false); }); test('should undo the last state change', () => { const newState = { ...initialState, currentPart: 'part2' } as const; // Use as const um.execute(newState); const undoneState = um.undo(); expect(undoneState).toEqual(initialState); expect(um.getState()).toEqual(initialState); expect(um.canUndo()).toBe(false); expect(um.canRedo()).toBe(true); }); test('should redo the undone state change', () => { const newState = { ...initialState, currentPart: 'part2' } as const; // Use as const um.execute(newState); um.undo(); const redoneState = um.redo(); expect(redoneState).toEqual(newState); expect(um.getState()).toEqual(newState); expect(um.canUndo()).toBe(true); expect(um.canRedo()).toBe(false); }); test('should clear redo stack on new execution after undo', () => { const state2 = { ...initialState, currentPart: 'part2' } as const; // Use as const const state3 = { ...initialState, currentPart: 'part3' } as const; // Use as const um.execute(state2); um.undo(); // Back to initialState, state2 is in redo stack um.execute(state3); // Execute a new change expect(um.getState()).toEqual(state3); expect(um.canUndo()).toBe(true); // Can undo state3 expect(um.canRedo()).toBe(false); // Redo stack (state2) should be cleared // Check undo goes back to initial state, not state 2 const undoneState = um.undo(); expect(undoneState).toEqual(initialState); }); test('should handle multiple undo/redo operations', () => { const state2 = { ...initialState, currentPart: 'part2' } as const; // Use as const const state3 = { ...initialState, currentPart: 'part3' } as const; // Use as const um.execute(state2); um.execute(state3); expect(um.getState()).toEqual(state3); um.undo(); expect(um.getState()).toEqual(state2); um.undo(); expect(um.getState()).toEqual(initialState); expect(um.canUndo()).toBe(false); expect(um.canRedo()).toBe(true); um.redo(); expect(um.getState()).toEqual(state2); um.redo(); expect(um.getState()).toEqual(state3); expect(um.canRedo()).toBe(false); expect(um.canUndo()).toBe(true); }); test('undo/redo should return null when stacks are empty', () => { expect(um.undo()).toBeNull(); expect(um.redo()).toBeNull(); const state2 = { ...initialState, currentPart: 'part2' } as const; // Use as const um.execute(state2); expect(um.redo()).toBeNull(); // Still no redo um.undo(); expect(um.undo()).toBeNull(); // Already at start }); });