open-design/apps/web/tests/components/plugins-home-section.test.tsx

327 lines
12 KiB
TypeScript

// @vitest-environment jsdom
// Plugins home section — UI contract.
//
// The section renders artifact-kind filters for the starter grid:
// Prototype / Live Artifact / Slides / Image / Video / HyperFrames / Audio.
// Prototype, Slides, Image, and Video expose a second row of scene buckets;
// the smaller Live Artifact, HyperFrames, and Audio slices stay flat. Saved is an
// orthogonal user collection override, and sparse buckets should fall
// back to the normal empty-filter state rather than rendering synthetic
// cards.
import { describe, expect, it, afterEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import type { ComponentProps } from 'react';
import { PluginsHomeSection } from '../../src/components/PluginsHomeSection';
import { I18nProvider } from '../../src/i18n';
function makePlugin(overrides: {
id: string;
title?: string;
titleI18n?: Record<string, string>;
description?: string;
descriptionI18n?: Record<string, string>;
tags?: string[];
featured?: boolean;
mode?: string;
kind?: 'scenario' | 'atom';
}): InstalledPluginRecord {
return {
id: overrides.id,
title: overrides.title ?? overrides.id,
version: '0.1.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: overrides.id,
version: '0.1.0',
title: overrides.title ?? overrides.id,
...(overrides.titleI18n ? { title_i18n: overrides.titleI18n } : {}),
...(overrides.description ? { description: overrides.description } : {}),
...(overrides.descriptionI18n ? { description_i18n: overrides.descriptionI18n } : {}),
...(overrides.tags ? { tags: overrides.tags } : {}),
od: {
kind: overrides.kind ?? 'scenario',
...(overrides.mode ? { mode: overrides.mode } : {}),
...(overrides.featured ? { featured: true } : {}),
},
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
function renderSection(
plugins: InstalledPluginRecord[] = sample,
props: Partial<ComponentProps<typeof PluginsHomeSection>> = {},
) {
return render(
<PluginsHomeSection
plugins={plugins}
loading={false}
activePluginId={null}
pendingApplyId={null}
onUse={() => {}}
onOpenDetails={() => {}}
{...props}
/>,
);
}
function renderSectionInChinese(
plugins: InstalledPluginRecord[] = sample,
props: Partial<ComponentProps<typeof PluginsHomeSection>> = {},
) {
return render(
<I18nProvider initial="zh-CN">
<PluginsHomeSection
plugins={plugins}
loading={false}
activePluginId={null}
pendingApplyId={null}
onUse={() => {}}
onOpenDetails={() => {}}
{...props}
/>
</I18nProvider>,
);
}
function pluginIds(): Array<string | null> {
return within(screen.getByRole('list'))
.getAllByRole('listitem')
.map((i) => i.getAttribute('data-plugin-id'));
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.localStorage.clear();
});
const sample: InstalledPluginRecord[] = [
makePlugin({ id: 'prototype-dashboard', mode: 'prototype', tags: ['dashboard'] }),
makePlugin({ id: 'prototype-app', mode: 'prototype', tags: ['mobile-app'] }),
makePlugin({ id: 'example-live-dashboard', mode: 'prototype', tags: ['live-dashboard'] }),
makePlugin({
id: 'image-template-notion-team-dashboard-live-artifact',
mode: 'image',
tags: ['live-artifact'],
}),
makePlugin({
id: 'example-social-media-matrix-tracker-template',
mode: 'template',
tags: ['live-artifacts'],
}),
makePlugin({
id: 'example-trading-analysis-dashboard-template',
mode: 'template',
tags: ['live-artifacts'],
}),
makePlugin({ id: 'example-live-artifact', mode: 'prototype', tags: ['live-artifact'] }),
makePlugin({ id: 'deck-pitch', mode: 'deck', tags: ['pitch-deck'], featured: true }),
makePlugin({ id: 'image-logo', mode: 'image', tags: ['logo'] }),
makePlugin({ id: 'video-short', mode: 'video', tags: ['short-form'] }),
makePlugin({ id: 'video-cinematic', mode: 'video', tags: ['cinematic'] }),
makePlugin({ id: 'hyperframes-composition', mode: 'video', tags: ['hyperframes'] }),
makePlugin({ id: 'audio-voice', mode: 'audio' }),
makePlugin({ id: 'hidden-atom', mode: 'prototype', tags: ['dashboard'], kind: 'atom' }),
];
describe('PluginsHomeSection (category bar)', () => {
it('frames the home shelf as community and can jump to registry', () => {
const onBrowseRegistry = vi.fn();
renderSection(sample, { onBrowseRegistry });
expect(screen.getByText('Community')).toBeTruthy();
fireEvent.click(screen.getByTestId('plugins-home-browse-registry'));
expect(onBrowseRegistry).toHaveBeenCalledTimes(1);
});
it('renders the artifact category row and the default Prototype scene row', () => {
renderSection();
expect(screen.getByTestId('plugins-home-row-category')).toBeTruthy();
expect(screen.getByTestId('plugins-home-chip-saved').textContent).toContain('Saved');
expect(screen.getByTestId('plugins-home-pill-category-all')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-category-prototype')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-category-live-artifact')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-category-deck')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-category-image')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-category-video')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-category-hyperframes')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-category-audio')).toBeTruthy();
expect(screen.queryByTestId('plugins-home-pill-category-import')).toBeNull();
expect(screen.queryByTestId('plugins-home-pill-category-create')).toBeNull();
expect(screen.queryByTestId('plugins-home-pill-category-export')).toBeNull();
expect(screen.getByTestId('plugins-home-row-subcategory-prototype')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-subcategory-prototype-business-dashboards')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-subcategory-prototype-app-prototypes')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-subcategory-prototype-developer-tools')).toBeTruthy();
});
it('filters Video separately from HyperFrames', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-video'));
expect(pluginIds().sort()).toEqual(['video-cinematic', 'video-short']);
expect(screen.getByTestId('plugins-home-row-subcategory-video')).toBeTruthy();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-hyperframes'));
expect(pluginIds()).toEqual(['hyperframes-composition']);
expect(screen.queryByTestId('plugins-home-row-subcategory-hyperframes')).toBeNull();
});
it('groups Live Artifact as its own flat Community category', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-live-artifact'));
expect(pluginIds()).toEqual([
'example-live-dashboard',
'image-template-notion-team-dashboard-live-artifact',
'example-social-media-matrix-tracker-template',
'example-trading-analysis-dashboard-template',
'example-live-artifact',
]);
expect(screen.queryByTestId('plugins-home-row-subcategory-live-artifact')).toBeNull();
});
it('keeps sparse subcategories as real filters without adding contribution cards', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-video'));
fireEvent.click(screen.getByTestId('plugins-home-pill-subcategory-video-social-short-form'));
expect(pluginIds()).toEqual(['video-short']);
expect(screen.queryByTestId('plugins-home-contribution-card')).toBeNull();
expect(screen.queryByText(/Contribute a/i)).toBeNull();
});
it('saves a plugin, updates the Saved chip, and shows a toast', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-save-prototype-dashboard'));
expect(screen.getByTestId('plugins-home-save-prototype-dashboard').textContent).toContain('Saved');
expect(screen.getByTestId('plugins-home-chip-saved').textContent).toContain('1');
expect(screen.getByRole('status').textContent).toContain('Saved prototype-dashboard.');
fireEvent.click(screen.getByTestId('plugins-home-chip-saved'));
expect(pluginIds()).toEqual(['prototype-dashboard']);
});
it('localizes plugin card titles, descriptions, search, and save toast', () => {
renderSectionInChinese([
makePlugin({
id: 'localized-deck',
title: 'Swiss International Deck',
titleI18n: { en: 'Swiss International Deck', 'zh-CN': '瑞士国际主义 Deck' },
description: '16-column grid.',
descriptionI18n: { en: '16-column grid.', 'zh-CN': '16 列网格。' },
mode: 'deck',
tags: ['grid'],
}),
], { preferDefaultFacet: false });
expect(screen.getAllByText('瑞士国际主义 Deck').length).toBeGreaterThan(0);
expect(screen.queryByText('Swiss International Deck')).toBeNull();
fireEvent.change(screen.getByPlaceholderText('搜索插件…'), {
target: { value: '瑞士' },
});
expect(pluginIds()).toEqual(['localized-deck']);
fireEvent.click(screen.getByTestId('plugins-home-save-localized-deck'));
expect(screen.getByRole('status').textContent).toContain('Saved 瑞士国际主义 Deck.');
});
it('shows the normal empty-filter state for planned empty buckets', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-video'));
fireEvent.click(screen.getByTestId('plugins-home-pill-subcategory-video-data-explainers'));
expect(screen.queryByRole('list')).toBeNull();
expect(screen.getByText(/No plugins match the current filters/i)).toBeTruthy();
expect(screen.queryByTestId('plugins-home-contribution-card')).toBeNull();
});
it('keeps HyperFrames and Audio flat', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-hyperframes'));
expect(pluginIds()).toEqual(['hyperframes-composition']);
expect(screen.queryByTestId('plugins-home-row-subcategory-hyperframes')).toBeNull();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-audio'));
expect(pluginIds()).toEqual(['audio-voice']);
expect(screen.queryByTestId('plugins-home-row-subcategory-audio')).toBeNull();
});
it('All pill clears the category filter and only shows user-facing plugins', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-pill-category-all'));
expect(pluginIds().sort()).toEqual([
'audio-voice',
'deck-pitch',
'example-live-artifact',
'example-live-dashboard',
'example-social-media-matrix-tracker-template',
'example-trading-analysis-dashboard-template',
'hyperframes-composition',
'image-logo',
'image-template-notion-team-dashboard-live-artifact',
'prototype-app',
'prototype-dashboard',
'video-cinematic',
'video-short',
]);
});
it('Saved chip overrides the category selection and shows only saved plugins', () => {
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-save-prototype-dashboard'));
fireEvent.click(screen.getByTestId('plugins-home-pill-category-video'));
fireEvent.click(screen.getByTestId('plugins-home-chip-saved'));
expect(pluginIds()).toEqual(['prototype-dashboard']);
});
it('Clear filters from the Saved empty state escapes Saved mode back to the full catalog', () => {
// Fresh browser, no saved plugins yet. Clicking Saved lands the
// user on the empty filter state — the recovery CTA must take
// them all the way back to the catalog, not just re-render the
// same Saved empty view.
renderSection();
fireEvent.click(screen.getByTestId('plugins-home-chip-saved'));
expect(screen.queryByRole('list')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /Clear filters/i }));
expect(pluginIds().sort()).toEqual([
'audio-voice',
'deck-pitch',
'example-live-artifact',
'example-live-dashboard',
'example-social-media-matrix-tracker-template',
'example-trading-analysis-dashboard-template',
'hyperframes-composition',
'image-logo',
'image-template-notion-team-dashboard-live-artifact',
'prototype-app',
'prototype-dashboard',
'video-cinematic',
'video-short',
]);
});
});