fix(builder): dedupe page slugs on add/rename/replaceAll (D3)
Two pages that slugify to the same string (e.g. both named "About") previously both published to about.html, silently overwriting each other on publish. Add a pure uniqueSlug(base, existingSlugs) helper that appends -2, -3, ... on collision, and apply it in addPage, renamePage, and replaceAllPages. The landing page's slug stays locked to 'index' regardless of collisions, matching existing behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user