diff --git a/src/elements/tag-selector.module.scss b/src/elements/tag-selector.module.scss index 6e4ce09c..33850ec3 100644 --- a/src/elements/tag-selector.module.scss +++ b/src/elements/tag-selector.module.scss @@ -63,6 +63,16 @@ gap: 0.375rem; } +// The advanced selector's equivalent of `.groupTags`, and deliberately the same +// gap. Without it the buttons are inline-flex boxes in a plain block, and JSX +// drops the whitespace between them, so a whole category renders as one +// unbroken strip of touching pills. +.advancedTags { + display: flex; + flex-flow: row wrap; + gap: 0.375rem; +} + .tagButton { font: inherit; cursor: pointer; diff --git a/src/elements/tag-selector.tsx b/src/elements/tag-selector.tsx index 5dc9fe9f..f95c23fe 100644 --- a/src/elements/tag-selector.tsx +++ b/src/elements/tag-selector.tsx @@ -163,7 +163,7 @@ function AdvancedTagSelector({ selection, onChange, context = 'models' }: TagSel return (

{category.name}

-
+
{category.tags.map((tagId) => { const tag = tagData.get(tagId); const state = getState(tagId, selection); diff --git a/src/pages/datasets/[id].tsx b/src/pages/datasets/[id].tsx index 98ed0c49..efdbc46c 100644 --- a/src/pages/datasets/[id].tsx +++ b/src/pages/datasets/[id].tsx @@ -124,7 +124,10 @@ function PageContent({ datasetId, staticDatasetData }: Props) { const dataset = datasetData.get(realDatasetId); - const { webApi, editMode } = useWebApi(IS_DEPLOYED); + // No override argument. `useWebApi`'s parameter means "allow editing even + // though we're deployed", so passing `IS_DEPLOYED` — as this did — made + // being deployed the one condition that switched edit mode on. + const { webApi, editMode } = useWebApi(); const { updateDatasetProperty } = useUpdateDataset(webApi, realDatasetId); const authors = useMemo(() => { @@ -166,15 +169,17 @@ function PageContent({ datasetId, staticDatasetData }: Props) { if (!dataset) { return ( -
-

Dataset not found

- - Back to datasets - -
+ +
+

Dataset not found

+ + Back to datasets + +
+
); } @@ -383,14 +388,15 @@ function PageContent({ datasetId, staticDatasetData }: Props) { ); } +// `PageContent` renders its own `PageContainer` — including the not-found +// branch — so this must not wrap it in a second one. The container is the whole +// page shell: header, site notice and footer. Nesting rendered all three twice. export default function Page({ datasetId, staticDatasetData }: Props) { return ( - - - + ); } diff --git a/src/pages/models/[id].tsx b/src/pages/models/[id].tsx index 98e452a1..6df4d515 100644 --- a/src/pages/models/[id].tsx +++ b/src/pages/models/[id].tsx @@ -51,7 +51,10 @@ import { } from '../../lib/util'; import { validateModel } from '../../lib/validate-model'; -const MAX_SIMILAR_MODELS = 12 * 2; +// One full row. The card grid is `auto-fill minmax(280px, 1fr)`, which lands on +// four columns at full page width, so four suggestions fill the strip across the +// bottom without leaving a ragged second row. +const MAX_SIMILAR_MODELS = 4; interface Params extends ParsedUrlQuery { id: ModelId; @@ -407,6 +410,8 @@ export default function Page({ return [...collectionData].filter(([, collection]) => collection.models.includes(modelId)).map(([id]) => id); }, [modelId, collectionData]); + const hasRelated = collections.length > 0 || similar.length > 0; + const router = useRouter(); const runModelValidation = useCallback(async () => { @@ -646,44 +651,14 @@ export default function Page({
- - {/* Related models live in this column so it always has - body: most descriptions are short, and the sidebar is - long, which otherwise left a tall void beside it. */} - {collections.length > 0 && ( -
-

- Collections that include this model -

- -
- )} - - {similar.length > 0 && ( -
-

Similar models

- {editMode && similarWithScores.length > 0 && ( -
- Show scores{' '} -
-                                            {similarWithScores
-                                                .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
-                                                .join('\n')}
-                                        
-
- )} - -
- )}
- {/* Right column: Sidebar */} + + {/* Sidebar. Deliberately the second grid child rather than + the last: below `lg` the grid collapses to one column and + renders in DOM order, so anything after this would push + downloads and specs below it. The related-model grids + used to live in the column above, which put two full + card grids ahead of the model's own details on a phone. */}
+ + {/* A full-width strip under both columns rather than more + body for the description column, so the card grids get + the whole page width and read as a footer to the page + instead of a continuation of the article. */} + {hasRelated && ( +
+ {collections.length > 0 && ( +
+

+ Collections that include this model +

+ +
+ )} + + {similar.length > 0 && ( +
+

+ Similar models +

+ {editMode && similarWithScores.length > 0 && ( +
+ Show scores{' '} +
+                                                {similarWithScores
+                                                    .map(({ id, score }) => `${score.toFixed(2).padEnd(6)} ${id}`)
+                                                    .join('\n')}
+                                            
+
+ )} + +
+ )} +
+ )} {editMode && (
diff --git a/tests/components/tag-selector.test.tsx b/tests/components/tag-selector.test.tsx new file mode 100644 index 00000000..2ed8ee97 --- /dev/null +++ b/tests/components/tag-selector.test.tsx @@ -0,0 +1,235 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { TagSelector, TagSelectorStyle } from '../../src/elements/tag-selector'; +import { TagId } from '../../src/lib/schema'; +import { STATIC_TAG_CATEGORY_DATA, STATIC_TAG_DATA } from '../../src/lib/static-data'; +import { SelectionState, TagSelection } from '../../src/lib/tag-condition'; + +/** + * Derived from the real tag data rather than hard-coded, so editing + * data/tags.json does not break these tests. + */ +function tagsOf(categoryId: string): { id: TagId; name: string }[] { + const category = STATIC_TAG_CATEGORY_DATA.get(categoryId as never); + if (!category) throw new Error(`no such tag category: ${categoryId}`); + + return category.tags.map((id) => ({ id, name: STATIC_TAG_DATA.get(id)?.name ?? id })); +} + +const SUBJECT = tagsOf('subject'); +const ARCHITECTURE = tagsOf('architecture'); +const DATASET = tagsOf('dataset'); + +const EMPTY: TagSelection = new Map(); + +function required(...ids: TagId[]): TagSelection { + return new Map(ids.map((id) => [id, SelectionState.Required])); +} + +/** Reports what the selector asked for without re-rendering it. */ +function renderStatic(selection: TagSelection = EMPTY, context?: 'models' | 'datasets') { + const onChange = vi.fn<(selection: TagSelection, style: TagSelectorStyle) => void>(); + + render( + + ); + + return { onChange, user: userEvent.setup() }; +} + +/** Feeds `onChange` back in, so multi-click flows behave like the real page. */ +function renderLive(initial: TagSelection = EMPTY) { + const seen: TagSelection[] = []; + + function Harness() { + const [selection, setSelection] = useState(initial); + + return ( + { + seen.push(next); + setSelection(next); + }} + /> + ); + } + + render(); + + return { seen, user: userEvent.setup() }; +} + +const toAdvanced = (user: ReturnType) => + user.click(screen.getByRole('button', { name: /advanced tag selector/i })); + +describe('TagSelector in simple mode', () => { + it('starts simple, showing everything and no tag chosen', () => { + renderStatic(); + + expect(screen.getByRole('button', { name: /all models/i })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: /advanced tag selector/i })).toBeInTheDocument(); + }); + + it('hides the categories that only make sense in advanced mode', () => { + // Architecture is `simple: false` — 33 buttons that would swamp the + // handful of subject and purpose options. + renderStatic(); + + expect(screen.getByRole('button', { name: SUBJECT[0].name })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: ARCHITECTURE[0].name })).not.toBeInTheDocument(); + }); + + it('requires the tag that was clicked', async () => { + const { onChange, user } = renderStatic(); + + await user.click(screen.getByRole('button', { name: SUBJECT[1].name })); + + expect(onChange).toHaveBeenCalledWith(required(SUBJECT[1].id), 'simple'); + }); + + it('replaces the previous tag rather than adding to it', async () => { + // Simple mode is single-select across every group, not per group. + const { seen, user } = renderLive(required(SUBJECT[0].id)); + + await user.click(screen.getByRole('button', { name: SUBJECT[1].name })); + + expect(seen.at(-1)).toEqual(required(SUBJECT[1].id)); + }); + + it('clears the selection through "All models"', async () => { + const { onChange, user } = renderStatic(required(SUBJECT[0].id)); + + await user.click(screen.getByRole('button', { name: /all models/i })); + + expect(onChange).toHaveBeenCalledWith(EMPTY, 'simple'); + }); + + it('ignores a click on the tag that is already chosen', async () => { + const { onChange, user } = renderStatic(required(SUBJECT[0].id)); + + await user.click(screen.getByRole('button', { name: SUBJECT[0].name })); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('switches itself to advanced for a selection it cannot show', async () => { + // A forbidden tag has no simple representation. Staying simple would + // display a filter that silently disagrees with the results. + render( + + ); + + expect(await screen.findByRole('button', { name: /simple tag selector/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: ARCHITECTURE[0].name })).toBeInTheDocument(); + }); +}); + +describe('TagSelector in advanced mode', () => { + it('reveals the categories simple mode holds back', async () => { + const { user } = renderStatic(); + + await toAdvanced(user); + + expect(screen.getByRole('button', { name: ARCHITECTURE[0].name })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: SUBJECT[0].name })).toBeInTheDocument(); + }); + + it('cycles a tag through required, forbidden and back to any', async () => { + const { seen, user } = renderLive(); + + await toAdvanced(user); + const tag = () => screen.getByRole('button', { name: ARCHITECTURE[0].name }); + + await user.click(tag()); + expect(seen.at(-1)).toEqual(new Map([[ARCHITECTURE[0].id, SelectionState.Required]])); + + await user.click(tag()); + expect(seen.at(-1)).toEqual(new Map([[ARCHITECTURE[0].id, SelectionState.Forbidden]])); + + await user.click(tag()); + expect(seen.at(-1)?.size).toBe(0); + }); + + it('keeps several tags at once, unlike simple mode', async () => { + const { seen, user } = renderLive(); + + await toAdvanced(user); + await user.click(screen.getByRole('button', { name: ARCHITECTURE[0].name })); + await user.click(screen.getByRole('button', { name: ARCHITECTURE[1].name })); + + expect(seen.at(-1)).toEqual(required(ARCHITECTURE[0].id, ARCHITECTURE[1].id)); + }); + + it('marks a required tag as pressed', async () => { + // No mode switch needed: an architecture tag has no simple + // representation, so the selector opens in advanced mode already. + renderStatic(required(ARCHITECTURE[0].id)); + + expect(await screen.findByRole('button', { name: ARCHITECTURE[0].name })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('offers "Clear all tags" only when there is something to clear', async () => { + const { user } = renderStatic(); + + await toAdvanced(user); + + expect(screen.getByRole('button', { name: /clear all tags/i })).toBeDisabled(); + }); + + it('clears everything at once', async () => { + // Opens in advanced mode, as above. + const { onChange, user } = renderStatic(required(ARCHITECTURE[0].id, SUBJECT[0].id)); + + await user.click(await screen.findByRole('button', { name: /clear all tags/i })); + + expect(onChange).toHaveBeenCalledWith(EMPTY, 'simple'); + }); + + it('groups each category into a single spacing container', async () => { + // jsdom does no layout, so the gap itself is unmeasurable here. What is + // checkable is the thing that was actually missing: the buttons sat in + // a bare
, and since they are inline-flex and JSX drops the + // whitespace between them, a whole category rendered as one unbroken + // strip of touching pills. + const { user } = renderStatic(); + + await toAdvanced(user); + const button = screen.getByRole('button', { name: ARCHITECTURE[0].name }); + const container = button.parentElement; + + expect(container).toHaveClass('advancedTags'); + expect(within(container as HTMLElement).getAllByRole('button').length).toBe(ARCHITECTURE.length); + }); +}); + +describe('TagSelector on the datasets page', () => { + it('shows dataset tags and nothing else', () => { + renderStatic(EMPTY, 'datasets'); + + expect(screen.getByRole('button', { name: /all datasets/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: DATASET[0].name })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: SUBJECT[0].name })).not.toBeInTheDocument(); + }); + + it('keeps model categories out of advanced mode too', async () => { + const { user } = renderStatic(EMPTY, 'datasets'); + + await toAdvanced(user); + + expect(screen.getByRole('button', { name: DATASET[0].name })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: ARCHITECTURE[0].name })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/lib/edit-mode-guard.test.ts b/tests/lib/edit-mode-guard.test.ts new file mode 100644 index 00000000..714e591c --- /dev/null +++ b/tests/lib/edit-mode-guard.test.ts @@ -0,0 +1,103 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Rendering every page against the deployed origin is not practical, and it + * would only ever cover the pages that exist today. This scans the source + * instead, so a page added next month is covered the moment it is written. + * + * The rule being enforced: `useWebApi`'s argument means "allow editing even + * though we are deployed". Passing anything truthy is therefore a deliberate + * decision that has to be justified here, not something a page picks up by + * copying its neighbour — which is exactly how the dataset page ended up + * calling `useWebApi(IS_DEPLOYED)` and unlocking editing on the live site. + */ + +const SRC = join(__dirname, '..', '..', 'src'); + +/** + * Call sites allowed to unlock edit mode on the deployed site, and why. + * + * Both entries are the "propose a contribution" flow: they build a model or + * dataset that does not exist yet and render it editably so a contributor can + * check it over and open a GitHub issue. On the deployed site `getWebApi` + * returns session-storage-backed collections, so these edit a local scratch + * copy and cannot reach the real database. + */ +const ALLOWED = new Map([ + ['pages/add-model.tsx', 'IS_DEPLOYED'], + ['pages/add-dataset.tsx', 'IS_DEPLOYED'], + // Threaded from a prop that only `OMDB_ADDMODEL_DUMMY` sets. + ['pages/models/[id].tsx', 'editModeOverride'], +]); + +/** Where `useWebApi` is declared — its signature is not a call site. */ +const DEFINITION = 'lib/hooks/use-web-api.tsx'; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); +} + +interface CallSite { + file: string; + argument: string; +} + +function callSites(): CallSite[] { + const found: CallSite[] = []; + + for (const file of sourceFiles(SRC)) { + const id = relative(SRC, file).split(sep).join('/'); + if (id === DEFINITION) continue; + + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(/\buseWebApi\(([^)]*)\)/g)) { + found.push({ file: id, argument: match[1].trim() }); + } + } + + return found; +} + +describe('edit mode on the deployed site', () => { + it('finds the call sites at all', () => { + // Guards the guard: a regex that silently matches nothing would make + // every assertion below pass without checking anything. + const sites = callSites(); + + expect(sites.length).toBeGreaterThan(5); + expect(sites.some(({ file }) => file === 'pages/models/[id].tsx')).toBe(true); + }); + + it('is not unlocked by any page outside the documented exceptions', () => { + const offenders = callSites() + .filter(({ argument }) => argument !== '') + .filter(({ file, argument }) => ALLOWED.get(file) !== argument); + + expect(offenders).toEqual([]); + }); + + it('keeps the dataset page read-only', () => { + // The regression this suite was written for. + const sites = callSites().filter(({ file }) => file === 'pages/datasets/[id].tsx'); + + expect(sites).not.toEqual([]); + for (const { argument } of sites) { + expect(argument).toBe(''); + } + }); + + it('never passes IS_DEPLOYED from a page that renders existing content', () => { + // `useWebApi(IS_DEPLOYED)` reads as "unlock when deployed", which is + // only ever right for the add-* flows. On a page that displays + // something already in the database it is always a bug. + const offenders = callSites().filter(({ file, argument }) => argument === 'IS_DEPLOYED' && !ALLOWED.has(file)); + + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/lib/use-web-api.test.tsx b/tests/lib/use-web-api.test.tsx new file mode 100644 index 00000000..68828819 --- /dev/null +++ b/tests/lib/use-web-api.test.tsx @@ -0,0 +1,147 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * `IS_DEPLOYED` is a module-level constant, so each case has to load + * `use-web-api` against a freshly mocked `site-data`. + */ +async function load(isDeployed: boolean) { + vi.resetModules(); + + const getWebApi = vi.fn(() => Promise.resolve({ tags: { update: vi.fn(() => Promise.resolve()) } })); + + vi.doMock('../../src/lib/site-data', () => ({ + IS_DEPLOYED: isDeployed, + SITE_URL: 'https://openmodeldb.info', + ADSENSE_PUBLISHER_ID: 'test', + })); + vi.doMock('../../src/lib/web-api', () => ({ + getWebApi, + startListeningForUpdates: vi.fn(), + addUpdateListener: vi.fn(() => vi.fn()), + })); + + return { ...(await import('../../src/lib/hooks/use-web-api')), getWebApi }; +} + +/** + * Await the exact promise the provider awaited, so assertions run against the + * settled state. + * + * Without this, `webApi` is still undefined and `editMode` still false on the + * first tick — which is also what a correct deployed build looks like — so a + * `waitFor` would pass before the api ever arrived and the test would hold + * even with the deployment check deleted. + */ +async function settle(getWebApi: { mock: { results: { value: unknown }[] } }) { + await act(async () => { + await getWebApi.mock.results[0]?.value; + }); +} + +afterEach(() => { + vi.doUnmock('../../src/lib/site-data'); + vi.doUnmock('../../src/lib/web-api'); +}); + +describe('useWebApi when deployed', () => { + it('refuses edit mode to a caller that does not override', async () => { + const { WebApiProvider, useWebApi, getWebApi } = await load(true); + + // Both callers in one render, against one loaded api. The overriding + // one is the control: it proves the api really did arrive, so the + // plain one staying locked is a refusal rather than a slow start. + const { result } = renderHook(() => ({ plain: useWebApi(), overriding: useWebApi(true) }), { + wrapper: WebApiProvider, + }); + await settle(getWebApi); + + expect(result.current.overriding.editMode).toBe(true); + expect(result.current.plain.editMode).toBe(false); + }); + + it('withholds the api itself, not just the flag', async () => { + // Anything that slipped past the flag still has nothing to write with. + const { WebApiProvider, useWebApi, getWebApi } = await load(true); + + const { result } = renderHook(() => ({ plain: useWebApi(), overriding: useWebApi(true) }), { + wrapper: WebApiProvider, + }); + await settle(getWebApi); + + expect(result.current.overriding.webApi).toBeDefined(); + expect(result.current.plain.webApi).toBeUndefined(); + }); + + it('allows edit mode only for a caller that explicitly overrides', async () => { + // The add-model / add-dataset flow. On the deployed site `getWebApi` + // hands back session-storage-backed collections, so this edits a local + // scratch copy and never reaches the real database. + const { WebApiProvider, useWebApi, getWebApi } = await load(true); + + const { result } = renderHook(() => useWebApi(true), { wrapper: WebApiProvider }); + await settle(getWebApi); + + expect(result.current.editMode).toBe(true); + }); + + it('never offers the header toggle', async () => { + const { WebApiProvider, useWebApi, useEditModeToggle, getWebApi } = await load(true); + + const { result } = renderHook(() => ({ toggle: useEditModeToggle(), control: useWebApi(true) }), { + wrapper: WebApiProvider, + }); + await settle(getWebApi); + + expect(result.current.control.editMode).toBe(true); + expect(result.current.toggle.editModeAvailable).toBe(false); + expect(result.current.toggle.editMode).toBe(false); + }); + + it('stays read-only however the toggle is driven', async () => { + // `toggleEditMode` flips the shared `enabled` flag, which is also what + // `useWebApi` reads. Being deployed has to win either way round. + // + // Twice, not once: `enabled` starts true, so a single toggle only ever + // turns editing *off* and would pass even with the deployment check + // gone. The second toggle is the one that asks the real question. + const { WebApiProvider, useWebApi, useEditModeToggle, getWebApi } = await load(true); + + const { result } = renderHook(() => ({ toggle: useEditModeToggle(), page: useWebApi() }), { + wrapper: WebApiProvider, + }); + await settle(getWebApi); + + for (let i = 0; i < 2; i++) { + act(() => result.current.toggle.toggleEditMode()); + + expect(result.current.toggle.editMode).toBe(false); + expect(result.current.page.editMode).toBe(false); + expect(result.current.page.webApi).toBeUndefined(); + } + }); +}); + +describe('useWebApi when running locally', () => { + it('enables edit mode without any override', async () => { + // The positive control for the suite above: same harness, same mocked + // api, only `IS_DEPLOYED` differs. + const { WebApiProvider, useWebApi, getWebApi } = await load(false); + + const { result } = renderHook(() => useWebApi(), { wrapper: WebApiProvider }); + await settle(getWebApi); + + expect(result.current.editMode).toBe(true); + expect(result.current.webApi).toBeDefined(); + }); + + it('offers the header toggle', async () => { + const { WebApiProvider, useEditModeToggle, getWebApi } = await load(false); + + const { result } = renderHook(() => useEditModeToggle(), { wrapper: WebApiProvider }); + await settle(getWebApi); + + expect(result.current.editModeAvailable).toBe(true); + expect(result.current.editMode).toBe(true); + }); +}); diff --git a/tests/pages/dataset-page.test.tsx b/tests/pages/dataset-page.test.tsx new file mode 100644 index 00000000..5335e661 --- /dev/null +++ b/tests/pages/dataset-page.test.tsx @@ -0,0 +1,101 @@ +/** + * `IS_DEPLOYED` is a module-level constant read from `location.host` at import + * time, so the only way to exercise the deployed build is to give the whole + * file the deployed origin before anything is imported. + * + * @vitest-environment-options { "url": "https://openmodeldb.info/" } + */ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebApiProvider } from '../../src/lib/hooks/use-web-api'; +import { Dataset, DatasetId } from '../../src/lib/schema'; + +vi.mock('next/router', () => ({ + useRouter: () => ({ push: vi.fn(), query: {}, asPath: '/', isReady: true, events: { on: vi.fn(), off: vi.fn() } }), +})); + +const fixture = vi.hoisted(() => ({ + datasetId: 'div2k', + dataset: { + name: 'DIV2K', + author: [], + license: null, + tags: [], + description: 'A dataset.', + date: '2024-01-01', + url: 'https://example.com/div2k', + images: [], + }, +})); + +// The real one talks to `/api/*` over fetch. The page only cares that *some* +// api resolved, which is precisely the condition that used to unlock editing. +// `useDatasets` overwrites its static props with whatever this returns, so the +// datasets collection has to actually contain the fixture. +vi.mock('../../src/lib/web-api', () => { + const collection = (entries: [string, unknown][] = []) => ({ + get: vi.fn(), + getAll: vi.fn(() => Promise.resolve(new Map(entries))), + update: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + changeId: vi.fn(() => Promise.resolve()), + }); + + return { + getWebApi: vi.fn(() => + Promise.resolve({ + models: collection(), + users: collection(), + tags: collection(), + tagCategories: collection(), + architectures: collection(), + collections: collection(), + datasets: collection([[fixture.datasetId, fixture.dataset]]), + }) + ), + startListeningForUpdates: vi.fn(), + addUpdateListener: vi.fn(() => vi.fn()), + }; +}); + +const datasetId = fixture.datasetId as DatasetId; +const dataset = fixture.dataset as unknown as Dataset; + +async function renderDatasetPage() { + const { default: Page } = await import('../../src/pages/datasets/[id]'); + + return render( + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('dataset page on the deployed site', () => { + it('stays read-only', async () => { + // The page used to pass `IS_DEPLOYED` as `useWebApi`'s override, which + // reads as "allow editing despite deployment" — so being deployed was + // the very thing that turned edit mode on. + await renderDatasetPage(); + + expect(await screen.findByText('DIV2K')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /delete dataset/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /clipboard/i })).not.toBeInTheDocument(); + }); + + it('shows the site notice exactly once', async () => { + // The page nested two `PageContainer`s, and the notice lives in the + // shell, so every dataset page rendered the whole shell twice. + await renderDatasetPage(); + + expect(await screen.findByText('DIV2K')).toBeInTheDocument(); + expect(screen.getAllByRole('status')).toHaveLength(1); + }); +}); diff --git a/tests/pages/model-page.test.tsx b/tests/pages/model-page.test.tsx new file mode 100644 index 00000000..5e236a3d --- /dev/null +++ b/tests/pages/model-page.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { WebApiProvider } from '../../src/lib/hooks/use-web-api'; +import { Model, ModelId } from '../../src/lib/schema'; + +vi.mock('next/router', () => ({ + useRouter: () => ({ push: vi.fn(), query: {}, asPath: '/', isReady: true, events: { on: vi.fn(), off: vi.fn() } }), +})); + +const fixture = vi.hoisted(() => { + const model = (name: string) => ({ + name, + author: [], + license: null, + tags: [], + description: 'A model.', + date: '2024-01-01', + architecture: 'esrgan', + size: null, + scale: 4, + inputChannels: 3, + outputChannels: 3, + resources: [], + images: [], + }); + + return { main: ['4x-Main', model('4x-Main')], other: ['4x-Other', model('4x-Other')] } as const; +}); + +vi.mock('../../src/lib/web-api', () => { + const collection = (entries: readonly (readonly [string, unknown])[] = []) => ({ + get: vi.fn(), + getAll: vi.fn(() => Promise.resolve(new Map(entries))), + update: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + changeId: vi.fn(() => Promise.resolve()), + }); + + return { + getWebApi: vi.fn(() => + Promise.resolve({ + models: collection([fixture.main, fixture.other]), + users: collection(), + tags: collection(), + tagCategories: collection(), + architectures: collection(), + collections: collection(), + datasets: collection(), + }) + ), + startListeningForUpdates: vi.fn(), + addUpdateListener: vi.fn(() => vi.fn()), + }; +}); + +const mainId = fixture.main[0] as ModelId; +const otherId = fixture.other[0] as ModelId; + +async function renderModelPage() { + const { default: Page } = await import('../../src/pages/models/[id]'); + + return render( + + + } + staticSimilar={[otherId]} + /> + + ); +} + +describe('model page layout', () => { + it('puts the model details ahead of the related-model grids', async () => { + // Below `lg` the page grid collapses to a single column and renders in + // DOM order, so this ordering *is* the mobile layout: downloads and + // specs have to come before "Similar models", not after it. + await renderModelPage(); + + const specs = await screen.findByText('Architecture'); + const similar = await screen.findByText('Similar models'); + + expect(specs.compareDocumentPosition(similar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index fe372fee..1f304d0c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,11 @@ export default defineConfig({ // The app imports SVGs as React components (see the `@svgr/webpack` rule in // next.config.js). Without the equivalent here, anything that renders the // header or a download button fails on the logo import. - plugins: [svgr()], + // + // `include` is required: the plugin only claims `*.svg?react` by default, + // and the app imports plain `*.svg`. Without it the import resolves to the + // asset URL and React tries to render the string as a component. + plugins: [svgr({ include: '**/*.svg' })], // tsconfig says `jsx: preserve` because Next does its own transform, and // Vite honours that — which leaves raw JSX in the output and every .tsx