428 lines
13 KiB
TypeScript
428 lines
13 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();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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('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();
|
||
|
|
});
|
||
|
|
});
|