Files
site-builder/craft/src/state/PageContext.slug.test.tsx
T
shadowdao e12fb89ada Fix M-4: punctuation-only page name yields empty slug ('.html')
slugify('!!!') stripped down to '' since punctuation-only names have no
a-z0-9 characters left; buildSavePayload then wrote filename = '' +
'.html'. slugify now falls back to 'page' when the computed slug is
empty, so uniqueSlug's existing dedupe (page, page-2, ...) applies same
as any other collision. Landing page slug stays forced to 'index'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:33:33 -07:00

179 lines
4.8 KiB
TypeScript

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 <Editor> 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<typeof vi.spyOn>;
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<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
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<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 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<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
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<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
const landingId = ctx!.pages[0].id;
act(() => {
ctx!.renamePage(landingId, 'Whatever', 'whatever');
});
expect(ctx!.pages[0].slug).toBe('index');
unmount();
});
});