mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* Refactor ChatComposer and DesignBrowserPanel components for improved functionality and localization - Introduced new functions in ChatComposer for managing attachment orders and appending staged attachments, enhancing user experience during file uploads. - Updated ChatPane to clarify the search button's aria-label for better accessibility. - Enhanced DesignBrowserPanel with new localization keys for browser use inputs and actions, improving clarity across multiple languages. - Adjusted styles in various components for better layout and user interaction, including updates to the chat input and staged context elements. * Update AvatarMenu and Chat components for improved functionality and localization - Modified AvatarMenu to include an optional section parameter for the onOpenSettings callback, enhancing flexibility in opening settings. - Updated tests for AvatarMenu to verify the correct section is passed when opening settings. - Adjusted styles in various components, including the ChatComposer and FileViewer, to improve layout and user interaction. - Enhanced localization strings across multiple languages to reflect changes in chat mode terminology from 'Chat' to 'Ask', improving clarity for users.
561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
BROWSER_USE_ACTION_TOTAL,
|
|
BROWSER_USE_CATEGORIES,
|
|
REFERENCE_GROUPS,
|
|
REFERENCE_TOTAL,
|
|
browserUseActionById,
|
|
browserUsePrompt,
|
|
browserFileName,
|
|
faviconUrl,
|
|
filterBrowserUseCategories,
|
|
filterReferenceGroups,
|
|
formatAddressDisplay,
|
|
formatAddressDisplayParts,
|
|
hostnameFromUrl,
|
|
isHistoryEntry,
|
|
isHistoryUrl,
|
|
labelFromUrl,
|
|
loadHistory,
|
|
normalizeBrowserAddress,
|
|
pageBriefMarkdown,
|
|
referenceIconUrl,
|
|
sameUrl,
|
|
saveHistory,
|
|
} from '../../src/components/DesignBrowserPanel';
|
|
import {
|
|
browserCommentFilePath,
|
|
isProjectHtmlBrowserUrl,
|
|
projectRelativePathFromBrowserUrl,
|
|
} from '../../src/components/design-browser-tools';
|
|
|
|
describe('normalizeBrowserAddress', () => {
|
|
it('passes through absolute http URLs unchanged', () => {
|
|
expect(normalizeBrowserAddress('http://example.com/page')).toBe('http://example.com/page');
|
|
});
|
|
|
|
it('passes through absolute https URLs unchanged', () => {
|
|
expect(normalizeBrowserAddress('https://example.com/page')).toBe('https://example.com/page');
|
|
});
|
|
|
|
it('passes through file URLs unchanged', () => {
|
|
expect(normalizeBrowserAddress('file:///Users/me/page.html')).toBe('file:///Users/me/page.html');
|
|
});
|
|
|
|
it('trims surrounding whitespace before matching', () => {
|
|
expect(normalizeBrowserAddress(' https://example.com ')).toBe('https://example.com');
|
|
});
|
|
|
|
it('promotes a bare domain to https', () => {
|
|
expect(normalizeBrowserAddress('example.com')).toBe('https://example.com');
|
|
});
|
|
|
|
it('promotes a bare domain with a path and port to https', () => {
|
|
expect(normalizeBrowserAddress('example.com:8080/path')).toBe('https://example.com:8080/path');
|
|
});
|
|
|
|
it('maps localhost to http', () => {
|
|
expect(normalizeBrowserAddress('localhost')).toBe('http://localhost');
|
|
expect(normalizeBrowserAddress('localhost:3000/dash')).toBe('http://localhost:3000/dash');
|
|
});
|
|
|
|
it('maps loopback IPs to http', () => {
|
|
expect(normalizeBrowserAddress('127.0.0.1')).toBe('http://127.0.0.1');
|
|
expect(normalizeBrowserAddress('127.0.0.1:5173')).toBe('http://127.0.0.1:5173');
|
|
expect(normalizeBrowserAddress('0.0.0.0:8000')).toBe('http://0.0.0.0:8000');
|
|
});
|
|
|
|
it('resolves /api, /artifacts, /frames paths against the page origin', () => {
|
|
const origin = window.location.origin;
|
|
expect(normalizeBrowserAddress('/api/runs')).toBe(`${origin}/api/runs`);
|
|
expect(normalizeBrowserAddress('/artifacts/x.png')).toBe(`${origin}/artifacts/x.png`);
|
|
expect(normalizeBrowserAddress('/frames/1')).toBe(`${origin}/frames/1`);
|
|
});
|
|
|
|
it('maps other absolute paths to file URLs', () => {
|
|
expect(normalizeBrowserAddress('/Users/me/page.html')).toBe('file:///Users/me/page.html');
|
|
expect(normalizeBrowserAddress('/some path/with space')).toBe(`file://${encodeURI('/some path/with space')}`);
|
|
});
|
|
|
|
it('treats free text as a Google search', () => {
|
|
expect(normalizeBrowserAddress('design inspiration')).toBe(
|
|
'https://www.google.com/search?q=design%20inspiration',
|
|
);
|
|
});
|
|
|
|
it('maps an empty string to about:blank', () => {
|
|
expect(normalizeBrowserAddress('')).toBe('about:blank');
|
|
expect(normalizeBrowserAddress(' ')).toBe('about:blank');
|
|
});
|
|
|
|
it('passes through an explicit about:blank', () => {
|
|
expect(normalizeBrowserAddress('about:blank')).toBe('about:blank');
|
|
});
|
|
});
|
|
|
|
describe('inspiration action prompts', () => {
|
|
it('keeps the inspiration catalogue at the intended 43 actions', () => {
|
|
expect(BROWSER_USE_ACTION_TOTAL).toBe(43);
|
|
expect(BROWSER_USE_CATEGORIES.map((category) => category.id)).toEqual([
|
|
'assets',
|
|
'tokens',
|
|
'motion',
|
|
'visual',
|
|
'structure',
|
|
'project',
|
|
'general',
|
|
]);
|
|
expect(BROWSER_USE_CATEGORIES[0]?.actions.slice(0, 3).map((action) => action.id)).toEqual([
|
|
'extract_logo',
|
|
'list_images',
|
|
'download_assets',
|
|
]);
|
|
expect(BROWSER_USE_CATEGORIES[BROWSER_USE_CATEGORIES.length - 1]?.actions.map((action) => action.id)).toContain('navigate');
|
|
expect(browserUseActionById('extract_fonts')?.output).toContain('typography.json');
|
|
expect(browserUseActionById('extract_og_metadata')?.output).toContain('OG/Twitter');
|
|
expect(browserUseActionById('audit_accessibility')?.output).toContain('A11y');
|
|
});
|
|
|
|
it('filters inspiration actions by action evidence and category title', () => {
|
|
const visualCategory = BROWSER_USE_CATEGORIES.find((category) => category.id === 'visual');
|
|
const categoryTitle = (category: (typeof BROWSER_USE_CATEGORIES)[number]) =>
|
|
({
|
|
assets: '素材提取',
|
|
tokens: '设计语言',
|
|
motion: '动效',
|
|
visual: '视觉校验',
|
|
structure: '组件结构',
|
|
project: '项目运行',
|
|
general: '通用操作',
|
|
})[category.id] ?? category.title;
|
|
expect(visualCategory).toBeTruthy();
|
|
expect(filterBrowserUseCategories(BROWSER_USE_CATEGORIES, 'a11y', categoryTitle)).toEqual([
|
|
{
|
|
...visualCategory!,
|
|
actions: [browserUseActionById('audit_accessibility')!],
|
|
},
|
|
]);
|
|
expect(
|
|
filterBrowserUseCategories(
|
|
BROWSER_USE_CATEGORIES,
|
|
'字体',
|
|
categoryTitle,
|
|
(action) => (action.id === 'extract_fonts' ? ['字体家族、字号、字重和 @font-face'] : []),
|
|
)[0]?.actions,
|
|
).toEqual([browserUseActionById('extract_fonts')!]);
|
|
expect(filterBrowserUseCategories(BROWSER_USE_CATEGORIES, '通用操作', categoryTitle)[0]?.id).toBe('general');
|
|
expect(filterBrowserUseCategories(BROWSER_USE_CATEGORIES, 'no-such-action', categoryTitle)).toEqual([]);
|
|
});
|
|
|
|
it('builds an agent-browser prompt bound to the current browser tab context', () => {
|
|
const action = browserUseActionById('extract_colors');
|
|
expect(action).not.toBeNull();
|
|
|
|
const prompt = browserUsePrompt(action!, {
|
|
browserFilePath: 'browser:https://example.com',
|
|
projectId: 'proj-1',
|
|
resolvedDir: '/tmp/open-design/project',
|
|
tabLabel: 'Example landing',
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
});
|
|
|
|
expect(prompt).toContain('@agent-browser');
|
|
expect(prompt).toContain('Use the selected Open Design Browser tab as the bound target.');
|
|
expect(prompt).toContain('- tab: Example landing');
|
|
expect(prompt).toContain('- url: https://example.com');
|
|
expect(prompt).toContain('Operation: extract_colors');
|
|
expect(prompt).toContain('browser-use / browser-harness style evidence');
|
|
});
|
|
});
|
|
|
|
describe('browser tool file targeting', () => {
|
|
it('treats localhost dev-server pages as browser targets, not single-file HTML saves', () => {
|
|
const url = 'http://localhost:3000/src/App.jsx';
|
|
|
|
expect(browserCommentFilePath(url, '/Users/me/project')).toBe(`browser:${url}`);
|
|
expect(projectRelativePathFromBrowserUrl(url, '/Users/me/project')).toBeNull();
|
|
expect(isProjectHtmlBrowserUrl(url, '/Users/me/project')).toBe(false);
|
|
});
|
|
|
|
it('maps project-local file HTML pages back to editable project files', () => {
|
|
const url = 'file:///Users/me/project/dist/index.html';
|
|
|
|
expect(browserCommentFilePath(url, '/Users/me/project')).toBe('dist/index.html');
|
|
expect(projectRelativePathFromBrowserUrl(url, '/Users/me/project')).toBe('dist/index.html');
|
|
expect(isProjectHtmlBrowserUrl(url, '/Users/me/project')).toBe(true);
|
|
});
|
|
|
|
it('keeps project-local non-HTML files as commentable browser targets without save affordance', () => {
|
|
const url = 'file:///Users/me/project/src/App.jsx';
|
|
|
|
expect(browserCommentFilePath(url, '/Users/me/project')).toBe('src/App.jsx');
|
|
expect(projectRelativePathFromBrowserUrl(url, '/Users/me/project')).toBe('src/App.jsx');
|
|
expect(isProjectHtmlBrowserUrl(url, '/Users/me/project')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('sameUrl', () => {
|
|
it('treats trailing slashes as equivalent', () => {
|
|
expect(sameUrl('https://example.com', 'https://example.com/')).toBe(true);
|
|
expect(sameUrl('https://example.com///', 'https://example.com')).toBe(true);
|
|
});
|
|
|
|
it('distinguishes different paths', () => {
|
|
expect(sameUrl('https://example.com/a', 'https://example.com/b')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('labelFromUrl', () => {
|
|
it('returns New Tab for the blank URL', () => {
|
|
expect(labelFromUrl('about:blank')).toBe('New Tab');
|
|
});
|
|
|
|
it('strips the www. prefix from the host', () => {
|
|
expect(labelFromUrl('https://www.example.com/page')).toBe('example.com');
|
|
expect(labelFromUrl('https://sub.example.com/')).toBe('sub.example.com');
|
|
});
|
|
|
|
it('falls back to the raw value when the URL cannot be parsed', () => {
|
|
expect(labelFromUrl('not a url')).toBe('not a url');
|
|
});
|
|
});
|
|
|
|
describe('formatAddressDisplay', () => {
|
|
it('keeps the URL alone when the title is only the host fallback', () => {
|
|
expect(formatAddressDisplay('https://www.example.com/path', 'example.com')).toBe('https://www.example.com/path');
|
|
});
|
|
|
|
it('appends a real page title for the passive address display', () => {
|
|
expect(formatAddressDisplay('https://www.baidu.com/', '百度一下,你就知道')).toBe(
|
|
'https://www.baidu.com/ / 百度一下,你就知道',
|
|
);
|
|
});
|
|
|
|
it('exposes URL and title as separate passive display parts', () => {
|
|
expect(formatAddressDisplayParts('https://brandfetch.com/', 'Just a moment...')).toEqual({
|
|
url: 'https://brandfetch.com/',
|
|
title: 'Just a moment...',
|
|
});
|
|
});
|
|
|
|
it('keeps the blank tab display empty', () => {
|
|
expect(formatAddressDisplay('about:blank', 'New Tab')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('hostnameFromUrl', () => {
|
|
it('returns a compact hostname without www', () => {
|
|
expect(hostnameFromUrl('https://www.example.com/docs')).toBe('example.com');
|
|
});
|
|
|
|
it('falls back to the raw value when parsing fails', () => {
|
|
expect(hostnameFromUrl('not a url')).toBe('not a url');
|
|
});
|
|
});
|
|
|
|
describe('faviconUrl', () => {
|
|
it('derives a same-origin favicon URL for http pages', () => {
|
|
expect(faviconUrl('https://www.example.com/docs')).toBe('https://www.example.com/favicon.ico');
|
|
});
|
|
|
|
it('skips non-http urls', () => {
|
|
expect(faviconUrl('file:///Users/me/page.html')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('referenceIconUrl', () => {
|
|
it('routes a reference site through the favicon service with its hostname', () => {
|
|
expect(referenceIconUrl('https://dribbble.com/')).toBe(
|
|
'https://www.google.com/s2/favicons?sz=64&domain=dribbble.com',
|
|
);
|
|
});
|
|
|
|
it('preserves subdomains and accepts a custom size', () => {
|
|
expect(referenceIconUrl('https://styles.refero.design/', 32)).toBe(
|
|
'https://www.google.com/s2/favicons?sz=32&domain=styles.refero.design',
|
|
);
|
|
});
|
|
|
|
it('returns a resolvable icon for every catalogued reference site', () => {
|
|
for (const group of REFERENCE_GROUPS) {
|
|
for (const site of group.sites) {
|
|
expect(referenceIconUrl(site.url)).toMatch(
|
|
/^https:\/\/www\.google\.com\/s2\/favicons\?sz=64&domain=/,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('skips non-http urls so the globe fallback still applies', () => {
|
|
expect(referenceIconUrl('file:///Users/me/page.html')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('isHistoryUrl', () => {
|
|
it('accepts http(s) and file URLs', () => {
|
|
expect(isHistoryUrl('https://example.com')).toBe(true);
|
|
expect(isHistoryUrl('http://localhost:3000')).toBe(true);
|
|
expect(isHistoryUrl('file:///Users/me/x.html')).toBe(true);
|
|
});
|
|
|
|
it('rejects the blank URL', () => {
|
|
expect(isHistoryUrl('about:blank')).toBe(false);
|
|
});
|
|
|
|
it('rejects non http/file schemes', () => {
|
|
expect(isHistoryUrl('data:text/html,hi')).toBe(false);
|
|
expect(isHistoryUrl('mailto:hi@example.com')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('pageBriefMarkdown', () => {
|
|
it('renders title, source, and populated sections while skipping empty ones', () => {
|
|
const md = pageBriefMarkdown(
|
|
{
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
description: 'A description',
|
|
headings: ['Hero', ' ', 'Features'],
|
|
images: [],
|
|
links: [{ text: 'Docs', url: 'https://example.com/docs' }],
|
|
colors: [{ value: 'rgb(0, 0, 0)', count: 4 }],
|
|
},
|
|
'https://fallback.example.com',
|
|
);
|
|
expect(md).toContain('# Example');
|
|
expect(md).toContain('Source: https://example.com');
|
|
expect(md).toContain('## Description');
|
|
expect(md).toContain('## Headings');
|
|
expect(md).toContain('- Hero');
|
|
expect(md).toContain('- Features');
|
|
expect(md).not.toContain('## Images');
|
|
expect(md).toContain('## Links');
|
|
expect(md).toContain('- Docs - https://example.com/docs');
|
|
expect(md).toContain('## Colors');
|
|
expect(md).toContain('- rgb(0, 0, 0) (4)');
|
|
expect(md).not.toContain('Browser Harness');
|
|
});
|
|
|
|
it('falls back to label and url when the brief omits them', () => {
|
|
const md = pageBriefMarkdown({}, 'https://www.fallback.example.com/path');
|
|
expect(md).toContain('# fallback.example.com');
|
|
expect(md).toContain('Source: https://www.fallback.example.com/path');
|
|
});
|
|
});
|
|
|
|
describe('browserFileName', () => {
|
|
it('sanitizes the host and includes the prefix and extension', () => {
|
|
const name = browserFileName('browser-capture', 'https://www.example.com/page', 'png');
|
|
expect(name).toMatch(/^browser\/browser-capture-example\.com-[\dTZ-]+\.png$/);
|
|
});
|
|
|
|
it('uses a page fallback when the host sanitizes to empty', () => {
|
|
const name = browserFileName('browser-brief', 'about:blank', 'md');
|
|
// about:blank -> labelFromUrl 'New Tab' -> 'New-Tab'
|
|
expect(name).toMatch(/^browser\/browser-brief-New-Tab-[\dTZ-]+\.md$/);
|
|
});
|
|
});
|
|
|
|
describe('isHistoryEntry', () => {
|
|
it('accepts a well-formed entry', () => {
|
|
expect(
|
|
isHistoryEntry({ url: 'https://x', title: 'X', iconUrl: 'https://x/favicon.ico', lastVisitedAt: 1, visitCount: 1 }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('rejects malformed values', () => {
|
|
expect(isHistoryEntry(null)).toBe(false);
|
|
expect(isHistoryEntry([])).toBe(false);
|
|
expect(isHistoryEntry('x')).toBe(false);
|
|
expect(isHistoryEntry({ url: 1, title: 'X', lastVisitedAt: 1, visitCount: 1 })).toBe(false);
|
|
expect(isHistoryEntry({ url: 'x', title: 'X', lastVisitedAt: 1 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('loadHistory / saveHistory round-trip', () => {
|
|
const projectId = 'proj-history';
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it('returns an empty array when nothing is stored', () => {
|
|
expect(loadHistory(projectId)).toEqual([]);
|
|
});
|
|
|
|
it('round-trips entries and sorts by lastVisitedAt descending', () => {
|
|
saveHistory(projectId, [
|
|
{ url: 'https://a.com', title: 'A', lastVisitedAt: 100, visitCount: 1 },
|
|
{ url: 'https://b.com', title: 'B', lastVisitedAt: 300, visitCount: 2 },
|
|
{ url: 'https://c.com', title: 'C', lastVisitedAt: 200, visitCount: 1 },
|
|
]);
|
|
const loaded = loadHistory(projectId);
|
|
expect(loaded.map((entry) => entry.url)).toEqual([
|
|
'https://b.com',
|
|
'https://c.com',
|
|
'https://a.com',
|
|
]);
|
|
});
|
|
|
|
it('drops malformed entries on load', () => {
|
|
window.localStorage.setItem(
|
|
`od:design-browser:${projectId}:history:v1`,
|
|
JSON.stringify([
|
|
{ url: 'https://ok.com', title: 'OK', lastVisitedAt: 1, visitCount: 1 },
|
|
{ url: 123, title: 'bad', lastVisitedAt: 1, visitCount: 1 },
|
|
]),
|
|
);
|
|
const loaded = loadHistory(projectId);
|
|
expect(loaded).toHaveLength(1);
|
|
expect(loaded[0]?.url).toBe('https://ok.com');
|
|
});
|
|
|
|
it('returns an empty array for corrupt or non-array JSON', () => {
|
|
const key = `od:design-browser:${projectId}:history:v1`;
|
|
window.localStorage.setItem(key, 'not json');
|
|
expect(loadHistory(projectId)).toEqual([]);
|
|
window.localStorage.setItem(key, JSON.stringify({ not: 'an array' }));
|
|
expect(loadHistory(projectId)).toEqual([]);
|
|
});
|
|
|
|
it('caps stored history at the HISTORY_LIMIT on save and load', () => {
|
|
const many = Array.from({ length: 120 }, (_, index) => ({
|
|
url: `https://site-${index}.com`,
|
|
title: `Site ${index}`,
|
|
lastVisitedAt: index,
|
|
visitCount: 1,
|
|
}));
|
|
saveHistory(projectId, many);
|
|
expect(loadHistory(projectId)).toHaveLength(80);
|
|
});
|
|
});
|
|
|
|
describe('REFERENCE_GROUPS catalogue', () => {
|
|
it('exposes every documented designer reference category', () => {
|
|
const ids = REFERENCE_GROUPS.map((group) => group.id);
|
|
expect(ids).toEqual([
|
|
'inspiration',
|
|
'interfaces',
|
|
'motion',
|
|
'color',
|
|
'type',
|
|
'icons',
|
|
'illustration',
|
|
'photography',
|
|
'3d',
|
|
'mockups',
|
|
'systems',
|
|
'components',
|
|
'guidelines',
|
|
'tools',
|
|
]);
|
|
});
|
|
|
|
it('gives every group a title and at least one absolute https/http site', () => {
|
|
for (const group of REFERENCE_GROUPS) {
|
|
expect(group.title.length).toBeGreaterThan(0);
|
|
expect(group.sites.length).toBeGreaterThan(0);
|
|
for (const site of group.sites) {
|
|
expect(site.label.length).toBeGreaterThan(0);
|
|
expect(site.detail.length).toBeGreaterThan(0);
|
|
expect(site.url).toMatch(/^https?:\/\//);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('uses unique category ids and unique site urls', () => {
|
|
const ids = REFERENCE_GROUPS.map((group) => group.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
const urls = REFERENCE_GROUPS.flatMap((group) => group.sites.map((site) => site.url));
|
|
expect(new Set(urls).size).toBe(urls.length);
|
|
});
|
|
|
|
it('keeps REFERENCE_TOTAL in sync with the catalogue', () => {
|
|
const counted = REFERENCE_GROUPS.reduce((sum, group) => sum + group.sites.length, 0);
|
|
expect(REFERENCE_TOTAL).toBe(counted);
|
|
});
|
|
|
|
it('includes the handoff and component-library reference URLs', () => {
|
|
const urls = new Set(REFERENCE_GROUPS.flatMap((group) => group.sites.map((site) => site.url)));
|
|
expect(Array.from(urls)).toEqual(expect.arrayContaining([
|
|
'https://thesvg.org/',
|
|
'https://unsplash.com/',
|
|
'https://motionsites.ai/',
|
|
'https://motion.page/showcase/',
|
|
'https://styles.refero.design/',
|
|
'https://brandfetch.com/',
|
|
'https://gsap.com/',
|
|
'https://transitions.dev/',
|
|
'https://fonts.google.com/',
|
|
'https://animography.net/',
|
|
'https://reactbits.dev/text-animations/shiny-text',
|
|
'https://toolfolio.io/',
|
|
'https://www.whirrls.com/',
|
|
'https://startups.gallery/',
|
|
'https://www.worldindots.com/',
|
|
'https://getdesign.md/',
|
|
'https://github.com/superset-sh/superset',
|
|
'https://svglogos.dev/',
|
|
'https://icons.lobehub.com/',
|
|
'https://animations.dev/',
|
|
'https://impeccable.style/',
|
|
'https://www.tasteskill.dev/',
|
|
'https://base-ui.com/',
|
|
'https://ui.shadcn.com/',
|
|
'https://www.heroui.com/',
|
|
]));
|
|
});
|
|
});
|
|
|
|
describe('filterReferenceGroups', () => {
|
|
it('returns every group untouched for the "all" category and an empty query', () => {
|
|
const result = filterReferenceGroups(REFERENCE_GROUPS, 'all', '');
|
|
expect(result.map((group) => group.id)).toEqual(REFERENCE_GROUPS.map((group) => group.id));
|
|
expect(result).toEqual(REFERENCE_GROUPS);
|
|
});
|
|
|
|
it('narrows to a single group when a category id is active', () => {
|
|
const result = filterReferenceGroups(REFERENCE_GROUPS, 'motion', '');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]?.id).toBe('motion');
|
|
expect(result[0]?.sites.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('matches sites by label, hostname, or detail across all categories', () => {
|
|
const byLabel = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'dribbble');
|
|
expect(byLabel.flatMap((group) => group.sites.map((site) => site.label))).toContain('Dribbble');
|
|
|
|
const byHostname = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'unsplash.com');
|
|
expect(byHostname.flatMap((group) => group.sites.map((site) => site.label))).toContain('Unsplash');
|
|
|
|
const byDetail = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'contrast');
|
|
expect(byDetail.flatMap((group) => group.sites.map((site) => site.label))).toContain('WebAIM Contrast');
|
|
});
|
|
|
|
it('keeps an entire group when the query matches its title', () => {
|
|
const color = REFERENCE_GROUPS.find((group) => group.id === 'color');
|
|
const result = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'color');
|
|
const matchedColor = result.find((group) => group.id === 'color');
|
|
expect(matchedColor?.sites).toEqual(color?.sites);
|
|
});
|
|
|
|
it('drops groups with no surviving sites and is case-insensitive', () => {
|
|
const result = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'COOLORS');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]?.id).toBe('color');
|
|
expect(result[0]?.sites.map((site) => site.label)).toEqual(['Coolors']);
|
|
});
|
|
|
|
it('returns an empty array when nothing matches', () => {
|
|
expect(filterReferenceGroups(REFERENCE_GROUPS, 'all', 'zzz-no-such-reference')).toEqual([]);
|
|
});
|
|
});
|