Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
510 lines
16 KiB
TypeScript
510 lines
16 KiB
TypeScript
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<PageData> & { 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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
// 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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
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<typeof usePages> | null = null;
|
|
const Consumer: React.FC = () => {
|
|
ctx = usePages();
|
|
return null;
|
|
};
|
|
render(
|
|
<PageProvider>
|
|
<Consumer />
|
|
</PageProvider>,
|
|
);
|
|
|
|
deserializeMock.mockClear();
|
|
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
|
|
await flushTimers();
|
|
|
|
expect(deserializeMock).toHaveBeenCalled();
|
|
|
|
unmount();
|
|
});
|
|
});
|