mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Stage B of plugin-driven-flow-plan — Home intent tabs / shortcuts.
|
|
// Covers:
|
|
// - Every chip in the catalog renders with its test id.
|
|
// - Clicking a chip forwards the full chip descriptor to onPickChip
|
|
// so the dispatcher in HomeView can route to the right flow.
|
|
// - The active + pending UI states light up the right chip and
|
|
// disable all chips while a plugin is mid-apply.
|
|
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { useState } from 'react';
|
|
import type { InstalledPluginRecord } from '@open-design/contracts';
|
|
|
|
import { HomeHero } from '../../src/components/HomeHero';
|
|
import {
|
|
HOME_HERO_CHIPS,
|
|
findChip,
|
|
} from '../../src/components/home-hero/chips';
|
|
import { I18nProvider, type Locale } from '../../src/i18n';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
function makePlugin(
|
|
id: string,
|
|
mode: string,
|
|
title = id,
|
|
extraTags: string[] = [],
|
|
options: { query?: string | null } = {},
|
|
): InstalledPluginRecord {
|
|
return {
|
|
id,
|
|
title,
|
|
version: '1.0.0',
|
|
sourceKind: 'bundled',
|
|
source: '/tmp',
|
|
trust: 'bundled',
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
manifest: {
|
|
name: id,
|
|
version: '1.0.0',
|
|
title,
|
|
description: 'Plugin preset fixture',
|
|
tags: [mode, ...extraTags],
|
|
od: {
|
|
mode,
|
|
useCase: {
|
|
...(options.query !== null
|
|
? { query: options.query ?? `Create with {{topic}} using ${title}` }
|
|
: {}),
|
|
},
|
|
inputs: [
|
|
{
|
|
name: 'topic',
|
|
label: 'Topic',
|
|
type: 'text',
|
|
default: 'a focused brief',
|
|
},
|
|
],
|
|
preview: { type: 'image', poster: '/preview.png' },
|
|
},
|
|
},
|
|
fsPath: '/tmp',
|
|
installedAt: 0,
|
|
updatedAt: 0,
|
|
};
|
|
}
|
|
|
|
function renderHero(
|
|
overrides: Partial<React.ComponentProps<typeof HomeHero>> = {},
|
|
locale: Locale = 'en',
|
|
) {
|
|
const onPickChip = vi.fn();
|
|
const onPickPlugin = vi.fn();
|
|
const onPickExamplePlugin = vi.fn();
|
|
const onClearActiveChip = vi.fn();
|
|
render(
|
|
<I18nProvider initial={locale}>
|
|
<HomeHero
|
|
prompt=""
|
|
onPromptChange={() => undefined}
|
|
onSubmit={() => undefined}
|
|
activePluginTitle={null}
|
|
activeChipId={null}
|
|
onClearActivePlugin={() => undefined}
|
|
pluginOptions={[]}
|
|
pluginsLoading={false}
|
|
pendingPluginId={null}
|
|
pendingChipId={null}
|
|
onPickPlugin={onPickPlugin}
|
|
onPickExamplePlugin={onPickExamplePlugin}
|
|
onPickChip={onPickChip}
|
|
onClearActiveChip={onClearActiveChip}
|
|
contextItemCount={0}
|
|
error={null}
|
|
{...overrides}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
return { onPickChip, onPickPlugin, onPickExamplePlugin, onClearActiveChip };
|
|
}
|
|
|
|
describe('HomeHero intent rail', () => {
|
|
it('renders creation chips as composer tabs and collapses shortcuts behind More', () => {
|
|
renderHero();
|
|
const tabs = screen.getByTestId('home-hero-type-tabs');
|
|
for (const chip of HOME_HERO_CHIPS) {
|
|
if (chip.group === 'create') {
|
|
const node = screen.getByTestId(`home-hero-rail-${chip.id}`);
|
|
expect(node).toBeTruthy();
|
|
expect(tabs.contains(node)).toBe(true);
|
|
} else {
|
|
expect(screen.queryByTestId(`home-hero-rail-${chip.id}`)).toBeNull();
|
|
}
|
|
}
|
|
fireEvent.click(screen.getByTestId('home-hero-shortcuts-trigger'));
|
|
const menu = screen.getByTestId('home-hero-shortcuts-menu');
|
|
for (const chip of HOME_HERO_CHIPS.filter((item) => item.group === 'migrate')) {
|
|
const node = screen.getByTestId(`home-hero-rail-${chip.id}`);
|
|
expect(node).toBeTruthy();
|
|
expect(menu.contains(node)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('forwards the matching chip descriptor when clicked', () => {
|
|
const { onPickChip } = renderHero();
|
|
fireEvent.click(screen.getByTestId('home-hero-rail-image'));
|
|
expect(onPickChip).toHaveBeenCalledTimes(1);
|
|
expect(onPickChip).toHaveBeenCalledWith(findChip('image'));
|
|
});
|
|
|
|
it('moves the active creation chip into the composer and hides the tab row', () => {
|
|
renderHero({ activeChipId: 'video' });
|
|
expect(screen.queryByTestId('home-hero-type-tabs')).toBeNull();
|
|
expect(screen.queryByTestId('home-hero-rail-video')).toBeNull();
|
|
const node = screen.getByTestId('home-hero-active-type-chip');
|
|
expect(node.getAttribute('data-chip-id')).toBe('video');
|
|
expect(node.textContent).toContain('Video');
|
|
});
|
|
|
|
it('lets the active creation chip be removed from the composer', () => {
|
|
const { onClearActiveChip } = renderHero({ activeChipId: 'prototype' });
|
|
fireEvent.click(screen.getByTestId('home-hero-active-type-chip'));
|
|
expect(onClearActiveChip).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses the active creation chip as the only clear control for a chip-bound plugin', () => {
|
|
const activePlugin = makePlugin('example-image-a', 'image', 'Product image');
|
|
renderHero({
|
|
activeChipId: 'image',
|
|
activePluginTitle: 'Product image',
|
|
activePluginRecord: activePlugin,
|
|
showActivePluginChip: true,
|
|
});
|
|
|
|
expect(screen.getByTestId('home-hero-active-plugin')).toBeTruthy();
|
|
expect(screen.getByTestId('home-hero-active-type-chip')).toBeTruthy();
|
|
expect(screen.queryByLabelText('Clear active plugin')).toBeNull();
|
|
});
|
|
|
|
it('keeps the active plugin clear control when no creation chip is active', () => {
|
|
const activePlugin = makePlugin('example-image-a', 'image', 'Product image');
|
|
const onClearActivePlugin = vi.fn();
|
|
renderHero({
|
|
activeChipId: null,
|
|
activePluginTitle: 'Product image',
|
|
activePluginRecord: activePlugin,
|
|
onClearActivePlugin,
|
|
showActivePluginChip: true,
|
|
});
|
|
|
|
const clear = screen.getByLabelText('Clear active plugin');
|
|
fireEvent.click(clear);
|
|
|
|
expect(onClearActivePlugin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('shows prompt examples below the composer for the selected tab', () => {
|
|
const onPromptChange = vi.fn();
|
|
renderHero({ activeChipId: 'deck', onPromptChange });
|
|
|
|
expect(screen.getByTestId('home-hero-prompt-examples')).toBeTruthy();
|
|
const examples = screen.getAllByTestId('home-hero-prompt-example');
|
|
expect(examples).toHaveLength(4);
|
|
|
|
fireEvent.click(examples[0]!);
|
|
expect(onPromptChange).toHaveBeenCalledWith(
|
|
'Research the market opportunity for a product launch, including competitors, target users, pricing hypotheses, and launch narrative',
|
|
);
|
|
expect(screen.getByTestId('home-hero-active-example').textContent).toContain('Example prompts: Research the market opportunity');
|
|
expect(screen.getByTestId('home-hero-active-example').textContent).toContain('...');
|
|
});
|
|
|
|
it('clears the prompt input when the selected example chip is removed', () => {
|
|
function StatefulHero() {
|
|
const [prompt, setPrompt] = useState('');
|
|
return (
|
|
<HomeHero
|
|
prompt={prompt}
|
|
onPromptChange={setPrompt}
|
|
onSubmit={() => undefined}
|
|
activePluginTitle={null}
|
|
activeChipId="deck"
|
|
onClearActivePlugin={() => undefined}
|
|
pluginOptions={[]}
|
|
pluginsLoading={false}
|
|
pendingPluginId={null}
|
|
pendingChipId={null}
|
|
onPickPlugin={() => undefined}
|
|
onPickExamplePlugin={() => undefined}
|
|
onPickChip={() => undefined}
|
|
onClearActiveChip={() => undefined}
|
|
contextItemCount={0}
|
|
error={null}
|
|
/>
|
|
);
|
|
}
|
|
|
|
render(<StatefulHero />);
|
|
|
|
const examples = screen.getAllByTestId('home-hero-prompt-example');
|
|
fireEvent.click(examples[0]!);
|
|
|
|
const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement;
|
|
expect(input.value).toContain('Research the market opportunity');
|
|
expect(screen.getByTestId('home-hero-active-example')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByTestId('home-hero-active-example').querySelector('.home-hero__active-clear')!);
|
|
|
|
expect(input.value).toBe('');
|
|
expect(screen.queryByTestId('home-hero-active-example')).toBeNull();
|
|
});
|
|
|
|
it('shows matching plugin presets in the example prompt area for the selected tab', () => {
|
|
const deckPlugin = makePlugin('example-deck-a', 'deck', 'Investor deck');
|
|
const imagePlugin = makePlugin('example-image-a', 'image', 'Product image');
|
|
const { onPickExamplePlugin } = renderHero({
|
|
activeChipId: 'deck',
|
|
pluginOptions: [deckPlugin, imagePlugin],
|
|
});
|
|
|
|
const presets = screen.getAllByTestId('home-hero-plugin-preset');
|
|
expect(presets).toHaveLength(1);
|
|
expect(presets[0]?.textContent).toContain('Investor deck');
|
|
expect(presets[0]?.textContent).toContain('a focused brief');
|
|
|
|
fireEvent.click(presets[0]!);
|
|
expect(onPickExamplePlugin).toHaveBeenCalledWith(
|
|
deckPlugin,
|
|
'deck',
|
|
'Create with a focused brief using Investor deck',
|
|
);
|
|
expect(screen.getByTestId('home-hero-active-example').textContent).toContain('Example prompts: Investor deck');
|
|
});
|
|
|
|
it('orders curated example presets first for the selected artifact type', () => {
|
|
const ordinaryDeck = makePlugin('example-ordinary-deck', 'deck', 'Ordinary deck');
|
|
const capsule = makePlugin(
|
|
'example-html-ppt-zhangzara-capsule',
|
|
'deck',
|
|
'Html Ppt Zhangzara Capsule',
|
|
);
|
|
const creativeMode = makePlugin(
|
|
'example-html-ppt-zhangzara-creative-mode',
|
|
'deck',
|
|
'Html Ppt Zhangzara Creative Mode',
|
|
);
|
|
renderHero({
|
|
activeChipId: 'deck',
|
|
pluginOptions: [ordinaryDeck, capsule, creativeMode],
|
|
});
|
|
|
|
const presets = screen.getAllByTestId('home-hero-plugin-preset');
|
|
expect(presets.map((preset) => preset.getAttribute('data-plugin-id'))).toEqual([
|
|
'example-html-ppt-zhangzara-creative-mode',
|
|
'example-html-ppt-zhangzara-capsule',
|
|
'example-ordinary-deck',
|
|
]);
|
|
});
|
|
|
|
it('keeps curated presets even when they rely on fallback prompt text', () => {
|
|
const otakuDance = makePlugin(
|
|
'image-template-infographic-otaku-dance-choreography-breakdown-gokurakujodo-16-panels',
|
|
'image',
|
|
'Infographic - Otaku Dance Choreography Breakdown (Gokuraku Jodo, 16 Panels)',
|
|
['image-template'],
|
|
{ query: null },
|
|
);
|
|
const ordinaryImage = makePlugin(
|
|
'image-template-ordinary',
|
|
'image',
|
|
'Ordinary image',
|
|
['image-template'],
|
|
);
|
|
renderHero({
|
|
activeChipId: 'image',
|
|
pluginOptions: [ordinaryImage, otakuDance],
|
|
});
|
|
|
|
const presets = screen.getAllByTestId('home-hero-plugin-preset');
|
|
expect(presets[0]?.getAttribute('data-plugin-id')).toBe(
|
|
'image-template-infographic-otaku-dance-choreography-breakdown-gokurakujodo-16-panels',
|
|
);
|
|
});
|
|
|
|
it('keeps Hatch Pet at the end of the image example presets', () => {
|
|
const hatchPet = makePlugin('example-hatch-pet', 'image', 'Hatch Pet');
|
|
const imagePoster = makePlugin('image-template-poster', 'image', 'Image Poster');
|
|
const stoneInfographic = makePlugin('image-template-stone', 'image', 'Stone Infographic');
|
|
renderHero({
|
|
activeChipId: 'image',
|
|
pluginOptions: [hatchPet, imagePoster, stoneInfographic],
|
|
});
|
|
|
|
const presets = screen.getAllByTestId('home-hero-plugin-preset');
|
|
expect(presets.map((preset) => preset.textContent)).toEqual([
|
|
expect.stringContaining('Image Poster'),
|
|
expect.stringContaining('Stone Infographic'),
|
|
expect.stringContaining('Hatch Pet'),
|
|
]);
|
|
});
|
|
|
|
it('moves live artifact presets out of Image and into Live artifact examples', () => {
|
|
const imagePoster = makePlugin('image-template-poster', 'image', 'Image Poster');
|
|
const liveDashboard = makePlugin(
|
|
'example-live-dashboard',
|
|
'prototype',
|
|
'Live Dashboard',
|
|
['live-dashboard'],
|
|
);
|
|
const notionDashboard = makePlugin(
|
|
'image-template-notion-team-dashboard-live-artifact',
|
|
'image',
|
|
'Notion-style Team Dashboard (Live Artifact)',
|
|
['live-artifact'],
|
|
);
|
|
const socialTracker = makePlugin(
|
|
'example-social-media-matrix-tracker-template',
|
|
'template',
|
|
'Social Media Matrix Tracker Template',
|
|
['live-artifacts'],
|
|
);
|
|
const tradingDashboard = makePlugin(
|
|
'example-trading-analysis-dashboard-template',
|
|
'template',
|
|
'Trading Analysis Dashboard Template',
|
|
['live-artifacts'],
|
|
);
|
|
const liveArtifact = makePlugin(
|
|
'example-live-artifact',
|
|
'prototype',
|
|
'Live Artifact',
|
|
['live-artifact'],
|
|
);
|
|
renderHero({
|
|
activeChipId: 'image',
|
|
pluginOptions: [imagePoster, liveDashboard, notionDashboard],
|
|
});
|
|
|
|
let presets = screen.getAllByTestId('home-hero-plugin-preset');
|
|
expect(presets).toHaveLength(1);
|
|
expect(presets[0]?.textContent).toContain('Image Poster');
|
|
|
|
cleanup();
|
|
renderHero({
|
|
activeChipId: 'live-artifact',
|
|
pluginOptions: [
|
|
imagePoster,
|
|
liveArtifact,
|
|
tradingDashboard,
|
|
notionDashboard,
|
|
socialTracker,
|
|
liveDashboard,
|
|
],
|
|
});
|
|
|
|
presets = screen.getAllByTestId('home-hero-plugin-preset');
|
|
expect(presets.map((preset) => preset.getAttribute('data-plugin-id'))).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',
|
|
]);
|
|
});
|
|
|
|
it('disables every visible chip while a plugin apply is in flight', () => {
|
|
renderHero({ pendingPluginId: 'od-figma-migration', pendingChipId: 'figma' });
|
|
for (const chip of HOME_HERO_CHIPS.filter((item) => item.group === 'create')) {
|
|
const node = screen.getByTestId(`home-hero-rail-${chip.id}`);
|
|
expect((node as HTMLButtonElement).disabled).toBe(true);
|
|
}
|
|
const trigger = screen.getByTestId('home-hero-shortcuts-trigger') as HTMLButtonElement;
|
|
expect(trigger.disabled).toBe(true);
|
|
expect(trigger.className).toContain('is-pending');
|
|
});
|
|
|
|
it('shows plugin authoring with the starter shortcuts after More opens', () => {
|
|
renderHero();
|
|
fireEvent.click(screen.getByTestId('home-hero-shortcuts-trigger'));
|
|
const createPluginGroup = screen
|
|
.getByTestId('home-hero-rail-create-plugin')
|
|
.closest('[data-rail-group]');
|
|
|
|
expect(createPluginGroup?.getAttribute('data-rail-group')).toBe('migrate');
|
|
for (const id of ['figma', 'folder', 'template']) {
|
|
expect(screen.getByTestId(`home-hero-rail-${id}`).closest('[data-rail-group]'))
|
|
.toBe(createPluginGroup);
|
|
}
|
|
});
|
|
|
|
it('localizes the folder shortcut label and hint in Simplified Chinese', () => {
|
|
renderHero({ activeChipId: 'folder' }, 'zh-CN');
|
|
fireEvent.click(screen.getByTestId('home-hero-shortcuts-trigger'));
|
|
|
|
const folder = screen.getByTestId('home-hero-rail-folder');
|
|
expect(folder.textContent).toContain('来自文件夹');
|
|
expect(folder.getAttribute('title')).toBe('导入本地文件夹并继续编辑。');
|
|
expect(folder.className).toContain('is-active');
|
|
});
|
|
|
|
it('keeps the generic fallback in the free-form prompt instead of an Other chip', () => {
|
|
renderHero();
|
|
|
|
expect(findChip('other')).toBeUndefined();
|
|
expect(screen.queryByTestId('home-hero-rail-other')).toBeNull();
|
|
});
|
|
|
|
it('migration chips carry the right action discriminator', () => {
|
|
expect(findChip('create-plugin')?.action).toMatchObject({ kind: 'create-plugin' });
|
|
expect(findChip('figma')?.action).toMatchObject({ kind: 'apply-figma-migration' });
|
|
expect(findChip('folder')?.action).toMatchObject({ kind: 'import-folder' });
|
|
expect(findChip('template')?.action).toMatchObject({ kind: 'open-template-picker' });
|
|
});
|
|
|
|
it('media chips route to od-media-generation with the matching project kind', () => {
|
|
expect(findChip('image')?.action).toMatchObject({
|
|
kind: 'apply-scenario',
|
|
pluginId: 'od-media-generation',
|
|
projectKind: 'image',
|
|
});
|
|
expect(findChip('video')?.action).toMatchObject({ pluginId: 'od-media-generation', projectKind: 'video' });
|
|
expect(findChip('audio')?.action).toMatchObject({ pluginId: 'od-media-generation', projectKind: 'audio' });
|
|
});
|
|
|
|
it('prototype and slide-deck chips route to their specialised bundled scenario plugin', () => {
|
|
// Prototype now binds to web-prototype's seed template instead of
|
|
// the generic od-new-generation router. Same for Slide deck →
|
|
// simple-deck. See packages/contracts/src/plugins/scenario-defaults.ts
|
|
// for the rationale (battle-tested seed + layouts + checklist).
|
|
expect(findChip('prototype')?.action).toMatchObject({ pluginId: 'example-web-prototype', projectKind: 'prototype' });
|
|
expect(findChip('deck')?.action).toMatchObject({ pluginId: 'example-simple-deck', projectKind: 'deck' });
|
|
});
|
|
|
|
it('specialised category chips route to their bundled scenario plugin', () => {
|
|
// HyperFrames is the motion-graphics specialisation of Video,
|
|
// surfaced as a separate chip so users can target it directly
|
|
// instead of routing through the generic Video chip.
|
|
expect(findChip('hyperframes')?.action).toMatchObject({
|
|
kind: 'apply-scenario',
|
|
pluginId: 'example-hyperframes',
|
|
projectKind: 'video',
|
|
});
|
|
expect(findChip('live-artifact')?.action).toMatchObject({
|
|
kind: 'apply-scenario',
|
|
pluginId: 'example-live-artifact',
|
|
projectKind: 'prototype',
|
|
projectMetadata: {
|
|
kind: 'prototype',
|
|
intent: 'live-artifact',
|
|
fidelity: 'high-fidelity',
|
|
},
|
|
});
|
|
});
|
|
});
|