import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { PageProvider, usePages, uniqueSlug, nextPageId } from './PageContext'; /* PageContext only needs `useEditor` from @craftjs/core (for query.serialize / actions.deserialize during page switches) — mock just that so PageProvider can mount without a real tree, following the DOM-harness pattern used in src/ui/AssetPicker.test.tsx (no @testing-library/react in this repo). */ vi.mock('@craftjs/core', () => ({ useEditor: () => ({ query: { serialize: () => '{}' }, actions: { deserialize: vi.fn() }, }), })); /* addPage mints ids via nextPageId() (timestamp + monotonic counter, M-3), so same-millisecond calls no longer collide on id. Date.now() is still pinned/advanced here for determinism across the slug-dedupe assertions below, independent of wall-clock timing. */ let dateNowSpy: ReturnType; beforeEach(() => { let counter = 1_700_000_000_000; dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => counter++); }); afterEach(() => { dateNowSpy.mockRestore(); }); 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(); } describe('nextPageId (M-3: no same-millisecond id collision)', () => { test('two calls yield distinct ids even when Date.now() is pinned to a constant', () => { const spy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); try { const id1 = nextPageId(); const id2 = nextPageId(); expect(id1).not.toBe(id2); } finally { spy.mockRestore(); } }); test('ids are prefixed with "page_"', () => { expect(nextPageId()).toMatch(/^page_/); }); }); describe('uniqueSlug', () => { test('returns base unchanged when no collision', () => { expect(uniqueSlug('about', ['index', 'contact'])).toBe('about'); }); test('appends -2 on first collision, -3 on the next, etc', () => { expect(uniqueSlug('about', ['about'])).toBe('about-2'); expect(uniqueSlug('about', ['about', 'about-2'])).toBe('about-3'); }); }); describe('PageProvider slug dedupe', () => { test('addPage: two pages with the same name yield distinct slugs', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => { ctx!.addPage('About', ''); }); act(() => { ctx!.addPage('About', ''); }); const slugs = ctx!.pages.map((p) => p.slug); expect(slugs).toEqual(['index', 'about', 'about-2']); expect(new Set(slugs).size).toBe(slugs.length); unmount(); }); test('renamePage: renaming to an already-used slug is deduped', () => { 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 contactPage = ctx!.pages.find((p) => p.name === 'Contact')!; act(() => { ctx!.renamePage(contactPage.id, 'Contact', 'about'); }); const renamed = ctx!.pages.find((p) => p.id === contactPage.id)!; expect(renamed.slug).toBe('about-2'); const slugs = ctx!.pages.map((p) => p.slug); expect(new Set(slugs).size).toBe(slugs.length); unmount(); }); test('M-4: a punctuation-only page name ("!!!") falls back to slug "page", never empty string', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); act(() => { ctx!.addPage('!!!', ''); }); const added = ctx!.pages.find((p) => p.name === '!!!')!; expect(added.slug).toBe('page'); expect(added.slug).not.toBe(''); // A second punctuation-only-named page dedupes to 'page-2', not ''. act(() => { ctx!.addPage('???', ''); }); const second = ctx!.pages.find((p) => p.name === '???')!; expect(second.slug).toBe('page-2'); expect(second.slug).not.toBe(''); unmount(); }); test('landing page slug always stays "index" even if renamed to collide', () => { let ctx: ReturnType | null = null; const Consumer: React.FC = () => { ctx = usePages(); return null; }; render( , ); const landingId = ctx!.pages[0].id; act(() => { ctx!.renamePage(landingId, 'Whatever', 'whatever'); }); expect(ctx!.pages[0].slug).toBe('index'); unmount(); }); });