import { describe, test, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { PageProvider, usePages, applyLandingInvariant } from './PageContext'; import { PageData } from '../types'; /** * PKG-I: page duplicate / reorder / set-landing. * * The landing-page invariant is: `pages[0]` is the landing page, slug * LOCKED to `'index'`; every other page gets a real, unique slug. This * suite covers: * - `applyLandingInvariant` as a pure function (unit tests, no provider). * - `movePage`/`setLandingPage` re-establishing the invariant after * reordering, via `PageProvider`. * - `duplicatePage` inserting a copy right after the source with a copied * craftState + seo and a unique slug, and switching the canvas to it. */ function makePage(overrides: Partial & { id: string }): PageData { return { name: overrides.id, slug: overrides.id, craftState: null, ...overrides }; } describe('applyLandingInvariant (pure)', () => { test('page at index 0 gets slug "index" even if it held a different slug', () => { const pages = [ makePage({ id: 'a', name: 'About', slug: 'about' }), makePage({ id: 'b', name: 'Home', slug: 'index' }), ]; const result = applyLandingInvariant(pages); expect(result[0].slug).toBe('index'); expect(result[0].id).toBe('a'); }); test('demotes the old landing page (now at index > 0) to a unique real slug', () => { const pages = [ makePage({ id: 'a', name: 'About', slug: 'about' }), makePage({ id: 'b', name: 'Home', slug: 'index' }), ]; const result = applyLandingInvariant(pages); const demoted = result.find((p) => p.id === 'b')!; expect(demoted.slug).not.toBe('index'); expect(demoted.slug).toBe('home'); }); test('never produces two pages with slug "index"', () => { const pages = [ makePage({ id: 'a', name: 'About', slug: 'about' }), makePage({ id: 'b', name: 'Home', slug: 'index' }), makePage({ id: 'c', name: 'Contact', slug: 'contact' }), ]; const result = applyLandingInvariant(pages); const indexSlugs = result.filter((p) => p.slug === 'index'); expect(indexSlugs).toHaveLength(1); expect(indexSlugs[0].id).toBe('a'); }); test('demoted page slug is deduped against a colliding existing slug elsewhere in the array', () => { const pages = [ makePage({ id: 'a', name: 'About', slug: 'about' }), makePage({ id: 'b', name: 'Home', slug: 'index' }), // demoted page, named "Home" -> slugifies to "home" makePage({ id: 'c', name: 'HomePage', slug: 'home' }), // unrelated page already using slug "home" ]; const result = applyLandingInvariant(pages); expect(result[0].slug).toBe('index'); const demoted = result.find((p) => p.id === 'b')!; expect(demoted.slug).toBe('home-2'); const untouched = result.find((p) => p.id === 'c')!; expect(untouched.slug).toBe('home'); const slugs = result.map((p) => p.slug); expect(new Set(slugs).size).toBe(slugs.length); }); test('non-landing pages that already have a real slug are left untouched', () => { const pages = [ makePage({ id: 'a', name: 'Home', slug: 'index' }), makePage({ id: 'b', name: 'About', slug: 'about' }), makePage({ id: 'c', name: 'Contact', slug: 'contact' }), ]; const result = applyLandingInvariant(pages); expect(result[1]).toEqual(pages[1]); expect(result[2]).toEqual(pages[2]); }); test('empty array is a no-op', () => { expect(applyLandingInvariant([])).toEqual([]); }); }); /* ---------- PageProvider-mounted coverage ---------- */ let serializeReturn = '{}'; const deserializeMock = vi.fn(); vi.mock('@craftjs/core', () => ({ useEditor: () => ({ query: { serialize: () => serializeReturn }, actions: { deserialize: deserializeMock }, }), })); let container: HTMLDivElement; let root: Root; function render(ui: React.ReactElement) { container = document.createElement('div'); document.body.appendChild(container); act(() => { root = createRoot(container); root.render(ui); }); } function unmount() { act(() => { root.unmount(); }); container.remove(); } async function flushTimers() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); } beforeEach(() => { serializeReturn = '{}'; deserializeMock.mockClear(); }); describe('PageContext.movePage', () => { test('moves a page up, swapping with its neighbor', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); act(() => ctx!.addPage('Contact', 'contact')); // pages: [Home(index0), About, Contact] const contactId = ctx!.pages[2].id; act(() => ctx!.movePage(contactId, 'up')); expect(ctx!.pages.map((p) => p.name)).toEqual(['Home', 'Contact', 'About']); // Landing invariant still holds -- Home untouched at index 0. expect(ctx!.pages[0].slug).toBe('index'); unmount(); }); test('is a no-op at the top boundary', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); const homeId = ctx!.pages[0].id; const before = ctx!.pages.map((p) => p.id); act(() => ctx!.movePage(homeId, 'up')); expect(ctx!.pages.map((p) => p.id)).toEqual(before); unmount(); }); test('is a no-op at the bottom boundary', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); const aboutId = ctx!.pages[1].id; const before = ctx!.pages.map((p) => p.id); act(() => ctx!.movePage(aboutId, 'down')); expect(ctx!.pages.map((p) => p.id)).toEqual(before); unmount(); }); test('moving a non-landing page into index 0 promotes it and demotes the old landing page to a real slug', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); // pages: [Home(index0, slug index), About] const aboutId = ctx!.pages[1].id; const homeId = ctx!.pages[0].id; act(() => ctx!.movePage(aboutId, 'up')); // pages: [About, Home] expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]); expect(ctx!.pages[0].slug).toBe('index'); // About is now the landing page const demotedHome = ctx!.pages.find((p) => p.id === homeId)!; expect(demotedHome.slug).not.toBe('index'); expect(demotedHome.slug).toBe('home'); // Exactly one 'index' slug, always at index 0. const indexPages = ctx!.pages.filter((p) => p.slug === 'index'); expect(indexPages).toHaveLength(1); expect(ctx!.pages.indexOf(indexPages[0])).toBe(0); // movePage does not touch the canvas. expect(deserializeMock).not.toHaveBeenCalled(); unmount(); }); test('moving the current landing page down demotes it and promotes its neighbor', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); // pages: [Home(index0, slug index), About] const homeId = ctx!.pages[0].id; const aboutId = ctx!.pages[1].id; act(() => ctx!.movePage(homeId, 'down')); // pages: [About, Home] expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]); // Order changed and the landing invariant re-established: index 0 // (now About) gets slug 'index'; the moved page (now at index 1, Home) // gets a real, non-'index' unique slug. expect(ctx!.pages[0].id).toBe(aboutId); expect(ctx!.pages[0].slug).toBe('index'); const demotedHome = ctx!.pages.find((p) => p.id === homeId)!; expect(demotedHome.slug).not.toBe('index'); expect(demotedHome.slug).toBe('home'); // Exactly one 'index' slug. const indexPages = ctx!.pages.filter((p) => p.slug === 'index'); expect(indexPages).toHaveLength(1); unmount(); }); }); describe('PageContext.setLandingPage', () => { test('promotes an arbitrary page to index 0 and demotes the old landing page', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); act(() => ctx!.addPage('Contact', 'contact')); const homeId = ctx!.pages[0].id; const contactId = ctx!.pages[2].id; act(() => ctx!.setLandingPage(contactId)); expect(ctx!.pages[0].id).toBe(contactId); expect(ctx!.pages[0].slug).toBe('index'); const demotedHome = ctx!.pages.find((p) => p.id === homeId)!; expect(demotedHome.slug).toBe('home'); const indexPages = ctx!.pages.filter((p) => p.slug === 'index'); expect(indexPages).toHaveLength(1); // setLandingPage does not touch the canvas. expect(deserializeMock).not.toHaveBeenCalled(); unmount(); }); test('is a no-op when the page is already the landing page', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); const homeId = ctx!.pages[0].id; const before = ctx!.pages.map((p) => ({ ...p })); act(() => ctx!.setLandingPage(homeId)); expect(ctx!.pages).toEqual(before); unmount(); }); }); describe('PageContext.duplicatePage', () => { test('inserts a copy immediately after the source with a copied craftState, seo, and a unique slug', async () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); await flushTimers(); act(() => ctx!.addPage('Contact', 'contact')); await flushTimers(); // pages: [Home, About, Contact]; Contact is currently active. const aboutId = ctx!.pages[1].id; act(() => ctx!.updatePageSeo(aboutId, { metaTitle: 'About Us' })); act(() => ctx!.duplicatePage(aboutId)); await flushTimers(); const names = ctx!.pages.map((p) => p.name); expect(names).toEqual(['Home', 'About', 'About copy', 'Contact']); const copy = ctx!.pages[2]; expect(copy.name).toBe('About copy'); expect(copy.slug).toBe('about-copy'); expect(copy.seo).toEqual({ metaTitle: 'About Us' }); // Every slug in the array is unique. const slugs = ctx!.pages.map((p) => p.slug); expect(new Set(slugs).size).toBe(slugs.length); // Landing invariant untouched -- copy is never index 0. expect(ctx!.pages[0].slug).toBe('index'); unmount(); }); test('duplicating the ACTIVE page saves the live canvas into the copy (and the original)', async () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); // Home is active by default. Simulate the user having made live edits. serializeReturn = '{"ROOT":{"live":"edit"}}'; act(() => ctx!.duplicatePage(ctx!.pages[0].id)); await flushTimers(); const copy = ctx!.pages[1]; expect(copy.name).toBe('Home copy'); expect(copy.craftState).toBe('{"ROOT":{"live":"edit"}}'); // Original page's stored state was also refreshed to the live canvas. expect(ctx!.pages[0].craftState).toBe('{"ROOT":{"live":"edit"}}'); // The canvas switched to the new copy. expect(ctx!.activePageId).toBe(copy.id); unmount(); }); test('duplicating a non-active page copies its already-stored craftState (no live-canvas read)', async () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); await flushTimers(); // Home is now inactive, stored with whatever it serialized to on switch. const homeId = ctx!.pages[0].id; const homeCraftState = ctx!.pages[0].craftState; // Switch the live serialize() return to something else, to prove // duplicating an inactive page does NOT read the live canvas. serializeReturn = '{"ROOT":{"unrelated":"currently-active-page-content"}}'; act(() => ctx!.duplicatePage(homeId)); await flushTimers(); const copy = ctx!.pages.find((p) => p.name === 'Home copy')!; expect(copy.craftState).toBe(homeCraftState); unmount(); }); test('duplicating a non-active page does NOT drop the outgoing active page\'s live unsaved edits (regression lock)', async () => { // Regression test for the Critical bug: duplicatePage(pageId) used to // call saveCurrentState() ONLY when pageId === the active page, yet // ALWAYS ended by tearing down the canvas via loadState() + switching // activePageId to the copy. If the duplicated page was NOT the active // one, the active page's live canvas edits were never serialized into // its slot before that teardown -- silently discarded. This asserts the // outgoing active page ('About') keeps its live-serialized craftState // after duplicating a DIFFERENT page ('Home'). let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => ctx!.addPage('About', 'about')); await flushTimers(); // pages: [Home, About]; About is active (addPage switches to it). const homeId = ctx!.pages[0].id; const aboutId = ctx!.pages[1].id; expect(ctx!.activePageId).toBe(aboutId); // Simulate the user having made live, unsaved edits to About (the // active page) that have not yet been serialized into pages[] state. const liveAboutEdit = '{"ROOT":{"live":"about-edit-not-yet-saved"}}'; serializeReturn = liveAboutEdit; // Duplicate a DIFFERENT page (Home), not the active one (About). act(() => ctx!.duplicatePage(homeId)); await flushTimers(); // The outgoing active page's live edits must have been persisted into // its own slot before the canvas was torn down and switched away. const aboutAfter = ctx!.pages.find((p) => p.id === aboutId)!; expect(aboutAfter.craftState).toBe(liveAboutEdit); unmount(); }); test('switches the canvas to the new copy (deserialize called with the copy craftState)', async () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); deserializeMock.mockClear(); act(() => ctx!.duplicatePage(ctx!.pages[0].id)); await flushTimers(); expect(deserializeMock).toHaveBeenCalled(); unmount(); }); });