@linhey/rhine-design 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -1
- package/CONTRIBUTING.md +2 -0
- package/README.md +9 -4
- package/components/Actions.tsx +1 -0
- package/components/AppShell.tsx +3 -2
- package/components/COMMON_COMPONENTS.md +5 -1
- package/components/Chat.tsx +160 -0
- package/components/Navigation.tsx +15 -4
- package/components/Overlays.tsx +37 -7
- package/components/README.md +11 -1
- package/components/chat.css +32 -0
- package/components/components.tsx +19 -9
- package/components/dialog-settings.css +54 -1
- package/components/extensions.css +4 -0
- package/components/index.ts +8 -3
- package/components/patterns.css +4 -0
- package/components/styles.css +11 -2
- package/components/theme-arknights.css +4 -2
- package/docs/AGENT_CHAT.md +76 -0
- package/docs/AGENT_CHAT_VERIFICATION.md +42 -0
- package/docs/APPLICATION_COMPONENTS.md +2 -0
- package/docs/BUTTON_ACTIONS.md +32 -0
- package/docs/BUTTON_ACTIONS_VERIFICATION.md +14 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +16 -0
- package/docs/COMPACT_SELECTION_VERIFICATION.md +15 -0
- package/docs/FLOATING_OVERLAYS.md +4 -0
- package/docs/INTERACTION_PATTERNS.md +4 -0
- package/docs/SETTINGS_COMPOSITION_VERIFICATION.md +12 -0
- package/docs/SETTINGS_DIALOG.md +42 -4
- package/docs/SIDE_NAV_ACTIONS.md +9 -0
- package/docs/SINGLE_SEARCH.md +19 -0
- package/docs/SINGLE_SEARCH_VERIFICATION.md +15 -0
- package/docs/SUMMARY_TABS.md +7 -1
- package/docs/TRAY_USAGE.md +34 -0
- package/docs/TRAY_USAGE_VERIFICATION.md +13 -0
- package/docs/assets/chat/after.png +0 -0
- package/docs/assets/chat/arknights.png +0 -0
- package/docs/assets/chat/before.png +0 -0
- package/docs/assets/chat/neo-brutalism.png +0 -0
- package/package.json +1 -1
- package/preview/ButtonActionsGallery.tsx +25 -0
- package/preview/ChatGallery.tsx +98 -0
- package/preview/CompactSelectionGallery.tsx +76 -0
- package/preview/GalleryShell.tsx +2 -2
- package/preview/NavActionsGallery.tsx +24 -0
- package/preview/SettingsCompositionGallery.tsx +38 -0
- package/preview/SettingsDialogGallery.tsx +54 -14
- package/preview/SingleSearchGallery.tsx +50 -0
- package/preview/SummaryTabsGallery.tsx +7 -3
- package/preview/TrayUsageGallery.tsx +82 -0
- package/preview/chat-gallery.css +1 -0
- package/preview/compact-selection.css +9 -0
- package/preview/main.tsx +15 -1
- package/preview/packageInfo.ts +1 -1
- package/preview/settings-dialog-gallery.css +5 -0
- package/preview/tray-usage.css +4 -0
- package/scripts/check-button-actions.js +34 -0
- package/scripts/check-compact-selection.js +40 -0
- package/scripts/check-dialog-scroll-boundaries.js +41 -0
- package/scripts/check-nav-actions.js +41 -0
- package/scripts/check-settings-bounded.js +49 -0
- package/scripts/check-settings-composition.js +50 -0
- package/scripts/check-settings-interactions.js +93 -0
- package/scripts/check-settings-theme.js +1 -5
- package/scripts/check-single-search.js +43 -0
- package/scripts/check-tray-usage.js +68 -0
- package/scripts/prepare-settings-consumer.mjs +21 -0
- package/skills/rhine-design/SKILL.md +2 -0
- package/skills/rhine-design/references/API_MAP.md +11 -0
- package/skills/rhine-design/references/TASK_MAP.md +1 -0
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/Chat.test.tsx +84 -0
- package/tests/CompactSelection.test.tsx +47 -0
- package/tests/NavActions.test.tsx +29 -0
- package/tests/SettingsDialog.test.tsx +102 -9
- package/tests/SingleSearch.test.tsx +42 -0
- package/tests/SkillInstallation.test.tsx +77 -0
- package/tests/TrayUsage.test.tsx +70 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Production preview, isolated playwright-cli session; synthetic data only.
|
|
2
|
+
async page => {
|
|
3
|
+
page.setDefaultTimeout(8000);
|
|
4
|
+
const origin = new URL(page.url()).origin;
|
|
5
|
+
const failures = [], errors = [], samples = [];
|
|
6
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
7
|
+
const check = (ok, context) => { if (!ok) failures.push(context); };
|
|
8
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
|
|
9
|
+
await page.setViewportSize({ width: 1100, height: 1000 });
|
|
10
|
+
await page.goto(`${origin}/?theme=${theme}#single-search`);
|
|
11
|
+
await page.getByRole('searchbox', { name: '搜索合成条目' }).waitFor();
|
|
12
|
+
await page.getByLabel('搜索容器宽度').selectOption(String(width));
|
|
13
|
+
await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
|
|
14
|
+
const input = page.getByRole('searchbox', { name: '搜索合成条目', exact: true });
|
|
15
|
+
const context = { theme, width, zoom };
|
|
16
|
+
check(await page.locator('[data-search-example] button').count() === 0, { ...context, kind: 'input-only' });
|
|
17
|
+
const typography = await input.evaluate(el => { const s = getComputedStyle(el), p = getComputedStyle(el, '::placeholder'); return { size: s.fontSize, line: s.lineHeight, weight: s.fontWeight, placeholder: p.fontSize, height: el.offsetHeight, fits: el.parentElement.scrollWidth <= el.parentElement.clientWidth + 1 }; });
|
|
18
|
+
check(typography.size === '14px' && typography.line === '22px' && typography.weight === '400' && typography.placeholder === '14px' && typography.height >= 44 && typography.fits, { ...context, kind: 'body-type-and-bounds', typography });
|
|
19
|
+
await page.getByLabel('搜索场景').selectOption('long');
|
|
20
|
+
await input.fill('跨区域联合研究项目中的长中文合成查询'.repeat(5));
|
|
21
|
+
await page.getByRole('status').filter({ hasText: '找到' }).waitFor();
|
|
22
|
+
check(await input.evaluate(el => el.getBoundingClientRect().right <= el.parentElement.getBoundingClientRect().right + 1), { ...context, kind: 'long-query' });
|
|
23
|
+
await input.press('Escape'); check(await input.inputValue() === '' && await input.evaluate(el => document.activeElement === el), { ...context, kind: 'clear-focus' });
|
|
24
|
+
for (const [state, message] of [['loading', '正在搜索'], ['empty', '没有匹配结果'], ['error', '搜索失败']]) {
|
|
25
|
+
await page.getByLabel('搜索场景').selectOption(state); await input.fill('合成查询');
|
|
26
|
+
await page.getByRole('status').filter({ hasText: message }).waitFor();
|
|
27
|
+
check(!!await input.getAttribute('aria-describedby'), { ...context, kind: `description-${state}` });
|
|
28
|
+
}
|
|
29
|
+
await page.getByLabel('搜索场景').selectOption('disabled'); check(await input.isDisabled(), { ...context, kind: 'disabled' });
|
|
30
|
+
if (width === 400 && zoom === 2) await page.screenshot({ path: `output/playwright/search-${theme}.png` });
|
|
31
|
+
samples.push({ ...context, typography });
|
|
32
|
+
}
|
|
33
|
+
for (const reduced of [false, true]) {
|
|
34
|
+
await page.emulateMedia({ reducedMotion: reduced ? 'reduce' : 'no-preference' });
|
|
35
|
+
await page.goto(`${origin}/#single-search`);
|
|
36
|
+
await page.getByRole('searchbox').fill('动效偏好');
|
|
37
|
+
await page.getByRole('status').filter({ hasText: '找到' }).waitFor();
|
|
38
|
+
}
|
|
39
|
+
await page.getByRole('radio', { name: '减少动态', exact: true }).check();
|
|
40
|
+
await page.getByRole('searchbox').press('Escape');
|
|
41
|
+
check(await page.getByRole('searchbox').inputValue() === '', { kind: 'manual-reduced-clear' });
|
|
42
|
+
return { pass: !failures.length && !errors.length, failures, errors, samples, scope: 'Production Chromium, 3 themes × 400/520/880 container × CSS zoom 1/2. OS IME and native zoom recorded separately.' };
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Start on this revision's production preview. native=1 expects Chrome's real page zoom already set to 200%.
|
|
2
|
+
async page => {
|
|
3
|
+
const origin = page.url().split('/').slice(0,3).join('/'), native = page.url().includes('native=1'), results = [], errors = [];
|
|
4
|
+
page.on('pageerror', e => errors.push(String(e)));
|
|
5
|
+
const panel = () => page.getByRole('dialog',{name:'账号与用量概览'});
|
|
6
|
+
const account = () => panel().getByRole('combobox',{name:'Agent A 当前账号'});
|
|
7
|
+
const open = async () => { if (!await panel().isVisible()) await page.getByRole('button',{name:'切换用量面板'}).click(); await panel().waitFor(); await page.waitForTimeout(200); };
|
|
8
|
+
const close = async () => { if (await panel().isVisible()) { await page.keyboard.press('Escape'); await panel().waitFor({state:'detached',timeout:3000}); } };
|
|
9
|
+
const size = () => page.getByTestId('switch-feedback').evaluate(e=>({h:e.getBoundingClientRect().height,content:e.closest('[data-testid="tray-panel"]').scrollHeight}));
|
|
10
|
+
const bounds = async () => {
|
|
11
|
+
const b = await panel().evaluate(e=>{const r=e.getBoundingClientRect();return {x:r.x,right:r.right,y:r.y,bottom:r.bottom,width:innerWidth,height:innerHeight};});
|
|
12
|
+
if (b.x < -1 || b.y < -1 || b.right > b.width+1 || b.bottom > b.height+1) throw Error(`Popover bounds ${JSON.stringify(b)}`);
|
|
13
|
+
};
|
|
14
|
+
for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880]) for (const zoom of (native ? [1] : [1,2])) {
|
|
15
|
+
await page.setViewportSize({width,height:1000});
|
|
16
|
+
await page.goto(`${origin}/?fixture=tray-usage&theme=${theme}&zoom=${zoom}`);
|
|
17
|
+
await panel().waitFor();
|
|
18
|
+
if (await panel().locator('.rhine-dialog__head').count()) throw Error('Visible header retained');
|
|
19
|
+
await close(); await page.getByRole('checkbox',{name:'长账号名',exact:true}).check(); await open();
|
|
20
|
+
await close(); await page.getByRole('checkbox',{name:'首次切换失败',exact:true}).check(); await open();
|
|
21
|
+
const other = await page.getByTestId('other-agent').textContent(), summary = await page.getByTestId('usage-summary').textContent(), before = await size();
|
|
22
|
+
await account().selectOption('b');
|
|
23
|
+
if (await account().inputValue() !== 'a' || !await account().isDisabled()) throw Error('Unconfirmed account committed');
|
|
24
|
+
const busy = await size();
|
|
25
|
+
await panel().getByRole('alert').waitFor();
|
|
26
|
+
const failed = await size();
|
|
27
|
+
if (await account().inputValue() !== 'a') throw Error('Failure changed account');
|
|
28
|
+
if (!((await page.getByTestId('request-count').textContent()) || '').endsWith('1')) throw Error('Duplicate request');
|
|
29
|
+
await panel().getByRole('button',{name:'重试切换'}).click();
|
|
30
|
+
await page.waitForFunction(()=>document.querySelector('[data-testid="current-account"]')?.textContent?.startsWith('协作账号 B'));
|
|
31
|
+
const success = await size();
|
|
32
|
+
if ([busy,failed,success].some(s=>Math.abs(s.h-before.h)>1 || Math.abs(s.content-before.content)>1)) throw Error(`Unstable heights ${JSON.stringify({before,busy,failed,success})}`);
|
|
33
|
+
if (await page.getByTestId('other-agent').textContent() !== other || await page.getByTestId('usage-summary').textContent() !== summary) throw Error('Other data changed');
|
|
34
|
+
await bounds();
|
|
35
|
+
await account().focus(); await page.keyboard.press('Escape'); await panel().waitFor({state:'detached',timeout:3000});
|
|
36
|
+
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='切换用量面板');
|
|
37
|
+
await page.keyboard.press('Enter'); await panel().waitFor();
|
|
38
|
+
await page.getByRole('button',{name:'切换用量面板'}).click(); await panel().waitFor({state:'detached',timeout:3000});
|
|
39
|
+
await open();
|
|
40
|
+
await page.mouse.click(5,5); await panel().waitFor({state:'detached',timeout:3000});
|
|
41
|
+
await open();
|
|
42
|
+
for (const state of ['loading','low','unknown','stale','connecting','unmatched','signed-out','shared']) {
|
|
43
|
+
await close(); await page.getByRole('combobox',{name:'用量场景'}).selectOption(state); await open();
|
|
44
|
+
if (['loading','stale','connecting','unmatched'].includes(state) && !await account().isDisabled()) throw Error(`Unavailable ${state}`);
|
|
45
|
+
if (state==='unknown' && await panel().getByRole('progressbar').count()) throw Error('Unknown shown as progress');
|
|
46
|
+
if (state==='stale' && !(await panel().textContent()).includes('上次数据 · 目录已过期')) throw Error('Cache not marked');
|
|
47
|
+
if (state==='signed-out' && /研究账号|协作账号|1.28|128|61%/.test(await panel().textContent())) throw Error('Logged out data leak');
|
|
48
|
+
if (state==='shared') {
|
|
49
|
+
await panel().getByText('2026-10 周期剩余 未知',{exact:true}).scrollIntoViewIfNeeded();
|
|
50
|
+
await page.getByTestId('usage-summary').scrollIntoViewIfNeeded();
|
|
51
|
+
const scrolled = await panel().evaluate(e=>e.scrollTop>0 && e.scrollHeight>e.clientHeight);
|
|
52
|
+
if (!scrolled) throw Error('Shared members not scrollable');
|
|
53
|
+
}
|
|
54
|
+
await bounds();
|
|
55
|
+
}
|
|
56
|
+
await close(); await page.getByRole('button',{name:'重置托盘示例'}).click(); await open();
|
|
57
|
+
if (await account().inputValue()!=='a' || await page.getByRole('checkbox',{name:'长账号名',exact:true}).isChecked()) throw Error('Reset failed');
|
|
58
|
+
results.push({theme,width,zoom,native,viewport:await page.evaluate(()=>({width:innerWidth,zoom:getComputedStyle(document.documentElement).zoom,dpr:devicePixelRatio})),pass:true,heights:{before,busy,failed,success}});
|
|
59
|
+
}
|
|
60
|
+
await page.emulateMedia({reducedMotion:'reduce'});
|
|
61
|
+
await page.goto(`${origin}/?fixture=tray-usage&motion=system`); await panel().waitFor();
|
|
62
|
+
const system = await panel().evaluate(e=>getComputedStyle(e).animationDuration);
|
|
63
|
+
await page.emulateMedia({reducedMotion:'no-preference'});
|
|
64
|
+
await page.goto(`${origin}/?fixture=tray-usage&motion=reduced`); await panel().waitFor();
|
|
65
|
+
const manual = await panel().evaluate(e=>getComputedStyle(e).animationDuration);
|
|
66
|
+
if (parseFloat(system) > 0.01 || parseFloat(manual)>0.01) throw Error('Reduced motion ignored');
|
|
67
|
+
return {pass:!errors.length,results,errors,reducedMotion:{system,manual}};
|
|
68
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Node 24. Creates a NEW isolated Vite consumer; never modifies an installed package.
|
|
2
|
+
// node scripts/prepare-settings-consumer.mjs candidate.tgz /new/consumer [--late-css]
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
const [tarball, destination, order] = process.argv.slice(2);
|
|
8
|
+
if (!tarball || !destination) throw new Error('Provide a fixed tarball and a new consumer directory.');
|
|
9
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const target = resolve(destination);
|
|
11
|
+
await mkdir(target); // Existing candidates are immutable; use a fresh directory.
|
|
12
|
+
const sourcePackage = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
|
|
13
|
+
await writeFile(resolve(target, 'package.json'), JSON.stringify({name:'settings-dialog-synthetic-consumer',version:'0.0.0',private:true,type:'module',scripts:{build:'vite build',preview:'vite preview'},dependencies:{'@linhey/rhine-design':`file:${resolve(tarball)}`,react:sourcePackage.devDependencies.react,'react-dom':sourcePackage.devDependencies['react-dom']},devDependencies:{vite:sourcePackage.devDependencies.vite,'@vitejs/plugin-react':sourcePackage.devDependencies['@vitejs/plugin-react']}}, null, 2));
|
|
14
|
+
await writeFile(resolve(target, 'index.html'), '<html><head><link rel="icon" href="data:,"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module" src="/main.tsx"></script></body></html>');
|
|
15
|
+
await writeFile(resolve(target, 'main.tsx'), `import React from 'react';\nimport {createRoot} from 'react-dom/client';\nimport {SettingsDialogFixture} from './SettingsDialogGallery';\n${order === '--late-css' ? "import '@linhey/rhine-design/styles.css?late';" : ''}\ncreateRoot(document.getElementById('root')!).render(<SettingsDialogFixture/>);`);
|
|
16
|
+
await writeFile(resolve(target, 'vite.config.ts'), "import {defineConfig} from 'vite';import react from '@vitejs/plugin-react';export default defineConfig({plugins:[react()]});");
|
|
17
|
+
const fixture = (await readFile(resolve(root, 'preview/SettingsDialogGallery.tsx'), 'utf8')).replaceAll("'../components'", "'@linhey/rhine-design'").replaceAll("'../components/theme'", "'@linhey/rhine-design/theme'");
|
|
18
|
+
await writeFile(resolve(target, 'SettingsDialogGallery.tsx'), fixture);
|
|
19
|
+
await writeFile(resolve(target, 'settings-dialog-gallery.css'), await readFile(resolve(root, 'preview/settings-dialog-gallery.css')));
|
|
20
|
+
execFileSync('npm', ['install'], {cwd:target,stdio:'inherit'});
|
|
21
|
+
execFileSync('npm', ['run','build'], {cwd:target,stdio:'inherit'});
|
|
@@ -17,6 +17,8 @@ Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different accoun
|
|
|
17
17
|
|
|
18
18
|
Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
|
|
19
19
|
|
|
20
|
+
Relative Markdown links stay inside this portable skill folder. Package-root paths are written as code, not as links relative to the installed skill. Any online contract fallback identifies a fixed source revision; it does not follow main or replace the installed version's contract.
|
|
21
|
+
|
|
20
22
|
## Route the task
|
|
21
23
|
|
|
22
24
|
- Screen design, interaction, typography, motion: start with [TASK_MAP.md](references/TASK_MAP.md), then read only the relevant contract.
|
|
@@ -4,6 +4,7 @@ All paths are relative to the matching package root. `package.json` exports, `co
|
|
|
4
4
|
|
|
5
5
|
| Exports | Source |
|
|
6
6
|
| --- | --- |
|
|
7
|
+
| ChatThread, ChatCard; ChatEntry, ChatMessage, ChatEvent, ChatMember, ChatStatus, ChatThreadProps, ChatCardProps (source candidate after 0.11.0) | `components/Chat.tsx`, `docs/AGENT_CHAT.md`; stable IDs, history anchor, explicit resume, readOnly action slots |
|
|
7
8
|
| Checkbox `layout="row"`, CheckboxProps(自 0.10.0) | `components/Forms.tsx`、`docs/CHECKBOX_ROWS.md`;整行双行多选,默认 form 保持兼容;npm 0.9.1 不含此选项 |
|
|
8
9
|
| TrendChart, StackedBar;StatusTimeline.detail(0.5.0) | `components/Charts.tsx`、`components/StatusTimeline.tsx`;`docs/APPLICATION_COMPONENTS.md`,未知不补零;键盘逐点查看、按ID受控高亮/详情、valueFormatter |
|
|
9
10
|
| Background, AppShell(0.5.0);AppShellToggle(0.8.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
|
|
@@ -59,3 +60,13 @@ API decisions that commonly matter:
|
|
|
59
60
|
Available since 0.8.0 (check installed exports): `ThemePicker` / `ThemePreview` in `components/ThemePicker.tsx`, contract `docs/THEME_PICKER.md`; `Dialog.navigation` in `components/Overlays.tsx`, contract `docs/SETTINGS_DIALOG.md`; shared floating CSS zoom behavior in `docs/FLOATING_OVERLAYS.md`.
|
|
60
61
|
|
|
61
62
|
Product review tooling (since 0.11.0): `/review` exports `defineReview`, URL helpers and scene/session types from `review/index.ts`; `/review/vite` exports Node-only `reviewPlugin` and `reviewProductionGuard` from `review/vite.mjs`, types in `review/vite.d.mts`. Never import the Vite entry into production UI. Read `docs/REVIEW_TOOL.md`; npm 0.10.0 does not contain these entries.
|
|
63
|
+
|
|
64
|
+
SideNav 行尾命令:`NavItem.actions` / `NavAction`,见 `docs/SIDE_NAV_ACTIONS.md`,真实展台 `#nav-actions`;未发布。
|
|
65
|
+
|
|
66
|
+
单输入即时搜索:TextField / TextFieldProps 的 `labelVisibility="hidden"` 保留非空名称;参见 `docs/SINGLE_SEARCH.md` 与 `#single-search`,未发布。搜索/异步与IME由宿主管理。
|
|
67
|
+
|
|
68
|
+
#41未发布:Button.contentAlign、Tabs.layout/TabsProps、Dialog.height/footer;先读 `docs/COMPACT_ACCOUNT_PATTERNS.md`、`docs/SETTINGS_DIALOG.md`、`docs/SUMMARY_TABS.md`,展台 `#compact-selection`。
|
|
69
|
+
|
|
70
|
+
#46未发布:Button.size="standard" | "large";动作语义先读 `docs/BUTTON_ACTIONS.md`,展台 `#button-actions`。
|
|
71
|
+
|
|
72
|
+
#18 未发布:Dialog `description?`、settings CSS次序独立、`height/footer/navigation` 固定头尾与正文/长导航独立滚动;短屏右对齐动作及真实消费验证见 `docs/SETTINGS_DIALOG.md`,展台 `#settings-dialog`。
|
|
@@ -4,6 +4,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
|
|
|
4
4
|
|
|
5
5
|
| Task | First reference (relative to package root) | Implementation / example |
|
|
6
6
|
| --- | --- | --- |
|
|
7
|
+
| Agent chat, member grouping, inline cards, observation and history scrolling | `docs/AGENT_CHAT.md` (source candidate after 0.11.0) | `components/Chat.tsx`, `preview/ChatGallery.tsx`; `/#agent-chat`; host owns transitions, requests and permissions |
|
|
7
8
|
| Application shell, backgrounds, media, advanced inputs, controlled tables | `docs/APPLICATION_COMPONENTS.md` (0.5.0) | `preview/ExtensionsGallery.tsx`; `/#app-shell`, `/#advanced-forms`, `/#advanced-data`, `/#media` |
|
|
8
9
|
| Workspace header, duplicate page titles, optional breadcrumbs | `docs/APPLICATION_COMPONENTS.md` → 平级工作区顶部操作区 (source guideline) | `preview/ExtensionsGallery.tsx`; `/#app-shell`; keep status/actions, navigation restore and accessible page identity |
|
|
9
10
|
| Product design review shell, synthetic HTTP/platform scenes, static deployment | `docs/REVIEW_TOOL.md` (since 0.11.0; not npm 0.10.0) | `review/`, `examples/review/http`, `examples/review/platform`; separate `/review` and `/review/vite` exports |
|
|
@@ -21,7 +21,7 @@ export function Example() {
|
|
|
21
21
|
|
|
22
22
|
## Integration invariants
|
|
23
23
|
|
|
24
|
-
Version 0.4.0 also exports `ThemeProvider`, `useTheme`, `themes`, `ThemeName` and `ThemeProviderProps` from `@linhey/rhine-design/theme` (not in npm 0.3.0).
|
|
24
|
+
Version 0.4.0 also exports `ThemeProvider`, `useTheme`, `themes`, `ThemeName` and `ThemeProviderProps` from `@linhey/rhine-design/theme` (not in npm 0.3.0). Read `docs/THEMING.md` from the verified package root. For a standalone skill installation without the package, see [the theme contract snapshot](https://github.com/linhay/rhine-design/blob/cfcb400fa213d9905eae1cab5dcb8ab8f817e03f/docs/THEMING.md) at commit `cfcb400fa213d9905eae1cab5dcb8ab8f817e03f` (0.10.0 source baseline). This fixed snapshot does not track main or establish availability in another installed version; prefer that version's own contract and implementation. This entry loads base and built-in theme CSS and renders the `.rhine-theme` root. Use `defaultTheme` for local state, or `theme` with `onThemeChange` for host state; storage and URL preferences remain with the host. Sibling providers are supported; nested providers throw. Do not wrap it in another themed root or key the subtree by theme. Gallery decoration is excluded from the public theme CSS.
|
|
25
25
|
|
|
26
26
|
Version 0.5.0 adds `Background` with canvas/content/sidebar/surface/overlay/media roles. `decoration="auto"` is the default: only canvas uses theme texture; content and AppShell default to plain backgrounds. Use `appearance` for local color/text/texture/size overrides and theme-root scene tokens for shared configuration. Background is paint only, not a new theme root or modal. Preview `#backgrounds` shows only the selected theme and follows the theme picker. Check installed exports before adopting this API; it is not in npm 0.4.0.
|
|
27
27
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { Button, ChatCard, ChatThread, type ChatEntry, type ChatMessage, type ChatStatus } from '../components';
|
|
4
|
+
import { ChatGallery } from '../preview/ChatGallery';
|
|
5
|
+
|
|
6
|
+
const msg = (id: string, minute: number, member = 'A', day = '今天'): ChatMessage => ({ id, kind: 'message', day, dateTime: `2026-10-27T10:${minute}:00+08:00`, time: `10:${minute}`, member: { id: member, name: member }, content: <p>{id}</p> });
|
|
7
|
+
afterEach(() => vi.useRealTimers());
|
|
8
|
+
describe('Chat composition', () => {
|
|
9
|
+
it('groups only adjacent same-member messages within five minutes and a day; events break groups', () => {
|
|
10
|
+
const { container } = render(<ChatThread label="聊天" entries={[msg('a', 10), msg('b', 11), { id: 'e', kind: 'event', day: '今天', content: '事件' }, msg('c', 12), msg('d', 13, 'B'), msg('f', 20, 'B'), msg('g', 21, 'B', '明天')]} />);
|
|
11
|
+
expect(container.querySelectorAll('[data-continuation=true]')).toHaveLength(1);
|
|
12
|
+
expect(screen.getByRole('article', { name: 'A · 10:11' })).toHaveTextContent('b');
|
|
13
|
+
expect(container.querySelectorAll('.rhine-chat-day')).toHaveLength(2);
|
|
14
|
+
});
|
|
15
|
+
it('preserves errors outside collapsed details and suppresses action slots in observation mode', () => {
|
|
16
|
+
const content = <ChatCard title="请求" status="failed" summary="来源不可用,请稍后核对" actions={<Button>接受</Button>}><p>完整依据</p></ChatCard>;
|
|
17
|
+
const entries: ChatEntry[] = [{ ...msg('a', 10), content, actions: <Button>重试</Button> }];
|
|
18
|
+
const { rerender } = render(<ChatThread label="聊天" readOnly entries={entries} />);
|
|
19
|
+
expect(screen.queryByRole('button', { name: '接受' })).not.toBeInTheDocument();
|
|
20
|
+
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument();
|
|
21
|
+
expect(screen.getByText('来源不可用,请稍后核对')).toBeVisible();
|
|
22
|
+
fireEvent.click(screen.getByText('请求 · 详情'));
|
|
23
|
+
expect(screen.getByText('完整依据')).toBeVisible();
|
|
24
|
+
rerender(<ChatThread label="聊天" entries={entries} />);
|
|
25
|
+
expect(screen.getByRole('button', { name: '接受' })).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
it('does not repeat a loading history request or remove the focused control', () => {
|
|
28
|
+
const load = vi.fn();
|
|
29
|
+
const { rerender } = render(<ChatThread label="聊天" entries={[]} hasEarlier onLoadEarlier={load} />);
|
|
30
|
+
const button = screen.getByRole('button', { name: '加载更早消息' }); button.focus(); fireEvent.click(button);
|
|
31
|
+
rerender(<ChatThread label="聊天" entries={[]} hasEarlier loadingEarlier onLoadEarlier={load} />);
|
|
32
|
+
fireEvent.click(button); expect(load).toHaveBeenCalledTimes(1); expect(button).toHaveFocus();
|
|
33
|
+
rerender(<ChatThread label="聊天" entries={[]} onLoadEarlier={load} historyError="合成失败" />);
|
|
34
|
+
fireEvent.click(button); expect(load).toHaveBeenCalledTimes(1); expect(button).toHaveFocus();
|
|
35
|
+
expect(screen.getByRole('alert')).toHaveTextContent('合成失败');
|
|
36
|
+
});
|
|
37
|
+
it('pauses on a disclosure and resumes only through the persistent follow control', () => {
|
|
38
|
+
render(<ChatThread label="聊天" entries={[{ ...msg('a', 10), content: <ChatCard title="详情" status="unknown" summary="尚未确认"><p>证据</p></ChatCard> }]} />);
|
|
39
|
+
fireEvent.click(screen.getByText('详情 · 详情'));
|
|
40
|
+
const follow = screen.getByRole('button', { name: '回到最新并跟随' });
|
|
41
|
+
expect(follow).toHaveAttribute('aria-pressed', 'false'); follow.focus(); fireEvent.click(follow);
|
|
42
|
+
expect(follow).toHaveAttribute('aria-pressed', 'true'); expect(follow).toHaveFocus();
|
|
43
|
+
});
|
|
44
|
+
it('announces new messages inserted before an existing stream without announcing prepended history', () => {
|
|
45
|
+
const entries = [msg('a', 10), msg('stream', 15)];
|
|
46
|
+
const { rerender } = render(<ChatThread label="聊天" entries={entries} />);
|
|
47
|
+
fireEvent.keyDown(screen.getByRole('region', { name: '聊天' }), { key: 'PageUp' });
|
|
48
|
+
rerender(<ChatThread label="聊天" entries={[msg('history', 9), ...entries]} />);
|
|
49
|
+
expect(screen.getByRole('status')).not.toHaveTextContent('有新消息');
|
|
50
|
+
rerender(<ChatThread label="聊天" entries={[msg('history', 9), entries[0], msg('new', 12), entries[1]]} />);
|
|
51
|
+
expect(screen.getByRole('status')).toHaveTextContent('有新消息');
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: '回到最新并跟随' }));
|
|
53
|
+
expect(screen.getByRole('status')).not.toHaveTextContent('有新消息');
|
|
54
|
+
});
|
|
55
|
+
it('keeps focus after a host action and treats unexpected runtime states as unknown', () => {
|
|
56
|
+
const { unmount } = render(<ChatGallery />);
|
|
57
|
+
const accept = screen.getByRole('button', { name: '接受请求' }); accept.focus(); fireEvent.click(accept);
|
|
58
|
+
expect(accept).toHaveFocus(); expect(accept).toHaveAttribute('aria-disabled', 'true');
|
|
59
|
+
expect(screen.getByText('已接受')).toBeVisible();
|
|
60
|
+
fireEvent.click(screen.getByRole('button', { name: '拒绝请求' }));
|
|
61
|
+
expect(screen.getByText('已接受')).toBeVisible(); unmount();
|
|
62
|
+
render(<ChatCard title="外部状态" status={'constructor' as ChatStatus} summary="未知来源" />);
|
|
63
|
+
expect(screen.getByText('状态未知')).toBeVisible();
|
|
64
|
+
});
|
|
65
|
+
it('disconnects ResizeObserver on unmount', () => {
|
|
66
|
+
const disconnect = vi.fn();
|
|
67
|
+
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = disconnect; });
|
|
68
|
+
const { unmount } = render(<ChatThread label="聊天" entries={[]} />); unmount();
|
|
69
|
+
expect(disconnect).toHaveBeenCalledOnce(); vi.unstubAllGlobals();
|
|
70
|
+
});
|
|
71
|
+
it('cancels synthetic streaming without late text or success and clears timers on unmount', () => {
|
|
72
|
+
vi.useFakeTimers();
|
|
73
|
+
const { unmount } = render(<ChatGallery />);
|
|
74
|
+
fireEvent.click(screen.getByRole('button', { name: '播放合成流式记录' }));
|
|
75
|
+
act(() => vi.advanceTimersByTime(440));
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: '取消演示生成' }));
|
|
77
|
+
const before = screen.getByRole('region', { name: '合成聊天消息' }).textContent;
|
|
78
|
+
act(() => vi.advanceTimersByTime(6000));
|
|
79
|
+
expect(screen.getByRole('region', { name: '合成聊天消息' }).textContent).toBe(before);
|
|
80
|
+
expect(screen.getByText('生成已取消;保留部分内容,未确认成功。')).toBeVisible();
|
|
81
|
+
fireEvent.click(screen.getByRole('button', { name: '加载更早消息' }));
|
|
82
|
+
unmount(); expect(vi.getTimerCount()).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { Button, Dialog, Tabs } from '../components';
|
|
4
|
+
import { CompactSelectionGallery } from '../preview/CompactSelectionGallery';
|
|
5
|
+
afterEach(()=>{vi.useRealTimers();vi.restoreAllMocks();});
|
|
6
|
+
describe('compact selection contracts',()=>{
|
|
7
|
+
it('keeps button native semantics and makes tabs layout independent of status',()=>{
|
|
8
|
+
const {rerender}=render(<><Button contentAlign="between">名称<span>箭头</span></Button><Tabs label="页签" value="a" onValueChange={()=>{}} items={[{value:'a',label:'甲',status:'运行',content:'内容'}]}/></>);
|
|
9
|
+
expect(screen.getByRole('button')).toHaveClass('rhine-button--between');expect(screen.getByRole('button')).not.toHaveAttribute('contentAlign');
|
|
10
|
+
expect(screen.getByRole('tablist')).toHaveClass('rhine-tabs__list--content');
|
|
11
|
+
rerender(<Tabs label="页签" layout="fill" value="a" onValueChange={()=>{}} items={[{value:'a',label:'甲',content:'内容'}]}/>);
|
|
12
|
+
expect(screen.getByRole('tablist')).toHaveClass('rhine-tabs__list--fill');
|
|
13
|
+
});
|
|
14
|
+
it('renders footer outside content scroll with opt-in height',()=>{
|
|
15
|
+
render(<Dialog trigger={<Button>打开</Button>} title="选择" description="草稿" height={560} footer={<Button>保存</Button>}><p>内容</p></Dialog>);
|
|
16
|
+
fireEvent.click(screen.getByText('打开'));const dialog=screen.getByRole('dialog');
|
|
17
|
+
expect(dialog).toHaveClass('rhine-dialog--bounded');expect(dialog.style.getPropertyValue('--rhine-dialog-height')).toBe('560px');
|
|
18
|
+
expect(screen.getByText('保存').closest('.rhine-dialog__scroll')).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
it('discards draft on close, prevents duplicate save/close while saving and retains failures',()=>{
|
|
21
|
+
vi.useFakeTimers();render(<CompactSelectionGallery/>);
|
|
22
|
+
const trigger=screen.getByRole('button',{name:'选择 合成对象 A 的授权分组'});
|
|
23
|
+
fireEvent.click(trigger);let dialog=screen.getByRole('dialog',{name:'分组选择'});
|
|
24
|
+
fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'beta'}});
|
|
25
|
+
expect(within(dialog).getByText('已确认:示例组')).toBeInTheDocument();
|
|
26
|
+
fireEvent.click(within(dialog).getByRole('button',{name:'关闭对话框'}));
|
|
27
|
+
fireEvent.click(trigger);dialog=screen.getByRole('dialog',{name:'分组选择'});
|
|
28
|
+
expect(within(dialog).getByLabelText('候选分组')).toHaveValue('alpha');
|
|
29
|
+
fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'beta'}});
|
|
30
|
+
const save=within(dialog).getByRole('button',{name:'保存'});fireEvent.click(save);fireEvent.click(save);
|
|
31
|
+
fireEvent.click(within(dialog).getByRole('button',{name:'关闭对话框'}));expect(dialog).toBeInTheDocument();
|
|
32
|
+
act(()=>vi.advanceTimersByTime(700));expect(within(dialog).getByText('已确认:联合研究组')).toBeInTheDocument();expect(save).toBeDisabled();
|
|
33
|
+
fireEvent.click(within(dialog).getByRole('button',{name:'关闭对话框'}));
|
|
34
|
+
fireEvent.change(screen.getByLabelText('组合场景'),{target:{value:'error'}});fireEvent.click(trigger);
|
|
35
|
+
dialog=screen.getByRole('dialog',{name:'分组选择'});fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'none'}});
|
|
36
|
+
fireEvent.click(within(dialog).getByRole('button',{name:'保存'}));act(()=>vi.advanceTimersByTime(700));
|
|
37
|
+
expect(within(dialog).getByText('已确认:联合研究组')).toBeInTheDocument();expect(within(dialog).getByLabelText('候选分组')).toHaveValue('none');
|
|
38
|
+
expect(within(dialog).getByRole('status')).toHaveTextContent('保存失败');
|
|
39
|
+
});
|
|
40
|
+
it('cleans host timers on unmount',()=>{
|
|
41
|
+
vi.useFakeTimers();const timer=vi.spyOn(globalThis,'setTimeout'),clear=vi.spyOn(globalThis,'clearTimeout');const {unmount}=render(<CompactSelectionGallery/>);
|
|
42
|
+
fireEvent.click(screen.getByRole('button',{name:'选择 合成对象 A 的授权分组'}));const dialog=screen.getByRole('dialog');
|
|
43
|
+
fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'beta'}});fireEvent.click(within(dialog).getByRole('button',{name:'保存'}));
|
|
44
|
+
const index=timer.mock.calls.findIndex(call=>call[1]===700);expect(index).toBeGreaterThanOrEqual(0);
|
|
45
|
+
const pendingTimer=timer.mock.results[index].value;unmount();expect(clear).toHaveBeenCalledWith(pendingTimer);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { SideNav } from '../components';
|
|
4
|
+
import { NavActionsGallery } from '../preview/NavActionsGallery';
|
|
5
|
+
describe('SideNav trailing actions', () => {
|
|
6
|
+
it('omits empty actions and keeps action triggers outside links', async () => {
|
|
7
|
+
const navigate = vi.fn(), action = vi.fn();
|
|
8
|
+
render(<SideNav currentId="a" onNavigate={navigate} groups={[{label:'项目', items:[
|
|
9
|
+
{id:'a', label:'甲', href:'#a', actions:[{id:'details',label:'详情',onSelect:action}]},
|
|
10
|
+
{id:'b', label:'乙', href:'#b', actions:[]}, {id:'c',label:'丙',href:'#c'},
|
|
11
|
+
]}]} />);
|
|
12
|
+
const trigger = screen.getByRole('button', {name:'甲:更多操作'});
|
|
13
|
+
expect(trigger.closest('a')).toBeNull(); expect(screen.getAllByRole('button')).toHaveLength(1);
|
|
14
|
+
expect(screen.getByRole('link', {name:'乙'}).parentElement).toHaveTextContent('乙');
|
|
15
|
+
fireEvent.keyDown(trigger, {key:'Enter'});
|
|
16
|
+
fireEvent.click(await screen.findByRole('menuitem', {name:'详情'}));
|
|
17
|
+
expect(navigate).not.toHaveBeenCalled(); expect(action).toHaveBeenCalledWith(trigger);
|
|
18
|
+
});
|
|
19
|
+
it('opens a controlled dialog and returns focus to the originating more button', async () => {
|
|
20
|
+
render(<NavActionsGallery />);
|
|
21
|
+
const trigger = screen.getByRole('button', {name:'合成工作区:更多操作'});
|
|
22
|
+
trigger.focus(); fireEvent.keyDown(trigger, {key:'Enter'});
|
|
23
|
+
fireEvent.click(await screen.findByRole('menuitem', {name:'查看详情'}));
|
|
24
|
+
expect(await screen.findByRole('dialog', {name:'工作区详情'})).toBeInTheDocument();
|
|
25
|
+
fireEvent.click(screen.getByRole('button', {name:'关闭对话框'}));
|
|
26
|
+
await waitFor(() => expect(trigger).toHaveFocus());
|
|
27
|
+
expect(screen.getByRole('link', {name:'合成工作区'})).toHaveAttribute('aria-current','page');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import { expect, it } from 'vitest';
|
|
1
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
3
3
|
import { SettingsDialogGallery } from '../preview/SettingsDialogGallery';
|
|
4
|
-
import {
|
|
4
|
+
import { ThemeProvider } from '../components/theme';
|
|
5
|
+
import { Button, Dialog, Drawer, Tooltip } from '../components';
|
|
5
6
|
|
|
6
7
|
it('keeps host state and URL while changing settings sections and explicitly saving', async () => {
|
|
7
|
-
render(<SettingsDialogGallery
|
|
8
|
+
render(<ThemeProvider><SettingsDialogGallery /></ThemeProvider>);
|
|
8
9
|
const url = location.href;
|
|
9
10
|
fireEvent.change(screen.getByRole('textbox', {name:'宿主查询'}), {target:{value:'已有查询'}});
|
|
10
11
|
fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
|
|
@@ -13,7 +14,7 @@ it('keeps host state and URL while changing settings sections and explicitly sav
|
|
|
13
14
|
fireEvent.click(screen.getByRole('link', {name:'账号'}));
|
|
14
15
|
expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue('修改后的名称');
|
|
15
16
|
expect(location.href).toBe(url);
|
|
16
|
-
fireEvent.click(screen.getByRole('button', {name:'
|
|
17
|
+
fireEvent.click(screen.getByRole('button', {name:'保存当前工作区设置'}));
|
|
17
18
|
await screen.findByText('已保存演示草稿');
|
|
18
19
|
expect(screen.getByRole('dialog', {name:'设置'})).toBeVisible();
|
|
19
20
|
fireEvent.click(screen.getByRole('button', {name:'关闭对话框'}));
|
|
@@ -22,12 +23,12 @@ it('keeps host state and URL while changing settings sections and explicitly sav
|
|
|
22
23
|
fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
|
|
23
24
|
expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue('修改后的名称');
|
|
24
25
|
fireEvent.change(screen.getByRole('textbox', {name:'工作区名称'}), {target:{value:'丢弃'}});
|
|
25
|
-
fireEvent.click(screen.getByRole('button', {name:'
|
|
26
|
+
fireEvent.click(screen.getByRole('button', {name:'撤销全部未保存的修改'}));
|
|
26
27
|
expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue('修改后的名称');
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
it('closes only the focused nested dialog on an immediate Escape', async () => {
|
|
30
|
-
render(<SettingsDialogGallery
|
|
31
|
+
render(<ThemeProvider><SettingsDialogGallery /></ThemeProvider>);
|
|
31
32
|
fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
|
|
32
33
|
fireEvent.click(screen.getByRole('link', {name:'用户管理与权限'}));
|
|
33
34
|
fireEvent.click(screen.getByRole('button', {name:'编辑成员 12'}));
|
|
@@ -52,10 +53,10 @@ it('lets the topmost tooltip consume Escape before closing its dialog', async ()
|
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
it.each([false, true])('reconciles pending saves on reopen without overwriting newer edits (edited=%s)', async edited => {
|
|
55
|
-
render(<SettingsDialogGallery
|
|
56
|
+
render(<ThemeProvider><SettingsDialogGallery /></ThemeProvider>);
|
|
56
57
|
fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
|
|
57
58
|
fireEvent.change(screen.getByRole('textbox', {name:'工作区名称'}), {target:{value:'保存中的名称'}});
|
|
58
|
-
fireEvent.click(screen.getByRole('button', {name:'
|
|
59
|
+
fireEvent.click(screen.getByRole('button', {name:'保存当前工作区设置'}));
|
|
59
60
|
fireEvent.click(screen.getByRole('button', {name:'关闭对话框'}));
|
|
60
61
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
61
62
|
fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
|
|
@@ -64,3 +65,95 @@ it.each([false, true])('reconciles pending saves on reopen without overwriting n
|
|
|
64
65
|
expect(screen.getByText('已保存名称:保存中的名称')).toBeVisible();
|
|
65
66
|
expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue(edited ? '重新打开后编辑' : '保存中的名称');
|
|
66
67
|
});
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
it.each([undefined, null, false, ''])('omits absent description without a dangling relationship (%s)', description => {
|
|
71
|
+
render(<Dialog trigger={<Button>Open</Button>} title="Settings" description={description} height={640} footer={<Button>Save</Button>} navigation={<a href="#a">Account</a>}><p>Form</p></Dialog>);
|
|
72
|
+
fireEvent.click(screen.getByText('Open'));
|
|
73
|
+
const dialog = screen.getByRole('dialog');
|
|
74
|
+
expect(dialog).not.toHaveAttribute('aria-describedby');
|
|
75
|
+
expect(dialog.querySelector('.rhine-dialog__main > p')).toBeNull();
|
|
76
|
+
expect(screen.getByRole('region', {name:'Settings内容'})).toHaveTextContent('Form');
|
|
77
|
+
expect(screen.getByText('Save').closest('.rhine-dialog__scroll')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps optional descriptions associated and ordinary Dialog/Drawer natural', () => {
|
|
81
|
+
const {rerender} = render(<Dialog trigger={<Button>Open</Button>} title="Short" description="Decision context"><p>Form</p></Dialog>);
|
|
82
|
+
fireEvent.click(screen.getByText('Open'));
|
|
83
|
+
let dialog = screen.getByRole('dialog');
|
|
84
|
+
expect(document.getElementById(dialog.getAttribute('aria-describedby')!)).toHaveTextContent('Decision context');
|
|
85
|
+
expect(dialog).not.toHaveClass('rhine-dialog--bounded');
|
|
86
|
+
rerender(<Drawer trigger={<Button>Open</Button>} title="Drawer"><p>Form</p></Drawer>);
|
|
87
|
+
fireEvent.click(screen.getByText('Open'));
|
|
88
|
+
dialog = screen.getByRole('dialog');
|
|
89
|
+
expect(dialog).not.toHaveClass('rhine-dialog--bounded');
|
|
90
|
+
expect(dialog).not.toHaveAttribute('aria-describedby');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); document.documentElement.style.zoom = ''; });
|
|
95
|
+
it.each(['error', 'conflict'])('retains the host draft after %s and reconciles only explicit success', outcome => {
|
|
96
|
+
vi.useFakeTimers();
|
|
97
|
+
render(<ThemeProvider><SettingsDialogGallery /></ThemeProvider>);
|
|
98
|
+
fireEvent.change(screen.getByLabelText('保存结果'), {target:{value:outcome}});
|
|
99
|
+
fireEvent.click(screen.getByText('打开设置'));
|
|
100
|
+
const dialog = screen.getByRole('dialog');
|
|
101
|
+
const draft = within(dialog).getByLabelText('工作区名称');
|
|
102
|
+
fireEvent.change(draft, {target:{value:'未提交草稿'}});
|
|
103
|
+
const save = within(dialog).getByRole('button', {name:'保存当前工作区设置'});
|
|
104
|
+
fireEvent.click(save); fireEvent.click(save);
|
|
105
|
+
expect(save).toHaveAttribute('aria-busy', 'true');
|
|
106
|
+
expect(save).toHaveAttribute('aria-disabled', 'true');
|
|
107
|
+
act(() => vi.advanceTimersByTime(500));
|
|
108
|
+
expect(draft).toHaveValue('未提交草稿');
|
|
109
|
+
expect(screen.getByText('已保存名称:研究工作区')).toBeInTheDocument();
|
|
110
|
+
expect(within(dialog).getByRole('alert')).toHaveTextContent(outcome === 'error' ? '保存失败' : '内容已更新');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('cleans pending host requests and ignores an obsolete async section', () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
const clear = vi.spyOn(globalThis, 'clearTimeout'), timer = vi.spyOn(globalThis, 'setTimeout');
|
|
116
|
+
const {unmount} = render(<ThemeProvider><SettingsDialogGallery /></ThemeProvider>);
|
|
117
|
+
fireEvent.click(screen.getByText('打开设置'));
|
|
118
|
+
fireEvent.click(screen.getByRole('link', {name:'异步栏目'}));
|
|
119
|
+
expect(screen.getByText('正在读取栏目')).toBeInTheDocument();
|
|
120
|
+
fireEvent.click(screen.getByRole('link', {name:'账号'}));
|
|
121
|
+
act(() => vi.advanceTimersByTime(500));
|
|
122
|
+
expect(screen.queryByText('异步栏目已就绪')).not.toBeInTheDocument();
|
|
123
|
+
fireEvent.click(screen.getByRole('button', {name:'保存当前工作区设置'}));
|
|
124
|
+
const hostTimers = timer.mock.calls.flatMap((call, i) => call[1] === 500 ? [timer.mock.results[i].value] : []);
|
|
125
|
+
unmount();
|
|
126
|
+
expect(hostTimers).toHaveLength(2);
|
|
127
|
+
for (const id of hostTimers) expect(clear).toHaveBeenCalledWith(id);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
it('gives only the actual bounded body a named keyboard entry', () => {
|
|
132
|
+
const props = {trigger:<Button>Open</Button>, title:'Read', height:400, open:true, onOpenChange:() => {}};
|
|
133
|
+
const {rerender} = render(<Dialog {...props}><p>Long text</p></Dialog>);
|
|
134
|
+
expect(screen.getByRole('region', {name:'Read内容'})).toHaveClass('rhine-dialog__body');
|
|
135
|
+
expect(screen.getByRole('region')).toHaveAttribute('tabindex', '0');
|
|
136
|
+
rerender(<Dialog {...props} navigation={<a href="#a">Account</a>}><p>Long text</p></Dialog>);
|
|
137
|
+
expect(screen.getAllByRole('region')).toHaveLength(1);
|
|
138
|
+
expect(screen.getByRole('region')).toHaveClass('rhine-dialog__settings-content');
|
|
139
|
+
expect(screen.getByRole('dialog').querySelector('.rhine-dialog__body')).not.toHaveAttribute('tabindex');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it.each([undefined, 'auto'] as const)('caps an auto-height footer (%s) against the zoomed viewport and cleans observation', async initialHeight => {
|
|
143
|
+
vi.stubGlobal('innerHeight', 800);
|
|
144
|
+
const remove = vi.spyOn(window, 'removeEventListener');
|
|
145
|
+
const fixture = (height?: number | 'auto') => <Dialog trigger={<Button>Open</Button>} title="Auto" open onOpenChange={() => {}} height={height} footer={<Button>Action</Button>}><p>Text</p></Dialog>;
|
|
146
|
+
const {rerender,unmount} = render(fixture(initialHeight));
|
|
147
|
+
const dialog = screen.getByRole('dialog');
|
|
148
|
+
const limit = () => parseFloat(dialog.style.getPropertyValue('--rhine-dialog-footer-limit'));
|
|
149
|
+
expect(limit()).toBeCloseTo(307.2);
|
|
150
|
+
document.documentElement.style.zoom = '2';
|
|
151
|
+
fireEvent(window, new Event('resize'));
|
|
152
|
+
expect(limit()).toBeCloseTo(147.2);
|
|
153
|
+
vi.stubGlobal('innerHeight', 600); fireEvent(window, new Event('resize'));
|
|
154
|
+
expect(limit()).toBeCloseTo(107.2);
|
|
155
|
+
rerender(fixture(400));
|
|
156
|
+
expect(dialog.style.getPropertyValue('--rhine-dialog-footer-limit')).toBe('');
|
|
157
|
+
expect(remove).toHaveBeenCalledWith('resize', expect.any(Function));
|
|
158
|
+
unmount();
|
|
159
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { TextField } from '../components';
|
|
4
|
+
import { SingleSearchGallery } from '../preview/SingleSearchGallery';
|
|
5
|
+
|
|
6
|
+
afterEach(() => vi.useRealTimers());
|
|
7
|
+
describe('single search contract', () => {
|
|
8
|
+
it('hides only the label visually, retains native props and rejects an empty name', () => {
|
|
9
|
+
const { rerender } = render(<TextField label="搜索" labelVisibility="hidden" aria-label=" " type="search" name="q" required />);
|
|
10
|
+
const input = screen.getByRole('searchbox', { name: '搜索' });
|
|
11
|
+
expect(input).toBeRequired(); expect(input).toHaveAttribute('name', 'q');
|
|
12
|
+
expect(screen.getByText('搜索')).toHaveClass('rhine-sr-only');
|
|
13
|
+
expect(input).not.toHaveAttribute('labelVisibility');
|
|
14
|
+
rerender(<TextField label="搜索" hint="提示" error="失败" disabled />);
|
|
15
|
+
expect(screen.getByLabelText('搜索')).toHaveAccessibleDescription('提示 失败');
|
|
16
|
+
expect(screen.getByText('搜索')).not.toHaveClass('rhine-sr-only');
|
|
17
|
+
expect(() => render(<TextField label=" " labelVisibility="hidden" />)).toThrow('non-empty label');
|
|
18
|
+
});
|
|
19
|
+
it('debounces committed composition and clears without moving focus or accepting stale results', () => {
|
|
20
|
+
vi.useFakeTimers(); render(<SingleSearchGallery />);
|
|
21
|
+
const input = screen.getByRole('searchbox'); input.focus();
|
|
22
|
+
fireEvent.compositionStart(input); fireEvent.change(input, { target: { value: '中文' } });
|
|
23
|
+
act(() => vi.advanceTimersByTime(1000)); expect(screen.getByRole('status')).toBeEmptyDOMElement();
|
|
24
|
+
fireEvent.keyDown(input, { key: 'Escape', isComposing: true }); expect(input).toHaveValue('中文');
|
|
25
|
+
fireEvent.compositionEnd(input); act(() => vi.advanceTimersByTime(250)); expect(screen.getByRole('status')).toHaveTextContent('正在搜索');
|
|
26
|
+
fireEvent.keyDown(input, { key: 'Escape' }); act(() => vi.advanceTimersByTime(1000));
|
|
27
|
+
expect(input).toHaveValue(''); expect(input).toHaveFocus(); expect(screen.getByRole('status')).toBeEmptyDOMElement();
|
|
28
|
+
fireEvent.change(input, { target: { value: '旧查询' } }); act(() => vi.advanceTimersByTime(250));
|
|
29
|
+
fireEvent.change(input, { target: { value: '新查询' } }); act(() => vi.advanceTimersByTime(750));
|
|
30
|
+
expect(screen.getByRole('status')).toHaveTextContent('新查询'); expect(screen.getByRole('status')).not.toHaveTextContent('旧查询');
|
|
31
|
+
});
|
|
32
|
+
it('announces errors and cancels pending work on disabling or unmount', () => {
|
|
33
|
+
vi.useFakeTimers(); const { unmount } = render(<SingleSearchGallery />);
|
|
34
|
+
fireEvent.change(screen.getByLabelText('搜索场景'), { target: { value: 'error' } });
|
|
35
|
+
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '合成' } }); act(() => vi.advanceTimersByTime(750));
|
|
36
|
+
expect(screen.getByRole('searchbox')).toHaveAccessibleDescription('搜索失败,请修改查询后重试。');
|
|
37
|
+
expect(screen.getByRole('searchbox')).toHaveAttribute('aria-invalid', 'true');
|
|
38
|
+
fireEvent.change(screen.getByLabelText('搜索场景'), { target: { value: 'disabled' } });
|
|
39
|
+
expect(screen.getByRole('searchbox')).toBeDisabled(); expect(screen.getByRole('status')).toBeEmptyDOMElement();
|
|
40
|
+
unmount(); expect(vi.getTimerCount()).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|