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 } 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 from `Date.now()`. Two adds inside the same test can land in the same millisecond and collide on id, which is an existing, unrelated bug (id collision, not slug collision) — out of scope here but it makes these tests flaky since a colliding id defeats the "other pages" slug lookup. Force distinct ids so the slug-dedupe assertions below are stable. */ 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('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(); }); });