@linhey/rhine-design 0.11.0 → 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 +35 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +9 -4
- package/components/Actions.tsx +1 -0
- package/components/AdvancedForms.tsx +13 -5
- package/components/AppShell.tsx +3 -2
- package/components/COMMON_COMPONENTS.md +5 -1
- package/components/Chat.tsx +160 -0
- package/components/Forms.tsx +66 -6
- 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/common.css +2 -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/components/useFloatingScale.ts +7 -3
- package/docs/AGENT_CHAT.md +76 -0
- package/docs/AGENT_CHAT_VERIFICATION.md +42 -0
- package/docs/APPLICATION_COMPONENTS.md +4 -2
- 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 +20 -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/DenseFilterControlsGallery.tsx +5 -2
- 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-combobox-modal-focus.js +22 -0
- package/scripts/check-combobox-zoom.js +109 -0
- package/scripts/check-compact-selection.js +40 -0
- package/scripts/check-dialog-scroll-boundaries.js +41 -0
- package/scripts/check-multiselect-completion.js +46 -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/FormParity.test.tsx +104 -2
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const source = resolve(process.env.RHINE_SKILL_SOURCE ?? 'skills/rhine-design');
|
|
7
|
+
const sandboxes: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const sandbox of sandboxes.splice(0)) rmSync(sandbox, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function install(fromPackage: boolean) {
|
|
14
|
+
const sandbox = mkdtempSync(join(tmpdir(), 'rhine-skill-'));
|
|
15
|
+
sandboxes.push(sandbox);
|
|
16
|
+
const target = join(sandbox, fromPackage ? '.agents/skills/rhine-design' : 'skills/rhine-design');
|
|
17
|
+
if (fromPackage) {
|
|
18
|
+
const packaged = join(sandbox, 'node_modules/@linhey/rhine-design/skills/rhine-design');
|
|
19
|
+
cpSync(source, packaged, { recursive: true });
|
|
20
|
+
// Same source and destination layout as the README's cp -R command.
|
|
21
|
+
cpSync(packaged, target, { recursive: true });
|
|
22
|
+
} else {
|
|
23
|
+
// Only the skill directory is available: no adjacent repository docs.
|
|
24
|
+
cpSync(source, target, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkLinks(root: string) {
|
|
30
|
+
let localLinks = 0;
|
|
31
|
+
for (const file of readdirSync(root, { recursive: true, encoding: 'utf8' }).filter(file => file.endsWith('.md'))) {
|
|
32
|
+
const document = join(root, file);
|
|
33
|
+
const markdown = readFileSync(document, 'utf8');
|
|
34
|
+
// The skill uses inline Markdown links; code-formatted package paths are not links.
|
|
35
|
+
for (const [, href] of markdown.matchAll(/\[[^\]]*\]\(([^\s)]+)\)/g)) {
|
|
36
|
+
if (/^https?:\/\//.test(href)) {
|
|
37
|
+
const url = new URL(href);
|
|
38
|
+
if (url.hostname === 'github.com' && /^\/linhay\/rhine-design\/(blob|tree)\//.test(url.pathname)) {
|
|
39
|
+
const revision = url.pathname.split('/')[4];
|
|
40
|
+
if (!/^(?:[a-f0-9]{40}|v?\d+\.\d+\.\d+)$/.test(revision)) throw new Error(`Floating source link: ${href}`);
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const [path, fragment] = href.split('#');
|
|
45
|
+
const target = path ? resolve(dirname(document), decodeURIComponent(path)) : document;
|
|
46
|
+
const within = relative(root, target);
|
|
47
|
+
if (isAbsolute(within) || within === '..' || within.startsWith('../')) throw new Error(`Link escapes skill: ${href}`);
|
|
48
|
+
if (!existsSync(target)) throw new Error(`Missing link target: ${href}`);
|
|
49
|
+
const realWithin = relative(realpathSync(root), realpathSync(target));
|
|
50
|
+
if (isAbsolute(realWithin) || realWithin === '..' || realWithin.startsWith('../')) throw new Error(`Link escapes skill: ${href}`);
|
|
51
|
+
if (fragment) {
|
|
52
|
+
const headings = [...readFileSync(target, 'utf8').matchAll(/^#{1,6}\s+(.+)$/gm)]
|
|
53
|
+
.map(([, heading]) => heading.toLowerCase().replace(/[^\p{L}\p{N}_\-\s]/gu, '').replace(/\s/g, '-'));
|
|
54
|
+
if (!headings.includes(decodeURIComponent(fragment))) throw new Error(`Missing heading: ${href}`);
|
|
55
|
+
}
|
|
56
|
+
localLinks++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return localLinks;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('portable skill installation', () => {
|
|
63
|
+
it.each([true, false])('keeps links reachable outside the repository (package copy: %s)', fromPackage => {
|
|
64
|
+
expect(checkLinks(install(fromPackage))).toBeGreaterThan(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it.each([
|
|
68
|
+
['../../../docs/THEMING.md', 'Link escapes skill'],
|
|
69
|
+
['missing.md', 'Missing link target'],
|
|
70
|
+
['../SKILL.md#missing-heading', 'Missing heading'],
|
|
71
|
+
['https://github.com/linhay/rhine-design/blob/main/docs/THEMING.md', 'Floating source link'],
|
|
72
|
+
])('rejects an invalid installed reference: %s', (href, error) => {
|
|
73
|
+
const root = install(false);
|
|
74
|
+
writeFileSync(join(root, 'references/regression.md'), `[contract](${href})\n`);
|
|
75
|
+
expect(() => checkLinks(root)).toThrow(error);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { Button, Popover } from '../components';
|
|
4
|
+
import { ThemeProvider } from '../components/theme';
|
|
5
|
+
import { TrayUsageGallery } from '../preview/TrayUsageGallery';
|
|
6
|
+
|
|
7
|
+
afterEach(() => vi.useRealTimers());
|
|
8
|
+
function demo() { return render(<ThemeProvider><TrayUsageGallery /></ThemeProvider>); }
|
|
9
|
+
const account = () => screen.getByRole('combobox', { name: 'Agent A 当前账号' });
|
|
10
|
+
const panel = () => screen.getByRole('dialog', { name: '账号与用量概览' });
|
|
11
|
+
|
|
12
|
+
describe('headerless Popover and synthetic usage host', () => {
|
|
13
|
+
it('preserves the accessible name and default header compatibility', () => {
|
|
14
|
+
const { rerender } = render(<ThemeProvider><Popover title="详情名称" trigger={<Button>详情</Button>} open>内容</Popover></ThemeProvider>);
|
|
15
|
+
expect(screen.getByRole('button', { name: '关闭详情' })).toBeVisible();
|
|
16
|
+
rerender(<ThemeProvider><Popover title="详情名称" showHeader={false} trigger={<Button>详情</Button>} open>内容</Popover></ThemeProvider>);
|
|
17
|
+
expect(screen.getByRole('dialog', { name: '详情名称' })).toBeVisible();
|
|
18
|
+
expect(screen.queryByRole('button', { name: '关闭详情' })).toBeNull();
|
|
19
|
+
expect(screen.queryByText('详情名称')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('keeps committed account and unrelated data while pending, locks duplicates, and retries a failure', () => {
|
|
22
|
+
demo(); vi.useFakeTimers();
|
|
23
|
+
fireEvent.click(screen.getByLabelText('首次切换失败'));
|
|
24
|
+
const other = screen.getByTestId('other-agent').textContent, summary = screen.getByTestId('usage-summary').textContent;
|
|
25
|
+
fireEvent.change(account(), { target: { value: 'b' } });
|
|
26
|
+
fireEvent.change(account(), { target: { value: 'b' } });
|
|
27
|
+
expect(account()).toBeDisabled(); expect(account()).toHaveValue('a');
|
|
28
|
+
expect(screen.getByTestId('current-account')).toHaveTextContent('研究账号 A');
|
|
29
|
+
expect(screen.getByTestId('request-count')).toHaveTextContent('1');
|
|
30
|
+
act(() => vi.advanceTimersByTime(1200));
|
|
31
|
+
expect(account()).toHaveValue('a'); expect(account()).not.toBeDisabled();
|
|
32
|
+
expect(within(panel()).getByRole('alert')).toHaveTextContent('切换失败');
|
|
33
|
+
fireEvent.click(screen.getByRole('button', { name: '重试切换' }));
|
|
34
|
+
act(() => vi.advanceTimersByTime(1200));
|
|
35
|
+
expect(account()).toHaveValue('b');
|
|
36
|
+
expect(screen.getByTestId('request-count')).toHaveTextContent('2');
|
|
37
|
+
expect(screen.getByTestId('other-agent').textContent).toBe(other);
|
|
38
|
+
expect(screen.getByTestId('usage-summary').textContent).toBe(summary);
|
|
39
|
+
});
|
|
40
|
+
it('blocks unavailable directories and cancels a late result on logout/reset', () => {
|
|
41
|
+
demo(); vi.useFakeTimers();
|
|
42
|
+
for (const state of ['loading', 'stale', 'connecting', 'unmatched']) {
|
|
43
|
+
fireEvent.change(screen.getByLabelText('用量场景'), { target: { value: state } });
|
|
44
|
+
expect(account()).toBeDisabled();
|
|
45
|
+
fireEvent.change(account(), { target: { value: 'b' } });
|
|
46
|
+
expect(screen.getByTestId('request-count')).toHaveTextContent('0');
|
|
47
|
+
}
|
|
48
|
+
fireEvent.change(screen.getByLabelText('用量场景'), { target: { value: 'normal' } });
|
|
49
|
+
fireEvent.change(account(), { target: { value: 'b' } });
|
|
50
|
+
fireEvent.change(screen.getByLabelText('用量场景'), { target: { value: 'signed-out' } });
|
|
51
|
+
act(() => vi.advanceTimersByTime(1200));
|
|
52
|
+
expect(within(panel()).queryByText(/研究账号|协作账号|1.28|128/)).toBeNull();
|
|
53
|
+
expect(screen.queryByTestId('usage-summary')).toBeNull();
|
|
54
|
+
fireEvent.click(screen.getByRole('button', { name: '重置托盘示例' }));
|
|
55
|
+
expect(account()).toHaveValue('a');
|
|
56
|
+
fireEvent.change(account(), { target: { value: 'b' } });
|
|
57
|
+
fireEvent.click(screen.getByRole('button', { name: '重置托盘示例' }));
|
|
58
|
+
act(() => vi.advanceTimersByTime(1200));
|
|
59
|
+
expect(account()).toHaveValue('a'); expect(screen.getByTestId('request-count')).toHaveTextContent('0');
|
|
60
|
+
});
|
|
61
|
+
it('keeps unknown quota distinct from zero and stale cache explicitly marked', () => {
|
|
62
|
+
demo();
|
|
63
|
+
fireEvent.change(screen.getByLabelText('用量场景'), { target: { value: 'unknown' } });
|
|
64
|
+
expect(within(panel()).getByText('未知')).toBeVisible();
|
|
65
|
+
expect(within(panel()).queryByRole('progressbar')).toBeNull();
|
|
66
|
+
fireEvent.change(screen.getByLabelText('用量场景'), { target: { value: 'stale' } });
|
|
67
|
+
expect(within(panel()).getByText('上次数据 · 目录已过期')).toBeVisible();
|
|
68
|
+
expect(screen.getByTestId('usage-summary')).toHaveTextContent('上次更新');
|
|
69
|
+
});
|
|
70
|
+
});
|