M-3: PageContext.addPage minted ids from bare `page_${Date.now()}` --
two adds inside the same millisecond collided on id, so a subsequent
rename/delete/save silently acted on both pages at once. Added a
module-scoped monotonic counter combined with the timestamp
(nextPageId(), exported for direct unit testing) and used it
everywhere an addPage-style id is minted (addPage, replaceAllPages).
M-4: scopeId() lowercased + stripped non-alphanumeric characters from
the node id into a slug, so two node ids differing only by
case/punctuation (e.g. "AbC" vs "abc", or "a-b" vs "ab") collapsed
onto the same scope -- defeating the whole point of scoping ids per
node (M-1/Menu/Tabs/ColumnLayout/Gallery/etc. all rely on it). Now
hashes the raw node id via the existing djb2 stableHash() instead of
slugifying it: still deterministic (same id -> same scope) and a valid
CSS ident, but collision-resistant across case/punctuation. This
changes the exact scope strings Menu/Tabs/ColumnLayout/Gallery/etc.
emit -- expected and fine, since none of their tests pinned an exact
scope value (all already asserted structure/uniqueness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
5.1 KiB
TypeScript
195 lines
5.1 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, 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 <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 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<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('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<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();
|
|
});
|
|
});
|