@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,82 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Button, Checkbox, Divider, IconButton, Inline, Metric, Notice, Popover, Progress, Select, Skeleton, Stack, Text } from '../components';
|
|
3
|
+
import { ThemeProvider, type ThemeName } from '../components/theme';
|
|
4
|
+
import './tray-usage.css';
|
|
5
|
+
|
|
6
|
+
type Scenario = 'normal' | 'loading' | 'low' | 'unknown' | 'stale' | 'connecting' | 'unmatched' | 'signed-out' | 'shared';
|
|
7
|
+
type Feedback = 'idle' | 'busy' | 'error' | 'success';
|
|
8
|
+
const accounts = [{ id: 'a', name: '研究账号 A', remaining: 78 }, { id: 'b', name: '协作账号 B', remaining: 42 }];
|
|
9
|
+
|
|
10
|
+
// A synthetic host, not a product account service or a public tray component.
|
|
11
|
+
export function TrayUsageGallery() {
|
|
12
|
+
const [open, setOpen] = useState(true), [scenario, setScenario] = useState<Scenario>('normal');
|
|
13
|
+
const [long, setLong] = useState(false), [failOnce, setFailOnce] = useState(false);
|
|
14
|
+
const [current, setCurrent] = useState('a'), [feedback, setFeedback] = useState<Feedback>('idle');
|
|
15
|
+
const [target, setTarget] = useState(''), [requests, setRequests] = useState(0);
|
|
16
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
17
|
+
const pending = useRef(false), attempts = useRef(0), epoch = useRef(0);
|
|
18
|
+
useEffect(() => () => { clearTimeout(timer.current); epoch.current++; }, []);
|
|
19
|
+
const cancel = () => { clearTimeout(timer.current); epoch.current++; pending.current = false; setFeedback('idle'); setTarget(''); };
|
|
20
|
+
const reset = () => { cancel(); attempts.current = 0; setCurrent('a'); setRequests(0); setScenario('normal'); setLong(false); setFailOnce(false); setOpen(true); };
|
|
21
|
+
const unavailable = ['loading','stale','connecting','unmatched','signed-out'].includes(scenario);
|
|
22
|
+
const switchAccount = (next: string) => {
|
|
23
|
+
if (pending.current || unavailable || next === current || !accounts.some(a => a.id === next)) return;
|
|
24
|
+
pending.current = true; setTarget(next); setFeedback('busy'); setRequests(n => n + 1);
|
|
25
|
+
const request = ++epoch.current, shouldFail = failOnce && attempts.current++ === 0;
|
|
26
|
+
timer.current = setTimeout(() => {
|
|
27
|
+
if (request !== epoch.current) return;
|
|
28
|
+
pending.current = false;
|
|
29
|
+
if (shouldFail) setFeedback('error');
|
|
30
|
+
else { setCurrent(next); setFeedback('success'); }
|
|
31
|
+
}, 1200);
|
|
32
|
+
};
|
|
33
|
+
const account = accounts.find(a => a.id === current)!;
|
|
34
|
+
const name = (value: string) => long ? `${value} · 跨地区协作研究工作空间与长期项目的完整账号名称` : value;
|
|
35
|
+
const remaining = scenario === 'unknown' ? null : scenario === 'low' ? 4 : account.remaining;
|
|
36
|
+
return <Stack>
|
|
37
|
+
<Text as="h2" variant="section">托盘用量组合</Text><Text>浏览器浮层 · 合成数据。默认展开;宿主拥有账号、请求和数据时效。</Text>
|
|
38
|
+
<Select label="用量场景" value={scenario} onChange={e => { cancel(); setScenario(e.target.value as Scenario); setOpen(true); }}>
|
|
39
|
+
<option value="normal">正常</option><option value="loading">加载</option><option value="low">低额度</option><option value="unknown">未知额度</option><option value="stale">目录过期 / 刷新失败</option><option value="connecting">连接处理中</option><option value="unmatched">连接未匹配</option><option value="signed-out">未登录</option><option value="shared">共享多账号</option>
|
|
40
|
+
</Select>
|
|
41
|
+
<Inline><Checkbox label="长账号名" checked={long} onChange={e => { setLong(e.target.checked); setOpen(true); }} /><Checkbox label="首次切换失败" checked={failOnce} onChange={e => { cancel(); attempts.current = 0; setFailOnce(e.target.checked); setOpen(true); }} /><Button variant="secondary" onClick={reset}>重置托盘示例</Button></Inline>
|
|
42
|
+
<Text variant="caption" data-testid="request-count">合成切换请求:{requests}</Text>
|
|
43
|
+
<div className="preview-tray-anchor"><Popover title="账号与用量概览" showHeader={false} open={open} onOpenChange={setOpen} side="bottom" trigger={<IconButton icon="chart" label="切换用量面板" />}>
|
|
44
|
+
<Stack gap={3} data-testid="tray-panel">
|
|
45
|
+
{scenario === 'signed-out' ? <Notice title="尚未登录">登录后显示当前账号和用量。</Notice> : <>
|
|
46
|
+
<section aria-label="当前 Agent 账号" aria-busy={feedback === 'busy'}>
|
|
47
|
+
<Stack gap={2}>
|
|
48
|
+
<Select label="Agent A 当前账号" value={scenario === 'unmatched' ? '' : current} disabled={unavailable || feedback === 'busy'} onChange={e => switchAccount(e.target.value)}>{scenario === 'unmatched' && <option value="">未匹配账号</option>}{accounts.map(a => <option key={a.id} value={a.id}>{name(a.name)}</option>)}</Select>
|
|
49
|
+
{scenario === 'loading' ? <Skeleton label="正在读取当前账号额度" lines={2} /> : scenario === 'unmatched' ? <Notice title="连接未匹配">当前连接尚未关联账号,额度不可用。</Notice> : <>
|
|
50
|
+
<Text data-testid="current-account">{name(account.name)}</Text>
|
|
51
|
+
<Metric label="本周期剩余额度" value={remaining === null ? '未知' : `${remaining}%`} description="2026-10-01 至 2026-10-31 · UTC+8" />
|
|
52
|
+
{remaining !== null && <Progress label="周期剩余" value={remaining} />}
|
|
53
|
+
</>}
|
|
54
|
+
<div className="preview-tray-feedback" data-testid="switch-feedback">
|
|
55
|
+
{(['idle','busy','error','success'] as const).map(status => <div key={status} style={{ visibility: feedback === status ? 'visible' : 'hidden' }} aria-hidden={feedback !== status}>
|
|
56
|
+
<Text role={feedback === status ? (status === 'error' ? 'alert' : 'status') : undefined}>{status === 'busy' ? '正在切换目标账号,确认前保留原账号及额度。' : status === 'error' ? '切换失败,原账号及额度保持不变。' : status === 'success' ? '切换已确认。其他 Agent 和统计保持不变。' : '选择账号即可切换,确认后更新额度。'}</Text>
|
|
57
|
+
{status === 'error' && <Button variant="secondary" disabled={feedback !== 'error' || unavailable} onClick={() => switchAccount(target)}>重试切换</Button>}
|
|
58
|
+
</div>)}
|
|
59
|
+
</div>
|
|
60
|
+
</Stack>
|
|
61
|
+
</section>
|
|
62
|
+
{scenario === 'low' && <Notice tone="warning" title="当前周期剩余额度较低" />}
|
|
63
|
+
{scenario === 'unknown' && <Notice title="额度暂不可用">未返回额度;未知不等于零。</Notice>}
|
|
64
|
+
{scenario === 'stale' && <Notice tone="warning" title="上次数据 · 目录已过期">刷新失败,暂时无法切换。以下为上次可信缓存。</Notice>}
|
|
65
|
+
{scenario === 'connecting' && <Notice title="连接正在处理">完成后才能切换账号。</Notice>}
|
|
66
|
+
{scenario !== 'loading' && <section aria-label="其他 Agent" data-testid="other-agent"><Text>Agent B · 独立演示账号</Text><Text>本周期剩余 61% · 2026-10</Text></section>}
|
|
67
|
+
{scenario === 'shared' && <section aria-label="共享成员额度"><Stack gap={2}><Text as="h3" variant="heading">共享成员</Text><Text tone="muted">同一上游的多个连接共用额度,不累加百分比。</Text>{Array.from({length:12},(_,i)=><div key={i}><Text>{name(`成员账号 ${i+1}`)}</Text><Text>2026-10 周期剩余 {i === 11 ? '未知' : `${78-i*4}%`}</Text></div>)}</Stack></section>}
|
|
68
|
+
<Divider />
|
|
69
|
+
<section aria-label="今日用量" data-testid="usage-summary"><Stack gap={2}>
|
|
70
|
+
<Metric label="今日预估消费" value="1.28" unit="USD" loading={scenario === 'loading'} />
|
|
71
|
+
{scenario === 'loading' ? <Skeleton label="正在读取今日统计" lines={2} /> : <><Text>请求数 128 · 待计价 3</Text><Text variant="caption" tone="muted">{scenario === 'stale' ? '上次更新' : '更新时间'}:2026-10-27 20:30 UTC+8</Text></>}
|
|
72
|
+
</Stack></section>
|
|
73
|
+
</>}
|
|
74
|
+
</Stack>
|
|
75
|
+
</Popover></div>
|
|
76
|
+
</Stack>;
|
|
77
|
+
}
|
|
78
|
+
export function TrayUsageFixture() {
|
|
79
|
+
const params = new URLSearchParams(location.search), zoom = Number(params.get('zoom')) || 1;
|
|
80
|
+
useEffect(() => { document.documentElement.style.zoom = String(zoom); return () => { document.documentElement.style.zoom = ''; }; }, [zoom]);
|
|
81
|
+
return <ThemeProvider theme={(params.get('theme') || 'rhine') as ThemeName} data-rhine-motion={params.get('motion') || 'system'}><div style={{ padding: 16 }}><TrayUsageGallery /></div></ThemeProvider>;
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.chat-gallery-canvas { max-width:100%; min-width:0; }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
.ds-compact-selection { display:grid; gap:16px; }
|
|
2
|
+
.ds-compact-selection__cards { display:grid; gap:12px; }
|
|
3
|
+
.ds-choice-card { padding:12px; display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; }
|
|
4
|
+
.ds-choice-card__info { display:grid; gap:4px; flex:1 1 240px; min-width:0; }
|
|
5
|
+
.ds-choice-card__info h3 { margin:0; }
|
|
6
|
+
.ds-choice-card__status { display:inline-flex; min-width:64px; min-height:32px; }
|
|
7
|
+
.ds-inline-choice { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
8
|
+
.ds-inline-choice>.rhine-text { font-size:var(--rhine-text-body); line-height:var(--rhine-leading-body); font-weight:var(--rhine-weight-label); }
|
|
9
|
+
.ds-choice-card__actions { display:flex; align-items:center; gap:8px; flex:none; }
|
package/preview/main.tsx
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { SettingsCompositionGallery, SettingsCompositionFixture } from './SettingsCompositionGallery';
|
|
2
|
+
import { TrayUsageGallery, TrayUsageFixture } from './TrayUsageGallery';
|
|
3
|
+
import { ChatGallery } from './ChatGallery';
|
|
1
4
|
import { BackgroundFiltersFixture } from './BackgroundFiltersFixture';
|
|
2
5
|
import { StrictMode, useEffect, useRef, useState } from 'react';
|
|
3
6
|
import { createRoot } from 'react-dom/client';
|
|
@@ -27,6 +30,10 @@ import { CompactFilterBarGallery } from './CompactFilterBarGallery';
|
|
|
27
30
|
import { DenseFilterControlsGallery } from './DenseFilterControlsGallery';
|
|
28
31
|
import { CheckboxRowGallery } from './CheckboxRowGallery';
|
|
29
32
|
import { ComponentTypographyGallery } from './ComponentTypographyGallery';
|
|
33
|
+
import { NavActionsGallery } from './NavActionsGallery';
|
|
34
|
+
import { SingleSearchGallery } from './SingleSearchGallery';
|
|
35
|
+
import { CompactSelectionGallery } from './CompactSelectionGallery';
|
|
36
|
+
import { ButtonActionsGallery } from './ButtonActionsGallery';
|
|
30
37
|
import { SummaryTabsGallery } from './SummaryTabsGallery';
|
|
31
38
|
|
|
32
39
|
function Gallery() {
|
|
@@ -48,6 +55,8 @@ function Gallery() {
|
|
|
48
55
|
const simulate = () => { setBusy(true); timer.current = setTimeout(() => { setBusy(false); setNotice(true); }, 900); };
|
|
49
56
|
return <GalleryShell motion={motion} onMotionChange={setMotion}>{section => <>
|
|
50
57
|
{section === 'theme-picker' && <ThemePickerGallery />}
|
|
58
|
+
{section === 'tray-usage' && <TrayUsageGallery />}
|
|
59
|
+
{section === 'settings-composition' && <SettingsCompositionGallery />}
|
|
51
60
|
{section === 'settings-dialog' && <SettingsDialogGallery />}
|
|
52
61
|
{section === 'overlay-bounds' && <OverlayBoundsGallery />}
|
|
53
62
|
{section === 'monitoring' && <MonitoringGallery />}
|
|
@@ -57,6 +66,11 @@ function Gallery() {
|
|
|
57
66
|
{section === 'compact-filters' && <><CompactFilterBarGallery /><DenseFilterControlsGallery /></>}
|
|
58
67
|
{section === 'checkbox-rows' && <CheckboxRowGallery />}
|
|
59
68
|
{section === 'component-typography' && <ComponentTypographyGallery />}
|
|
69
|
+
{section === 'nav-actions' && <NavActionsGallery />}
|
|
70
|
+
{section === 'agent-chat' && <ChatGallery />}
|
|
71
|
+
{section === 'single-search' && <SingleSearchGallery />}
|
|
72
|
+
{section === 'compact-selection' && <CompactSelectionGallery />}
|
|
73
|
+
{section === 'button-actions' && <ButtonActionsGallery />}
|
|
60
74
|
{section === 'summary-tabs' && <SummaryTabsGallery />}
|
|
61
75
|
{section === 'backgrounds' && <BackgroundGallery />}
|
|
62
76
|
{section === 'app-shell' && <ShellGallery />}
|
|
@@ -149,4 +163,4 @@ function Gallery() {
|
|
|
149
163
|
|
|
150
164
|
}
|
|
151
165
|
|
|
152
|
-
createRoot(document.getElementById('root')!).render(<StrictMode>{['orbit-entry', 'list-filters'].includes(new URLSearchParams(location.search).get('fixture') || '') ? <BackgroundFiltersFixture /> : new URLSearchParams(location.search).get('fixture') === 'overlay-bounds' ? <OverlayBoundsFixture /> : new URLSearchParams(location.search).get('fixture') === 'settings-dialog' ? <SettingsDialogFixture /> : new URLSearchParams(location.search).get('fixture') === 'theme-picker' ? <ThemePickerFixture /> : <Gallery />}</StrictMode>);
|
|
166
|
+
createRoot(document.getElementById('root')!).render(<StrictMode>{['orbit-entry', 'list-filters'].includes(new URLSearchParams(location.search).get('fixture') || '') ? <BackgroundFiltersFixture /> : new URLSearchParams(location.search).get('fixture') === 'overlay-bounds' ? <OverlayBoundsFixture /> : new URLSearchParams(location.search).get('fixture') === 'settings-dialog' ? <SettingsDialogFixture /> : new URLSearchParams(location.search).get('fixture') === 'theme-picker' ? <ThemePickerFixture /> : new URLSearchParams(location.search).get('fixture') === 'settings-composition' ? <SettingsCompositionFixture /> : new URLSearchParams(location.search).get('fixture') === 'tray-usage' ? <TrayUsageFixture /> : <Gallery />}</StrictMode>);
|
package/preview/packageInfo.ts
CHANGED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
.preview-tray-anchor { display:flex; justify-content:center; }
|
|
2
|
+
.preview-tray-feedback { display:grid; }
|
|
3
|
+
/* Every state contributes to the same intrinsic height, including wrapped error + retry. */
|
|
4
|
+
.preview-tray-feedback > div { grid-area:1 / 1; display:flex; flex-direction:column; align-items:flex-start; gap:var(--rhine-space-2); }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
async page=>{
|
|
2
|
+
page.setDefaultTimeout(8000);const failures=[],errors=[],samples=[];const origin=new URL(page.url()).origin;
|
|
3
|
+
const contrast=({color,bg})=>{const luminance=c=>c.match(/[\d.]+/g).slice(0,3).map(Number).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4}).reduce((sum,v,i)=>sum+v*[.2126,.7152,.0722][i],0);const values=[luminance(color),luminance(bg)].sort((a,b)=>a-b);return (values[1]+.05)/(values[0]+.05);};
|
|
4
|
+
page.on('pageerror',e=>errors.push(String(e)));const check=(ok,ctx)=>{if(!ok)failures.push(ctx)};
|
|
5
|
+
for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,520,880])for(const zoom of [1,2]){
|
|
6
|
+
const context={theme,width,zoom};await page.setViewportSize({width,height:1000});await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#button-actions`);
|
|
7
|
+
await page.getByLabel('按钮容器宽度').selectOption(String(width));await page.evaluate(z=>document.documentElement.style.zoom=String(z),zoom);
|
|
8
|
+
const demo=page.locator('[data-button-actions]');const buttons=demo.locator('button');
|
|
9
|
+
const dims=()=>buttons.evaluateAll(es=>es.map(e=>{const s=getComputedStyle(e);return {w:e.offsetWidth,h:e.offsetHeight,size:s.fontSize,line:s.lineHeight,pad:s.paddingInlineStart,large:e.classList.contains('rhine-button--large'),fit:e.scrollWidth<=e.clientWidth+1}}));
|
|
10
|
+
const normal=await dims();check(normal.every(x=>x.h>=(x.large?52:44)&&x.size===(x.large?'16px':'14px')&&x.line===(x.large?'24px':'22px')&&x.pad===(x.large?'24px':'16px')&&x.fit),{...context,kind:'size-contract',normal});
|
|
11
|
+
for(const state of ['disabled','busy']){await page.getByLabel('按钮状态').selectOption(state);const ds=await dims();check(ds.every((x,i)=>x.w===normal[i].w&&x.h===normal[i].h),{...context,kind:`stable-${state}`});if(state==='disabled')check(await buttons.first().isDisabled(),{...context,kind:'disabled'});else check(await buttons.first().getAttribute('aria-busy')==='true',{...context,kind:'busy'});}
|
|
12
|
+
await page.getByLabel('按钮状态').selectOption('normal');
|
|
13
|
+
const logout=demo.getByRole('button',{name:'退出登录',exact:true}).nth(1);await logout.focus();await page.keyboard.press('Enter');await page.keyboard.press('Space');
|
|
14
|
+
await page.getByRole('status').filter({hasText:'large退出登录'}).waitFor();check(await logout.evaluate(e=>document.activeElement===e),{...context,kind:'keyboard-focus'});
|
|
15
|
+
const disconnect=demo.getByRole('button',{name:'断开连接',exact:true}).nth(1);await disconnect.focus();await page.keyboard.press('Space');await page.getByRole('status').filter({hasText:'large断开连接'}).waitFor();
|
|
16
|
+
for(const label of ['退出登录','断开连接','删除记录']){
|
|
17
|
+
const button=demo.getByRole('button',{name:label,exact:true}).nth(1);await page.mouse.move(0,0);await page.waitForTimeout(350);const normalColor=await button.evaluate(e=>({color:getComputedStyle(e).color,bg:getComputedStyle(e).backgroundColor}));check(contrast(normalColor)>=4.5,{...context,kind:`contrast-default-${label}`,normalColor,ratio:contrast(normalColor)});await button.hover();await page.waitForTimeout(350);const color=await button.evaluate(e=>({color:getComputedStyle(e).color,bg:getComputedStyle(e).backgroundColor}));
|
|
18
|
+
check(contrast(color)>=4.5,{...context,kind:`contrast-hover-${label}`,color,ratio:contrast(color)});
|
|
19
|
+
await page.mouse.down();check(await button.evaluate(e=>e.matches(':active')),{...context,kind:`active-${label}`});await page.mouse.move(0,0);await page.mouse.up();
|
|
20
|
+
await button.focus();await page.keyboard.press('Tab');await page.keyboard.press('Shift+Tab');
|
|
21
|
+
check(await button.evaluate(e=>e.matches(':focus-visible')&&(getComputedStyle(e).outlineStyle!=='none'||getComputedStyle(e).boxShadow!=='none')),{...context,kind:`focus-${label}`});
|
|
22
|
+
samples.push({...context,label,color});
|
|
23
|
+
}
|
|
24
|
+
await page.getByLabel('按钮状态').selectOption('long');check(await demo.evaluate(e=>e.scrollWidth<=e.clientWidth+1),{...context,kind:'long-container'});check((await dims()).every(x=>x.fit),{...context,kind:'long-text'});
|
|
25
|
+
await page.getByLabel('按钮状态').selectOption('normal');await demo.getByRole('button',{name:'删除记录',exact:true}).nth(1).click();
|
|
26
|
+
const dialog=page.getByRole('alertdialog',{name:'删除合成记录'});await dialog.waitFor();await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
|
|
27
|
+
if(width===880&&zoom===1)await demo.screenshot({path:`output/playwright/buttons-${theme}.png`});
|
|
28
|
+
}
|
|
29
|
+
for(const theme of ['rhine','arknights','neo-brutalism'])for(const mode of ['system','manual']){
|
|
30
|
+
await page.emulateMedia({reducedMotion:mode==='system'?'reduce':'no-preference'});await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#button-actions`);
|
|
31
|
+
if(mode==='manual')await page.getByRole('radio',{name:'减少动态',exact:true}).check();await page.locator('[data-button-actions]').getByRole('button',{name:'退出登录',exact:true}).first().press('Enter');await page.getByRole('status').filter({hasText:'合成操作完成'}).waitFor();
|
|
32
|
+
}
|
|
33
|
+
return {pass:!failures.length&&!errors.length,failures,errors,samples};
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
async page => {
|
|
2
|
+
page.setDefaultTimeout(10000);
|
|
3
|
+
const origin=new URL(page.url()).origin, failures=[],errors=[],samples=[];
|
|
4
|
+
page.on('pageerror',e=>errors.push(String(e)));
|
|
5
|
+
const check=(ok,ctx)=>{if(!ok)failures.push(ctx)};
|
|
6
|
+
for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,520,880])for(const zoom of [1,2]){
|
|
7
|
+
const context={theme,width,zoom};
|
|
8
|
+
await page.setViewportSize({width,height:1000});await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#compact-selection`);
|
|
9
|
+
await page.getByLabel('组合宽度').selectOption(String(width));await page.evaluate(z=>document.documentElement.style.zoom=String(z),zoom);
|
|
10
|
+
const cards=page.locator('[data-card]');
|
|
11
|
+
const measure=()=>cards.evaluateAll(es=>es.map(e=>({height:e.getBoundingClientRect().height,fit:e.scrollWidth<=e.clientWidth+1,widths:[...e.querySelectorAll('.ds-choice-card__actions button')].map(b=>b.offsetWidth)})));
|
|
12
|
+
const before=await measure();check(Math.abs(before[0].height-before[1].height)<1&&before.every(x=>x.fit&&x.widths[0]===64&&x.widths[1]===112),{...context,kind:'equal-cards-actions',before});
|
|
13
|
+
await page.getByRole('tab',{name:'备用区',exact:true}).focus();await page.keyboard.press('Home');await page.keyboard.press('End');
|
|
14
|
+
await page.getByRole('button',{name:'切换操作',exact:true}).click();await page.getByRole('menuitem',{name:'设为当前项',exact:true}).click();
|
|
15
|
+
const frames=await page.evaluate(()=>new Promise(resolve=>{const data=[],start=performance.now();function frame(){data.push([...document.querySelectorAll('[data-card]')].map(e=>e.getBoundingClientRect().height));if(performance.now()-start<900)requestAnimationFrame(frame);else resolve(data)}requestAnimationFrame(frame)}));
|
|
16
|
+
check(frames.every(f=>f.every((height,i)=>Math.abs(height-before[i].height)<1)),{...context,kind:'card-frame-stability',range:frames.slice(0,2).concat(frames.slice(-2))});
|
|
17
|
+
check(await page.getByRole('tab',{name:'备用区',exact:true}).getAttribute('aria-selected')==='true',{...context,kind:'tab-identity'});
|
|
18
|
+
const trigger=page.getByRole('button',{name:'选择 合成对象 B 的授权分组'});await trigger.click();const dialog=page.getByRole('dialog',{name:'分组选择'});await dialog.waitFor();await dialog.evaluate(e=>Promise.all(e.getAnimations().map(a=>a.finished)));
|
|
19
|
+
const box=await dialog.boundingBox();
|
|
20
|
+
await page.evaluate(()=>{window.__frames=[];window.__sampling=true;function frame(){const e=document.querySelector('[role=dialog]');if(e){const r=e.getBoundingClientRect();window.__frames.push({height:r.height,y:r.y});}if(window.__sampling)requestAnimationFrame(frame)}requestAnimationFrame(frame)});
|
|
21
|
+
for(const value of ['four','last','empty','loading','error','two'])await dialog.getByLabel('预览内容').selectOption(value);
|
|
22
|
+
await dialog.getByLabel('候选分组').selectOption('beta');check(await dialog.getByText('已确认:示例组').count()===1,{...context,kind:'preview-only'});
|
|
23
|
+
const save=dialog.getByRole('button',{name:'保存',exact:true});await save.click();await page.keyboard.press('Escape');check(await dialog.count()===1,{...context,kind:'busy-blocks-close'});
|
|
24
|
+
await dialog.getByText('已确认:联合研究组').waitFor();check(await save.isDisabled(),{...context,kind:'confirmed-save'});
|
|
25
|
+
const heights=await page.evaluate(()=>{window.__sampling=false;return window.__frames});
|
|
26
|
+
check(heights.every(r=>Math.abs(r.height-box.height)<1&&Math.abs(r.y-box.y)<1),{...context,kind:'dialog-frame-stability',box,range:heights.slice(0,1).concat(heights.slice(-1))});
|
|
27
|
+
const footer=await save.boundingBox();const viewport=await page.evaluate(()=>({width:innerWidth,height:innerHeight}));
|
|
28
|
+
check(footer.y>=0&&footer.y+footer.height<=viewport.height+1&&footer.x>=0&&footer.x+footer.width<=viewport.width+1,{...context,kind:'footer-reachable',footer,viewport});
|
|
29
|
+
await dialog.getByLabel('候选分组').selectOption('none');await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
|
|
30
|
+
await trigger.click();check(await dialog.getByLabel('候选分组').inputValue()==='beta',{...context,kind:'discard-draft'});
|
|
31
|
+
await dialog.getByRole('button',{name:'关闭对话框'}).click();await dialog.waitFor({state:'detached'});await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.startsWith('选择 '));
|
|
32
|
+
await page.getByLabel('组合场景').selectOption('error');await trigger.click();await dialog.getByLabel('候选分组').selectOption('none');await dialog.getByRole('button',{name:'保存',exact:true}).click();await dialog.getByText('保存失败,请重试;确认值未改变。').waitFor();check(await dialog.getByText('已确认:联合研究组').count()===1,{...context,kind:'failure-preserves-confirmed'});
|
|
33
|
+
await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
|
|
34
|
+
await page.getByLabel('组合场景').selectOption('long');check((await measure()).every(x=>x.fit),{...context,kind:'long-fit'});
|
|
35
|
+
for(const layout of ['fill','content']){await page.getByLabel('页签布局').selectOption(layout);check(await page.getByRole('tablist',{name:'选择摘要'}).evaluate(el=>el.scrollWidth<=el.clientWidth+1),{...context,kind:`tabs-${layout}`});}
|
|
36
|
+
if(width===400&&zoom===2){await cards.first().scrollIntoViewIfNeeded();await page.screenshot({path:`output/playwright/compact-${theme}.png`});}
|
|
37
|
+
samples.push(context);
|
|
38
|
+
}
|
|
39
|
+
return {pass:!failures.length&&!errors.length,failures,errors,samples};
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Additional public API boundaries: footer without height, and text-only bounded body.
|
|
2
|
+
async page => {
|
|
3
|
+
const origin=page.url().split('/').slice(0,3).join('/'),results=[];
|
|
4
|
+
for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,880])for(const zoom of [1,2]) {
|
|
5
|
+
const c={theme,width,zoom};
|
|
6
|
+
try {
|
|
7
|
+
await page.setViewportSize({width,height:648});
|
|
8
|
+
await page.goto(`${origin}/?fixture=settings-dialog&theme=${theme}&zoom=${zoom}`);
|
|
9
|
+
await page.getByRole('button',{name:'自动高度操作区示例'}).click();
|
|
10
|
+
const auto=page.getByRole('dialog',{name:'自动高度操作区'});
|
|
11
|
+
const initial=await auto.evaluate(e=>{
|
|
12
|
+
const footer=e.querySelector('.rhine-dialog__footer'),body=e.querySelector('.rhine-dialog__body');
|
|
13
|
+
return {root:e.getBoundingClientRect().toJSON(),head:e.querySelector('.rhine-dialog__head').getBoundingClientRect().toJSON(),footer:footer.getBoundingClientRect().toJSON(),body:body.getBoundingClientRect().toJSON(),footerScrollHeight:footer.scrollHeight,footerClientHeight:footer.clientHeight};
|
|
14
|
+
});
|
|
15
|
+
await auto.getByRole('button',{name:'长操作 20',exact:true}).focus();
|
|
16
|
+
const after=await auto.evaluate(e=>{
|
|
17
|
+
const footer=e.querySelector('.rhine-dialog__footer'),r=footer.getBoundingClientRect(),last=footer.querySelector('button:last-child').getBoundingClientRect();
|
|
18
|
+
return {head:e.querySelector('.rhine-dialog__head').getBoundingClientRect().toJSON(),lastInside:last.top>=r.top-1&&last.bottom<=r.bottom+1,scroll:footer.scrollTop,rootScroll:e.scrollTop};
|
|
19
|
+
});
|
|
20
|
+
const autoPass=initial.body.height>0&&initial.footer.height<=(648-32*zoom)*.4+1&&initial.footerScrollHeight>initial.footerClientHeight&&after.lastInside&&after.scroll>0&&after.rootScroll===0&&Math.abs(after.head.y-initial.head.y)<1;
|
|
21
|
+
// Ancestor zoom changes while open must update the local-pixel cap.
|
|
22
|
+
for(const changedZoom of [zoom===1?2:1,zoom]) {
|
|
23
|
+
await page.evaluate(z=>document.documentElement.style.zoom=String(z),changedZoom);
|
|
24
|
+
await page.waitForFunction(z=>{const e=document.querySelector('[role=dialog]');return e&&Math.abs(parseFloat(e.style.getPropertyValue('--rhine-dialog-footer-limit'))-(innerHeight/z-32)*.4)<1;},changedZoom);
|
|
25
|
+
}
|
|
26
|
+
await page.keyboard.press('Escape');await auto.waitFor({state:'detached'});
|
|
27
|
+
await page.getByRole('button',{name:'纯文本长正文示例'}).click();
|
|
28
|
+
const text=page.getByRole('dialog',{name:'纯文本长正文'}),region=text.getByRole('region',{name:'纯文本长正文内容'});
|
|
29
|
+
const named=await region.count()===1;
|
|
30
|
+
let keyboard=false;
|
|
31
|
+
if(named) {
|
|
32
|
+
await text.getByRole('button',{name:'关闭对话框'}).focus();await page.keyboard.press('Tab');
|
|
33
|
+
keyboard=await region.evaluate(e=>document.activeElement===e);
|
|
34
|
+
await page.keyboard.press('End');
|
|
35
|
+
await page.waitForFunction(()=>{const e=document.querySelector('[aria-label="纯文本长正文内容"]');return e&&e.scrollTop>0&&e.scrollHeight-e.clientHeight-e.scrollTop<2;});
|
|
36
|
+
}
|
|
37
|
+
results.push({...c,pass:autoPass&&named&&keyboard,autoPass,named,keyboard,initial,after});
|
|
38
|
+
}catch(error){results.push({...c,pass:false,error:String(error)});}
|
|
39
|
+
}
|
|
40
|
+
return {pass:results.every(r=>r.pass),results};
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
async page => {
|
|
2
|
+
page.setDefaultTimeout(8000);
|
|
3
|
+
const failures=[], errors=[], samples=[];
|
|
4
|
+
page.on('pageerror',e=>errors.push(String(e)));
|
|
5
|
+
const origin=new URL(page.url()).origin;
|
|
6
|
+
const check=(ok,context)=>{if(!ok)failures.push(context)};
|
|
7
|
+
for(const theme of ['rhine','arknights','neo-brutalism'])for(const width of [400,520,880])for(const zoom of [1,2]){
|
|
8
|
+
await page.setViewportSize({width:1100,height:1000});
|
|
9
|
+
await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#nav-actions`);
|
|
10
|
+
await page.getByLabel('导航容器宽度').selectOption(String(width));
|
|
11
|
+
await page.getByLabel('导航标题').selectOption('long');
|
|
12
|
+
await page.evaluate(z=>document.documentElement.style.zoom=String(z),zoom);
|
|
13
|
+
const nav=page.getByRole('navigation',{name:'行操作示例'}),trigger=nav.getByRole('button');
|
|
14
|
+
const context={theme,width,zoom};
|
|
15
|
+
check(await trigger.count()===1,{...context,kind:'empty-actions'});
|
|
16
|
+
const bounds=await trigger.evaluate(el=>({height:el.offsetHeight,width:el.offsetWidth,nested:!!el.closest('a'),opacity:getComputedStyle(el).opacity,fit:el.closest('nav').scrollWidth<=el.closest('nav').clientWidth+1}));
|
|
17
|
+
check(bounds.height>=44&&bounds.width>=44&&!bounds.nested&&bounds.opacity==='1'&&bounds.fit,{...context,kind:'bounds',bounds});
|
|
18
|
+
await nav.getByRole('link',{name:'没有动作的工作区',exact:true}).click();
|
|
19
|
+
await trigger.click();
|
|
20
|
+
await page.getByRole('menuitem',{name:'查看详情',exact:true}).click();
|
|
21
|
+
const dialog=page.getByRole('dialog',{name:'工作区详情'});await dialog.waitFor();
|
|
22
|
+
await page.keyboard.press('Escape');await dialog.waitFor({state:'detached'});
|
|
23
|
+
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.endsWith(':更多操作'));
|
|
24
|
+
check(await nav.getByRole('link',{name:'没有动作的工作区',exact:true}).getAttribute('aria-current')==='page',{...context,kind:'no-navigation'});
|
|
25
|
+
await trigger.press('Space');await page.getByRole('menu').waitFor();
|
|
26
|
+
await page.keyboard.press('Escape');await page.getByRole('menu').waitFor({state:'detached'});
|
|
27
|
+
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.endsWith(':更多操作'));
|
|
28
|
+
if(width===400&&zoom===2){await nav.scrollIntoViewIfNeeded();await page.screenshot({path:`output/playwright/nav-${theme}.png`});}
|
|
29
|
+
samples.push(context);
|
|
30
|
+
}
|
|
31
|
+
for(const reduced of [false,true]){
|
|
32
|
+
await page.emulateMedia({reducedMotion:reduced?'reduce':'no-preference'});
|
|
33
|
+
await page.goto(`${origin}/?proof=${Date.now()}#nav-actions`);await page.getByRole('navigation',{name:'行操作示例'}).getByRole('button').click();
|
|
34
|
+
await page.getByRole('menuitem',{name:'查看详情',exact:true}).click();await page.getByRole('dialog').waitFor();
|
|
35
|
+
await page.getByRole('button',{name:'关闭对话框',exact:true}).click();await page.getByRole('dialog').waitFor({state:'detached'});
|
|
36
|
+
}
|
|
37
|
+
await page.getByRole('radio',{name:'减少动态',exact:true}).check();
|
|
38
|
+
const trigger=page.getByRole('navigation',{name:'行操作示例'}).getByRole('button');await trigger.press('Enter');await page.keyboard.press('Escape');
|
|
39
|
+
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')?.endsWith(':更多操作'));
|
|
40
|
+
return {pass:!failures.length&&!errors.length,failures,errors,samples};
|
|
41
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Run against a production preview or an installed-tarball consumer:
|
|
2
|
+
// playwright-cli goto http://127.0.0.1:18481/?fixture=settings-dialog
|
|
3
|
+
// playwright-cli run-code --filename scripts/check-settings-bounded.js
|
|
4
|
+
async page => {
|
|
5
|
+
const base = page.url().split('/').slice(0,3).join('/'), results = [], errors = [];
|
|
6
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
7
|
+
const cases = [];
|
|
8
|
+
for (const theme of ['rhine','arknights','neo-brutalism']) {
|
|
9
|
+
for (const width of [400,520,880,1280]) for (const zoom of [1,2]) for (const height of [900,648]) cases.push({theme,width,zoom,height});
|
|
10
|
+
// A 400px bordered iframe has 398px inner width; include that real consumer boundary.
|
|
11
|
+
cases.push({theme,width:398,zoom:2,height:648});
|
|
12
|
+
for (const [width,height] of [[390,680],[889,480],[1440,900]]) cases.push({theme,width,zoom:1,height});
|
|
13
|
+
}
|
|
14
|
+
const rect = e => {const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom};};
|
|
15
|
+
for (const c of cases) {
|
|
16
|
+
try {
|
|
17
|
+
await page.setViewportSize({width:c.width,height:c.height});
|
|
18
|
+
await page.goto(`${base}/?fixture=settings-dialog&theme=${c.theme}&zoom=${c.zoom}`);
|
|
19
|
+
await page.getByRole('checkbox',{name:'长导航',exact:true}).check();
|
|
20
|
+
await page.getByRole('checkbox',{name:'长操作文案',exact:true}).check();
|
|
21
|
+
await page.getByRole('button',{name:'打开设置',exact:true}).click();
|
|
22
|
+
const dialog=page.getByRole('dialog',{name:'设置',exact:true});
|
|
23
|
+
await dialog.getByRole('link',{name:'用户管理与权限',exact:true}).click();
|
|
24
|
+
const measure=()=>dialog.evaluate(d=>{
|
|
25
|
+
const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom};};
|
|
26
|
+
const body=d.querySelector('.rhine-dialog__settings-content'), nav=d.querySelector('.rhine-dialog__navigation'), footer=d.querySelector('.rhine-dialog__footer');
|
|
27
|
+
return {dialog:rect(d),head:rect(d.querySelector('.rhine-dialog__head')),close:rect(d.querySelector('.rhine-dialog__head button')),footer:rect(footer),buttons:[...footer.querySelectorAll('button')].map(rect),labelsMatch:[...footer.querySelectorAll('button')].every(b=>(b.getAttribute('aria-label')||b.innerText).replace(/\s/g,'').includes(b.innerText.replace(/\s/g,''))),body:rect(body),nav:rect(nav),bodyScroll:body.scrollTop,navScroll:nav.scrollTop,overflow:body.scrollWidth>body.clientWidth+1,footerOverflow:footer.scrollHeight>footer.clientHeight+1,columns:getComputedStyle(d.querySelector('.rhine-dialog__settings-grid')).gridTemplateColumns,rootScroll:d.scrollTop};
|
|
28
|
+
});
|
|
29
|
+
const before=await measure();
|
|
30
|
+
const body=dialog.getByRole('region',{name:'设置内容'});
|
|
31
|
+
await body.focus(); await page.keyboard.press('End');
|
|
32
|
+
await body.evaluate(e=>{e.scrollTop=e.scrollHeight;});
|
|
33
|
+
const last=dialog.getByRole('button',{name:'编辑成员 30',exact:true});
|
|
34
|
+
const lastRect=await last.evaluate(rect);
|
|
35
|
+
const after=await measure();
|
|
36
|
+
const navLast=dialog.getByRole('link',{name:'扩展设置 20 · 长栏目名称',exact:true});
|
|
37
|
+
await navLast.scrollIntoViewIfNeeded();
|
|
38
|
+
const navLastRect=await navLast.evaluate(rect), navAfter=await measure();
|
|
39
|
+
const within=(r,b)=>r.x>=b.x-1&&r.right<=b.right+1&&r.y>=b.y-1&&r.bottom<=b.bottom+1;
|
|
40
|
+
const same=(a,b)=>['x','y','width','height'].every(k=>Math.abs(a[k]-b[k])<1);
|
|
41
|
+
const stable=['dialog','head','close','footer'].every(k=>same(before[k],after[k])&&same(before[k],navAfter[k]));
|
|
42
|
+
const expectedColumns=c.width>=880&&c.zoom===1?2:1;
|
|
43
|
+
const checks={labelsMatch:after.labelsMatch,stable,buttonsStable:before.buttons.every((r,i)=>same(r,after.buttons[i])),withinViewport:within(after.dialog,{x:0,y:0,right:c.width,bottom:c.height}),bodyNonzero:after.body.height>0,bodyScrolled:after.bodyScroll>0,lastReachable:within(lastRect,after.body),navReachable:within(navLastRect,navAfter.nav),footerVisible:after.buttons.every(r=>within(r,after.footer)),targets:after.buttons.every(r=>r.width/c.zoom>=44-.1&&r.height/c.zoom>=44-.1),noOverflow:!after.overflow&&!after.footerOverflow,columns:after.columns.split(' ').length===expectedColumns,noRootScroll:after.rootScroll===0};
|
|
44
|
+
results.push({...c,pass:Object.values(checks).every(Boolean),checks,before,after});
|
|
45
|
+
if(c.width===400&&c.zoom===2&&c.height===648) await page.screenshot({path:`output/playwright/settings-${c.theme}-400-200-short.png`});
|
|
46
|
+
} catch(error) { results.push({...c,pass:false,error:String(error)}); }
|
|
47
|
+
}
|
|
48
|
+
return {pass:results.every(r=>r.pass)&&errors.length===0,cases:results.length,errors,failures:results.filter(r=>!r.pass),results};
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Run against this revision's production preview; origin comes from the current page.
|
|
2
|
+
async page => {
|
|
3
|
+
const native = page.url().includes('native=1');
|
|
4
|
+
const origin = page.url().split('/').slice(0,3).join('/'), results = [], errors = [];
|
|
5
|
+
page.on('pageerror', e => errors.push(String(e)));
|
|
6
|
+
for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880]) for (const zoom of (native ? [1] : [1,2])) {
|
|
7
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
8
|
+
await page.goto(`${origin}/?fixture=settings-composition&theme=${theme}&zoom=${zoom}`);
|
|
9
|
+
await page.getByRole('checkbox', {name:'长产品名与状态'}).check();
|
|
10
|
+
const shell = page.getByRole('region', {name:'设置组合工作区'}), header = shell.locator('.rhine-shell__content-header');
|
|
11
|
+
const viewport = await page.evaluate(()=>({width:innerWidth,dpr:devicePixelRatio,zoom:getComputedStyle(document.documentElement).zoom}));
|
|
12
|
+
const desktop = viewport.width / zoom - 32 >= 760;
|
|
13
|
+
if (await header.isVisible() === desktop) throw Error('Empty header visibility');
|
|
14
|
+
await page.getByRole('checkbox', {name:'显示顶部动作'}).check();
|
|
15
|
+
if (!await header.isVisible()) throw Error('Visible action lost');
|
|
16
|
+
await page.getByRole('button',{name:'完成顶部操作'}).click();
|
|
17
|
+
if (desktop) {
|
|
18
|
+
await page.getByRole('button',{name:'收起设置导航'}).focus(); await page.keyboard.press('Enter');
|
|
19
|
+
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='展开设置导航');
|
|
20
|
+
if (!await header.isVisible()) throw Error('Restore header missing');
|
|
21
|
+
await page.keyboard.press('Enter');
|
|
22
|
+
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='收起设置导航');
|
|
23
|
+
if (await header.isVisible()) throw Error('Empty header remains');
|
|
24
|
+
} else {
|
|
25
|
+
await shell.getByRole('button',{name:'设置导航',exact:true}).click();
|
|
26
|
+
await page.getByRole('dialog',{name:'设置导航',exact:true}).waitFor();
|
|
27
|
+
await page.keyboard.press('Escape');
|
|
28
|
+
await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='设置导航');
|
|
29
|
+
}
|
|
30
|
+
for (const state of ['current','checking','available','error']) {
|
|
31
|
+
await page.getByRole('combobox',{name:'更新状态'}).selectOption(state);
|
|
32
|
+
const area = page.getByTestId('update-area');
|
|
33
|
+
const geometry = await area.evaluate(e=>{const s=e.querySelector('[role=status]').getBoundingClientRect(),b=e.querySelector('button').getBoundingClientRect(),r=e.getBoundingClientRect();return { aligned:Math.abs(s.right-b.right)<1, overflow:e.scrollWidth>e.clientWidth+1, right:r.right,x:r.x };});
|
|
34
|
+
if (!geometry.aligned || geometry.overflow || geometry.x<0 || geometry.right>viewport.width+1) throw Error(JSON.stringify({theme,width,zoom,state,geometry}));
|
|
35
|
+
if (state==='checking' && await area.locator('button').getAttribute('aria-busy')!=='true') throw Error('Busy feedback absent');
|
|
36
|
+
}
|
|
37
|
+
await page.getByRole('button',{name:'重置设置示例'}).click();
|
|
38
|
+
if (await page.getByRole('checkbox',{name:'长产品名与状态'}).isChecked()) throw Error('Reset failed');
|
|
39
|
+
results.push({theme,width,zoom,native,viewport,pass:true});
|
|
40
|
+
}
|
|
41
|
+
await page.emulateMedia({ reducedMotion:'reduce' });
|
|
42
|
+
await page.goto(`${origin}/?fixture=settings-composition&motion=system`);
|
|
43
|
+
await page.getByRole('combobox',{name:'更新状态'}).selectOption('checking');
|
|
44
|
+
const system = await page.locator('.rhine-spinner').evaluate(e=>getComputedStyle(e).animationDuration);
|
|
45
|
+
await page.emulateMedia({reducedMotion:'no-preference'});
|
|
46
|
+
await page.goto(`${origin}/?fixture=settings-composition&motion=reduced`);
|
|
47
|
+
await page.getByRole('combobox',{name:'更新状态'}).selectOption('checking');
|
|
48
|
+
const manual = await page.locator('.rhine-spinner').evaluate(e=>getComputedStyle(e).animationDuration);
|
|
49
|
+
return {pass:!errors.length,results,errors,reducedMotion:{system,manual}};
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Run after navigating to a production settings-dialog fixture (source or tarball consumer).
|
|
2
|
+
async page => {
|
|
3
|
+
page.setDefaultTimeout(6000);
|
|
4
|
+
const base=page.url().split('/').slice(0,3).join('/'), results=[],errors=[];
|
|
5
|
+
page.on('pageerror',error=>errors.push(String(error)));
|
|
6
|
+
const assert=(ok,message)=>{if(!ok)throw new Error(message)};
|
|
7
|
+
const trapped=async dialog=>assert(await dialog.evaluate(e=>e.contains(document.activeElement)),'focus escaped settings');
|
|
8
|
+
for(const theme of ['rhine','arknights','neo-brutalism']) for(const width of [400,520,880,1280]) for(const zoom of [1,2]) {
|
|
9
|
+
const c={theme,width,zoom};
|
|
10
|
+
try {
|
|
11
|
+
await page.setViewportSize({width,height:900});
|
|
12
|
+
await page.goto(`${base}/?fixture=settings-dialog&theme=${theme}&zoom=${zoom}`);
|
|
13
|
+
const url=page.url(),host=page.getByRole('textbox',{name:'宿主查询'});
|
|
14
|
+
await host.fill('保留宿主查询');
|
|
15
|
+
const trigger=page.getByRole('button',{name:'打开设置',exact:true});await trigger.click();
|
|
16
|
+
const d=page.getByRole('dialog',{name:'设置',exact:true});
|
|
17
|
+
assert(await d.getAttribute('aria-describedby')===null,'dangling description');
|
|
18
|
+
const frame=await d.boundingBox();
|
|
19
|
+
const stable=async()=>{const r=await d.boundingBox();assert(Math.abs(r.y-frame.y)<1&&Math.abs(r.height-frame.height)<1,'section changed frame')};
|
|
20
|
+
const draft=d.getByRole('textbox',{name:'工作区名称'});await draft.fill('跨主题草稿');
|
|
21
|
+
const handle=await draft.elementHandle();
|
|
22
|
+
for(const selectedTheme of ['rhine','arknights','neo-brutalism']) {
|
|
23
|
+
await d.getByRole('combobox',{name:'界面主题'}).selectOption(selectedTheme);
|
|
24
|
+
assert(await handle.evaluate(e=>e.isConnected&&e.value==='跨主题草稿'),'theme remounted draft');
|
|
25
|
+
await stable();
|
|
26
|
+
}
|
|
27
|
+
await d.getByRole('combobox',{name:'界面主题'}).selectOption(theme);
|
|
28
|
+
const search=d.getByRole('combobox',{name:'授权成员'});
|
|
29
|
+
await search.fill('成员 2');
|
|
30
|
+
const list=page.getByRole('listbox');await list.waitFor();
|
|
31
|
+
const opt=list.getByRole('option',{name:'合成成员 2',exact:true});await opt.click();
|
|
32
|
+
assert(await opt.getAttribute('aria-selected')==='true','multiselect did not select');
|
|
33
|
+
await opt.click();assert(await opt.getAttribute('aria-selected')==='false','multiselect did not deselect');
|
|
34
|
+
await page.getByRole('button',{name:'完成选择',exact:true}).click();
|
|
35
|
+
// Immediate consecutive reverse/forward traversal after a nested FocusScope unmount.
|
|
36
|
+
for(let i=0;i<8;i++){await page.keyboard.press('Shift+Tab');await trapped(d);}
|
|
37
|
+
for(let i=0;i<8;i++){await page.keyboard.press('Tab');await trapped(d);}
|
|
38
|
+
await search.fill('成员 2');await list.waitFor();await page.keyboard.press('Escape');await list.waitFor({state:'detached'});await trapped(d);
|
|
39
|
+
assert(await d.isVisible(),'select Escape closed parent');
|
|
40
|
+
await d.getByRole('link',{name:'用户管理与权限',exact:true}).click();await stable();
|
|
41
|
+
const edit=d.getByRole('button',{name:'编辑成员 30',exact:true});await edit.click();
|
|
42
|
+
const inner=page.getByRole('dialog',{name:'编辑成员 30',exact:true});await inner.getByRole('textbox',{name:'成员备注'}).fill('内层草稿');
|
|
43
|
+
await page.keyboard.press('Escape');await inner.waitFor({state:'detached'});
|
|
44
|
+
await page.waitForFunction(()=>document.activeElement?.textContent==='编辑成员 30');
|
|
45
|
+
for(let i=0;i<8;i++){await page.keyboard.press('Shift+Tab');await trapped(d);}
|
|
46
|
+
await d.getByRole('link',{name:'公开项目',exact:true}).click();
|
|
47
|
+
for(const state of ['loading','error','empty','normal']) {
|
|
48
|
+
await d.getByRole('combobox',{name:'项目状态'}).selectOption(state);await stable();
|
|
49
|
+
const text={loading:'正在读取项目',error:'项目读取失败',empty:'暂无公开项目',normal:'项目标题'}[state];
|
|
50
|
+
assert((await d.textContent()).includes(text)||await d.getByLabel(text).count()>0,`missing ${state}`);
|
|
51
|
+
}
|
|
52
|
+
await d.getByRole('link',{name:'异步栏目',exact:true}).click();await d.getByText('正在读取栏目', {exact:true}).waitFor({state:'attached'});await stable();await d.getByText('异步栏目已就绪').waitFor();await stable();
|
|
53
|
+
await d.getByRole('link',{name:'账号',exact:true}).click();assert(await draft.inputValue()==='跨主题草稿','section lost draft');
|
|
54
|
+
await d.getByRole('button',{name:'保存当前工作区设置',exact:true}).click();assert(await d.getByRole('button',{name:'保存当前工作区设置'}).isDisabled(),'save is not busy');await stable();
|
|
55
|
+
await d.getByText('已保存演示草稿').waitFor();await stable();
|
|
56
|
+
await draft.fill('将被撤销');await d.getByRole('button',{name:'撤销全部未保存的修改'}).click();assert(await draft.inputValue()==='跨主题草稿','undo failed');
|
|
57
|
+
await d.getByRole('button',{name:'关闭设置',exact:true}).click();await d.waitFor({state:'detached'});await page.waitForFunction(()=>document.activeElement?.textContent==='打开设置');
|
|
58
|
+
assert(page.url()===url&&await host.inputValue()==='保留宿主查询','host state changed');
|
|
59
|
+
await trigger.click();assert(await draft.inputValue()==='跨主题草稿','reopen lost saved data');await page.keyboard.press('Escape');await d.waitFor({state:'detached'});
|
|
60
|
+
for(const outcome of ['error','conflict']) {
|
|
61
|
+
await page.getByRole('combobox',{name:'保存结果'}).selectOption(outcome);await trigger.click();await draft.fill('保留失败草稿');await d.getByRole('button',{name:'保存当前工作区设置'}).click();
|
|
62
|
+
await d.getByText(outcome==='error'?'保存失败,草稿已保留':'内容已更新,请核对后重新保存').waitFor();assert(await draft.inputValue()==='保留失败草稿','failure discarded draft');await page.keyboard.press('Escape');await d.waitFor({state:'detached'});
|
|
63
|
+
}
|
|
64
|
+
await page.getByRole('button',{name:'普通短弹窗'}).click();const short=page.getByRole('dialog',{name:'短对话框'});assert(!(await short.getAttribute('class')).includes('--bounded'),'short dialog became bounded');await page.keyboard.press('Escape');await short.waitFor({state:'detached'});
|
|
65
|
+
await page.getByRole('button',{name:'有界普通表单示例'}).click();
|
|
66
|
+
const form=page.getByRole('dialog',{name:'有界普通表单'}), head=form.locator('.rhine-dialog__head'), footer=form.locator('.rhine-dialog__footer');
|
|
67
|
+
const headBefore=await head.boundingBox(), footerBefore=await footer.boundingBox();
|
|
68
|
+
await form.getByRole('textbox',{name:'表单字段 30',exact:true}).focus();
|
|
69
|
+
const field=await form.getByRole('textbox',{name:'表单字段 30',exact:true}).boundingBox(), bodyRect=await form.locator('.rhine-dialog__body').boundingBox();
|
|
70
|
+
assert(Math.abs((await head.boundingBox()).y-headBefore.y)<1&&Math.abs((await footer.boundingBox()).y-footerBefore.y)<1,'plain bounded head/footer moved');
|
|
71
|
+
assert(field.y>=bodyRect.y-1&&field.y+field.height<=bodyRect.y+bodyRect.height+1,'plain bounded last field unreachable');
|
|
72
|
+
await page.keyboard.press('Escape');await form.waitFor({state:'detached'});
|
|
73
|
+
if(zoom===1) for(const [triggerName,title] of [['普通抽屉示例','普通抽屉'],['辅助分栏示例','辅助分栏']]) {
|
|
74
|
+
await page.getByRole('button',{name:triggerName}).click();const legacy=page.getByRole('dialog',{name:title,exact:true});
|
|
75
|
+
assert(!(await legacy.getAttribute('class')).includes('--bounded'),'legacy mode became bounded');
|
|
76
|
+
await legacy.getByRole('textbox').fill('保留既有模式');await page.keyboard.press('Tab');await trapped(legacy);
|
|
77
|
+
await page.keyboard.press('Escape');await legacy.waitFor({state:'detached'});
|
|
78
|
+
}
|
|
79
|
+
results.push({...c,pass:true});
|
|
80
|
+
}catch(error){results.push({...c,pass:false,error:String(error)});}
|
|
81
|
+
}
|
|
82
|
+
// System and manual reduced motion are independent paths.
|
|
83
|
+
const motion=[];
|
|
84
|
+
for(const mode of ['system','manual','full']) {
|
|
85
|
+
await page.emulateMedia({reducedMotion:mode==='system'?'reduce':'no-preference'});
|
|
86
|
+
await page.goto(`${base}/?fixture=settings-dialog&motion=${mode==='manual'?'reduced':'full'}`);
|
|
87
|
+
await page.getByRole('button',{name:'打开设置',exact:true}).click();
|
|
88
|
+
const duration=await page.getByRole('dialog',{name:'设置',exact:true}).evaluate(e=>getComputedStyle(e).animationDuration);
|
|
89
|
+
motion.push({mode,duration,pass:mode==='full'?parseFloat(duration)>0:parseFloat(duration)===0});
|
|
90
|
+
}
|
|
91
|
+
await page.emulateMedia({reducedMotion:'no-preference'});
|
|
92
|
+
return {pass:results.every(r=>r.pass)&&motion.every(r=>r.pass)&&!errors.length,results,motion,errors};
|
|
93
|
+
}
|
|
@@ -16,12 +16,8 @@ async page => {
|
|
|
16
16
|
await page.getByRole('button',{name:'打开设置',exact:true}).click();
|
|
17
17
|
const outer = page.getByRole('dialog',{name:'设置',exact:true});
|
|
18
18
|
const outerBounds = await box(outer);
|
|
19
|
-
const help = outer.getByRole('button',{name:'设置帮助',exact:true});
|
|
20
|
-
await help.focus(); await page.getByRole('tooltip').waitFor();
|
|
21
|
-
await page.keyboard.press('Escape'); await page.getByRole('tooltip').waitFor({state:'detached'});
|
|
22
|
-
if (!await outer.isVisible()) throw new Error('Tooltip Escape closed the settings dialog');
|
|
23
19
|
await outer.getByRole('textbox',{name:'工作区名称'}).fill('已保存草稿');
|
|
24
|
-
const save = outer.getByRole('button',{name:'
|
|
20
|
+
const save = outer.getByRole('button',{name:'保存当前工作区设置',exact:true});
|
|
25
21
|
await save.scrollIntoViewIfNeeded(); const saveBounds=await box(save); await save.click();
|
|
26
22
|
await outer.getByRole('status').getByText('已保存演示草稿').waitFor();
|
|
27
23
|
await outer.getByRole('link',{name:'用户管理与权限',exact:true}).click();
|