@linhey/rhine-design 0.8.0 → 0.9.0
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 +7 -0
- package/components/AdvancedForms.tsx +38 -8
- package/components/ListFilterBar.tsx +104 -0
- package/components/OrbitBackdrop.tsx +9 -3
- package/components/README.md +1 -0
- package/components/index.ts +1 -0
- package/components/list-filter-bar.css +33 -0
- package/components/orbit.css +21 -0
- package/docs/BACKGROUND_FILTER_VERIFICATION.md +12 -0
- package/docs/DESIGN_SYSTEM.md +4 -0
- package/docs/INTERACTION_PATTERNS.md +5 -1
- package/docs/LIST_FILTER_BAR.md +73 -0
- package/docs/ORBIT_BACKDROP.md +25 -0
- package/package.json +1 -1
- package/preview/BackgroundFiltersFixture.tsx +28 -0
- package/preview/GalleryShell.tsx +1 -1
- package/preview/ListFilterBarGallery.tsx +170 -0
- package/preview/OrbitGallery.tsx +14 -0
- package/preview/main.tsx +4 -1
- package/preview/preview.css +15 -0
- package/scripts/check-background-filters.js +58 -0
- package/scripts/check-background-motion.js +169 -0
- package/scripts/check-filter-native-date.js +47 -0
- package/tests/DateNativeInput.test.tsx +147 -0
- package/tests/ListFilterBar.test.tsx +198 -0
- package/tests/OrbitBackdrop.test.tsx +28 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Badge, Button, Checkbox, DateRangePicker, EmptyState, Inline, ListFilterBar,
|
|
4
|
+
Select, Skeleton, Stack, Surface, Text, type DateRange,
|
|
5
|
+
} from '../components';
|
|
6
|
+
|
|
7
|
+
type PreviewState = 'normal' | 'loading' | 'empty' | 'error' | 'disabled';
|
|
8
|
+
const records = [
|
|
9
|
+
{ id: 'design-review', title: '核对页面设计与窄屏布局', person: '示例成员', state: 'done', reviewed: true, date: '2026-10-24' },
|
|
10
|
+
{ id: 'component-review', title: '整理共享组件与交互规则', person: '组件维护者', state: 'running', reviewed: false, date: '2026-10-23' },
|
|
11
|
+
];
|
|
12
|
+
const dateLabel = ({ start, end }: DateRange) => start || end ? `${start || '不限开始'} — ${end || '不限结束'}` : undefined;
|
|
13
|
+
|
|
14
|
+
function usePreviewRefresh() {
|
|
15
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
16
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
17
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
18
|
+
return {
|
|
19
|
+
refreshing,
|
|
20
|
+
refresh: () => {
|
|
21
|
+
if (timer.current) clearTimeout(timer.current);
|
|
22
|
+
setRefreshing(true);
|
|
23
|
+
timer.current = setTimeout(() => setRefreshing(false), 600);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function SessionListExample({ mode, embedded = false }: { mode: PreviewState; embedded?: boolean }) {
|
|
29
|
+
const [query, setQuery] = useState('');
|
|
30
|
+
const [state, setState] = useState('all');
|
|
31
|
+
const [sort, setSort] = useState('newest');
|
|
32
|
+
const [person, setPerson] = useState('all');
|
|
33
|
+
const [reviewed, setReviewed] = useState(false);
|
|
34
|
+
const [range, setRange] = useState<DateRange>({ start: '', end: '' });
|
|
35
|
+
const [dateResetKey, setDateResetKey] = useState(0);
|
|
36
|
+
const [exportCount, setExportCount] = useState(0);
|
|
37
|
+
const { refreshing, refresh } = usePreviewRefresh();
|
|
38
|
+
const disabled = mode === 'disabled';
|
|
39
|
+
const pending = mode === 'loading' || refreshing;
|
|
40
|
+
const label = embedded ? '人员会话筛选' : '录制会话筛选';
|
|
41
|
+
const reset = () => { setQuery(''); setState('all'); setSort('newest'); setPerson('all'); setReviewed(false); setRange({ start: '', end: '' }); setDateResetKey(key => key + 1); };
|
|
42
|
+
// Gallery records are synthetic; production hosts send these values to their own query service.
|
|
43
|
+
const rows = mode === 'empty' ? [] : records.filter(row =>
|
|
44
|
+
(state === 'all' || row.state === state) &&
|
|
45
|
+
(person === 'all' || row.person === person) &&
|
|
46
|
+
(!reviewed || row.reviewed) &&
|
|
47
|
+
(!range.start || row.date >= range.start) && (!range.end || row.date <= range.end) &&
|
|
48
|
+
`${row.title} ${row.person} ${row.id}`.includes(query.trim()),
|
|
49
|
+
).slice().sort((a, b) => sort === 'oldest' ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id));
|
|
50
|
+
const common = [
|
|
51
|
+
{
|
|
52
|
+
id: 'status', label: '会话状态', activeLabel: state === 'all' ? undefined : state === 'done' ? '已完成' : '进行中',
|
|
53
|
+
onClear: disabled ? undefined : () => setState('all'),
|
|
54
|
+
control: <Select label="会话状态" value={state} disabled={disabled} onChange={event => setState(event.target.value)}>
|
|
55
|
+
<option value="all">全部状态</option><option value="running">进行中</option><option value="done">已完成</option>
|
|
56
|
+
</Select>,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'sort', label: '排序', activeLabel: sort === 'newest' ? undefined : '最早事件优先',
|
|
60
|
+
onClear: disabled ? undefined : () => setSort('newest'),
|
|
61
|
+
control: <Select label="排序" value={sort} disabled={disabled} onChange={event => setSort(event.target.value)}>
|
|
62
|
+
<option value="newest">最后事件最新</option><option value="oldest">最早事件优先</option>
|
|
63
|
+
</Select>,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
const reviewField = {
|
|
67
|
+
id: 'reviewed', label: '审查', activeLabel: reviewed ? '只看已审查' : undefined,
|
|
68
|
+
onClear: disabled ? undefined : () => setReviewed(false),
|
|
69
|
+
control: <Checkbox label="只看已审查" checked={reviewed} disabled={disabled} onChange={event => setReviewed(event.target.checked)} />,
|
|
70
|
+
};
|
|
71
|
+
return <Stack gap={4} style={embedded ? { maxWidth: 420, width: '100%' } : undefined}>
|
|
72
|
+
<ListFilterBar label={label}
|
|
73
|
+
search={{ label: '搜索会话', placeholder: '搜索消息或会话 ID', value: query, disabled, onChange: event => setQuery(event.target.value) }}
|
|
74
|
+
primary={embedded ? common : [...common, reviewField]}
|
|
75
|
+
filters={[
|
|
76
|
+
...(embedded ? [reviewField] : []),
|
|
77
|
+
{
|
|
78
|
+
id: 'person', label: '人员', activeLabel: person === 'all' ? undefined : person,
|
|
79
|
+
onClear: disabled ? undefined : () => setPerson('all'),
|
|
80
|
+
control: <Select label="人员" value={person} disabled={disabled} onChange={event => setPerson(event.target.value)}>
|
|
81
|
+
<option value="all">全部人员</option><option>示例成员</option><option>组件维护者</option>
|
|
82
|
+
</Select>,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'date', label: '最后事件日期', activeLabel: dateLabel(range),
|
|
86
|
+
onClear: disabled ? undefined : () => setRange({ start: '', end: '' }),
|
|
87
|
+
control: <DateRangePicker key={dateResetKey} label="最后事件日期" value={range} onValueChange={setRange} disabled={disabled}
|
|
88
|
+
presets={[{ label: '今天', value: { start: '2026-10-24', end: '2026-10-24' } }]} />,
|
|
89
|
+
},
|
|
90
|
+
]}
|
|
91
|
+
onReset={disabled ? undefined : reset}
|
|
92
|
+
refresh={{ onClick: refresh, busy: pending, disabled }}
|
|
93
|
+
action={embedded ? undefined : <Button variant="secondary" disabled={disabled || pending} onClick={() => setExportCount(count => count + 1)}>导出示例</Button>}
|
|
94
|
+
result={pending ? undefined : `${rows.length} 条结果`}
|
|
95
|
+
pending={pending}
|
|
96
|
+
error={mode === 'error' ? '读取失败,当前保留上次结果。请刷新重试。' : undefined}
|
|
97
|
+
/>
|
|
98
|
+
{pending ? <Skeleton label="正在读取会话" lines={3} /> : rows.length ? rows.map(row => <Surface key={row.id}>
|
|
99
|
+
<Stack gap={2}>
|
|
100
|
+
<Inline gap={2}><Text as="h4" variant="label">{row.person}</Text><Badge>{row.state === 'done' ? '已完成' : '进行中'}</Badge></Inline>
|
|
101
|
+
<Text>{row.title}</Text><Text variant="caption" tone="muted">{row.id}</Text>
|
|
102
|
+
</Stack>
|
|
103
|
+
</Surface>) : <EmptyState title="没有匹配的会话" description="试试其他条件。" action={!disabled ? <Button variant="secondary" onClick={reset}>清除筛选</Button> : undefined} />}
|
|
104
|
+
{!!exportCount && <Text role="status" variant="caption" tone="muted">已触发 {exportCount} 次示例操作;展台不生成或下载文件。</Text>}
|
|
105
|
+
</Stack>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function StatisticsExample({ mode }: { mode: PreviewState }) {
|
|
109
|
+
const [scope, setScope] = useState('all');
|
|
110
|
+
const [platform, setPlatform] = useState('all');
|
|
111
|
+
const [metric, setMetric] = useState('cost');
|
|
112
|
+
const [range, setRange] = useState<DateRange>({ start: '2026-10-24', end: '2026-10-24' });
|
|
113
|
+
const [dateResetKey, setDateResetKey] = useState(0);
|
|
114
|
+
const { refreshing, refresh } = usePreviewRefresh();
|
|
115
|
+
const disabled = mode === 'disabled';
|
|
116
|
+
const pending = mode === 'loading' || refreshing;
|
|
117
|
+
const reset = () => { setScope('all'); setPlatform('all'); setMetric('cost'); setRange({ start: '2026-10-24', end: '2026-10-24' }); setDateResetKey(key => key + 1); };
|
|
118
|
+
return <Stack gap={4}>
|
|
119
|
+
<ListFilterBar label="部门用量筛选"
|
|
120
|
+
primary={[
|
|
121
|
+
{ id: 'platform', label: '平台', activeLabel: platform === 'all' ? undefined : platform, onClear: disabled ? undefined : () => setPlatform('all'),
|
|
122
|
+
control: <Select label="平台" value={platform} disabled={disabled} onChange={event => setPlatform(event.target.value)}><option value="all">全部平台</option><option>OpenAI</option></Select> },
|
|
123
|
+
{ id: 'metric', label: '排行依据', activeLabel: metric === 'cost' ? undefined : 'Token 排行', onClear: disabled ? undefined : () => setMetric('cost'),
|
|
124
|
+
control: <Select label="排行依据" value={metric} disabled={disabled} onChange={event => setMetric(event.target.value)}><option value="cost">消费排行</option><option value="tokens">Token 排行</option></Select> },
|
|
125
|
+
]}
|
|
126
|
+
filters={[
|
|
127
|
+
{ id: 'scope', label: '统计范围', activeLabel: scope === 'all' ? undefined : scope, onClear: disabled ? undefined : () => setScope('all'),
|
|
128
|
+
control: <Select label="统计范围" value={scope} disabled={disabled} onChange={event => setScope(event.target.value)}><option value="all">所有可见部门</option><option>示例团队 / 跨部门体验与可访问性协作小组</option></Select> },
|
|
129
|
+
{ id: 'range', label: '统计日期', activeLabel: range.start === '2026-10-24' && range.end === '2026-10-24' ? undefined : dateLabel(range),
|
|
130
|
+
onClear: disabled ? undefined : () => setRange({ start: '2026-10-24', end: '2026-10-24' }),
|
|
131
|
+
control: <DateRangePicker key={dateResetKey} label="统计日期" value={range} onValueChange={setRange} disabled={disabled} clearable={false} /> },
|
|
132
|
+
]}
|
|
133
|
+
onReset={disabled ? undefined : reset} refresh={{ onClick: refresh, busy: pending, disabled }} pending={pending}
|
|
134
|
+
result={pending ? undefined : mode === 'empty' ? '当前范围无用量' : `${dateLabel(range)} · ${scope === 'all' ? '所有可见部门' : scope}`}
|
|
135
|
+
error={mode === 'error' ? '统计读取失败,暂不能确认当前范围结果。' : undefined}
|
|
136
|
+
/>
|
|
137
|
+
{pending ? <Skeleton label="正在读取用量" lines={2} /> : mode === 'empty' ? <EmptyState title="当前范围没有用量" description="可更改范围或日期后重试。" /> : <Surface><Stack gap={2}>
|
|
138
|
+
<Text as="h4" variant="label">示例统计结果</Text><Text variant="caption" tone="muted">统计范围、日期与排行方式由宿主控制;此处仅展示合成数据。</Text>
|
|
139
|
+
<Text numeric>{metric === 'cost' ? '$ 8.42' : '1,400,000 Token'}</Text>
|
|
140
|
+
</Stack></Surface>}
|
|
141
|
+
</Stack>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function ListFilterBarGallery() {
|
|
145
|
+
const [mode, setMode] = useState<PreviewState>('normal');
|
|
146
|
+
return <section id="list-filter-bar">
|
|
147
|
+
<div className="ds-section-title"><span>FILTERS</span><h2>列表筛选 · 限制常显复杂度</h2></div>
|
|
148
|
+
<p className="ds-caption">搜索、最多两个常显条件和一个业务操作;其他条件按需展开。对应 Rhine #19,查询与权限由宿主管理。</p>
|
|
149
|
+
<Stack gap={6}>
|
|
150
|
+
<Select label="示例状态" value={mode} onChange={event => setMode(event.target.value as PreviewState)}>
|
|
151
|
+
<option value="normal">正常 / 有数据</option><option value="loading">加载中</option><option value="empty">无结果</option><option value="error">读取失败</option><option value="disabled">条件不可修改</option>
|
|
152
|
+
</Select>
|
|
153
|
+
<Stack gap={3}>
|
|
154
|
+
<Text as="h3" variant="heading">独立列表</Text>
|
|
155
|
+
<Text variant="caption" tone="muted">传入的第三个常显条件“审查”自动归入更多筛选;不会继续增加顶部行数。</Text>
|
|
156
|
+
<SessionListExample mode={mode} />
|
|
157
|
+
</Stack>
|
|
158
|
+
<Stack gap={3}>
|
|
159
|
+
<Text as="h3" variant="heading">嵌入人员详情 · 420px 容器</Text>
|
|
160
|
+
<Text variant="caption" tone="muted">同一组件随实际容器重排,窄区域仍保留结果、刷新和退出筛选的入口。</Text>
|
|
161
|
+
<SessionListExample mode={mode} embedded />
|
|
162
|
+
</Stack>
|
|
163
|
+
<Stack gap={3}>
|
|
164
|
+
<Text as="h3" variant="heading">统计范围 · 无搜索框</Text>
|
|
165
|
+
<StatisticsExample mode={mode} />
|
|
166
|
+
</Stack>
|
|
167
|
+
<Text variant="caption" tone="muted">筛选即时改变宿主状态,关闭抽屉不撤销条件。加载、错误和结果在抽屉内同样可见;用 Escape 关闭并返回入口。</Text>
|
|
168
|
+
</Stack>
|
|
169
|
+
</section>;
|
|
170
|
+
}
|
package/preview/OrbitGallery.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
+
import { OrbitEntryExample } from './BackgroundFiltersFixture';
|
|
2
3
|
import { Badge, Button, Disclosure, Inline, OrbitBackdrop, SegmentedControl, Stack, Surface, Switch, Text, TextField } from '../components';
|
|
3
4
|
|
|
4
5
|
export function OrbitGallery() {
|
|
@@ -46,5 +47,18 @@ export function OrbitGallery() {
|
|
|
46
47
|
</Stack></div>
|
|
47
48
|
</OrbitBackdrop>
|
|
48
49
|
</Disclosure>
|
|
50
|
+
<Disclosure title="查看入口与全页背景组合">
|
|
51
|
+
<Text tone="muted">文字保留在上方,艺术位于右下角;无框背景下的表单使用 Surface 保持清晰。</Text>
|
|
52
|
+
<div className="ds-orbit-layout-container"><div className="ds-orbit-layout-examples ds-gap-top">
|
|
53
|
+
<OrbitBackdrop className="ds-orbit-corner-example" appearance={appearance} reducedMotion={reduced} composition="corner" controls={false} role="region" aria-label="右下角艺术与文字">
|
|
54
|
+
<Stack gap={4}>
|
|
55
|
+
<Text variant="overline">RHINE STUDIO</Text>
|
|
56
|
+
<Text as="h3" variant="title">从这里,<br />继续创造。</Text>
|
|
57
|
+
<Text tone="muted">让内容拥有清晰的阅读空间。</Text>
|
|
58
|
+
</Stack>
|
|
59
|
+
</OrbitBackdrop>
|
|
60
|
+
<OrbitEntryExample appearance={appearance} reduced={reduced} />
|
|
61
|
+
</div></div>
|
|
62
|
+
</Disclosure>
|
|
49
63
|
</section>;
|
|
50
64
|
}
|
package/preview/main.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BackgroundFiltersFixture } from './BackgroundFiltersFixture';
|
|
1
2
|
import { StrictMode, useEffect, useRef, useState } from 'react';
|
|
2
3
|
import { createRoot } from 'react-dom/client';
|
|
3
4
|
import { Badge, Button, Checkbox, Disclosure, EmptyState, Logo, Notice, Progress, SegmentedControl, Select, SelectionCard, Skeleton, Surface, Tabs, Text, TextField, Toolbar, type TextVariant } from '../components/index';
|
|
@@ -19,6 +20,7 @@ import { NeutronStarGallery } from './NeutronStarGallery';
|
|
|
19
20
|
import { AdvancedDataGallery, AdvancedFormsGallery, MediaGallery, ShellGallery } from './ExtensionsGallery';
|
|
20
21
|
import { MonitoringGallery } from './MonitoringGallery';
|
|
21
22
|
import { DateRangeFilterGallery } from './DateRangeFilterGallery';
|
|
23
|
+
import { ListFilterBarGallery } from './ListFilterBarGallery';
|
|
22
24
|
import { CompactAccountGallery } from './CompactAccountGallery';
|
|
23
25
|
|
|
24
26
|
function Gallery() {
|
|
@@ -44,6 +46,7 @@ function Gallery() {
|
|
|
44
46
|
{section === 'overlay-bounds' && <OverlayBoundsGallery />}
|
|
45
47
|
{section === 'monitoring' && <MonitoringGallery />}
|
|
46
48
|
{section === 'date-range-filter' && <DateRangeFilterGallery />}
|
|
49
|
+
{section === 'list-filter-bar' && <ListFilterBarGallery />}
|
|
47
50
|
{section === 'compact-account' && <CompactAccountGallery />}
|
|
48
51
|
{section === 'backgrounds' && <BackgroundGallery />}
|
|
49
52
|
{section === 'app-shell' && <ShellGallery />}
|
|
@@ -135,4 +138,4 @@ function Gallery() {
|
|
|
135
138
|
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
createRoot(document.getElementById('root')!).render(<StrictMode>{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>);
|
|
141
|
+
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>);
|
package/preview/preview.css
CHANGED
|
@@ -26,4 +26,19 @@ body { margin:0; }
|
|
|
26
26
|
.ds-footer { border-top:1px solid var(--rhine-ink); display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; padding:24px 0; color:var(--rhine-muted); font-size:12px; }
|
|
27
27
|
.ds-orbit-artwork { min-height:clamp(360px, 52vw, 560px); }
|
|
28
28
|
.ds-orbit-example-content { width:min(100%, 560px); }
|
|
29
|
+
.ds-orbit-layout-container { container-type:inline-size; }
|
|
30
|
+
.ds-orbit-layout-examples { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--rhine-space-6); }
|
|
31
|
+
.ds-orbit-corner-example { min-height:620px; }
|
|
32
|
+
@container (max-width:760px) { .ds-orbit-layout-examples { grid-template-columns:1fr; } }
|
|
29
33
|
@media(max-width:760px) { .ds-hero,.ds-grid { grid-template-columns:1fr; gap:24px; }.ds-hero { padding-top:32px; }.ds-hero h1 { font-size:32px; letter-spacing:-1px; }.ds-palette { grid-template-columns:repeat(auto-fit,minmax(min(100%,90px),1fr)); }.ds-palette>div>div { height:64px; }.ds-section-title h2 { font-size:19px; } }
|
|
34
|
+
|
|
35
|
+
.ds-orbit-entry-container { container-type:inline-size; min-width:0; }
|
|
36
|
+
.ds-orbit-entry { position:relative; isolation:isolate; display:grid; grid-template-columns:1fr 1fr; gap:var(--rhine-space-6); min-height:620px; padding:var(--rhine-space-6); }
|
|
37
|
+
.ds-orbit-entry-background { position:absolute; inset:0 auto 0 0; width:50%; z-index:-1; pointer-events:none; }
|
|
38
|
+
.ds-orbit-entry-intro, .ds-orbit-entry-form { min-width:0; align-self:start; }
|
|
39
|
+
.ds-orbit-entry-form { background:var(--rhine-surface); }
|
|
40
|
+
.ds-orbit-entry:has([data-rhine-orbit-appearance="original"]) .ds-orbit-entry-intro { color:#f2f7ff; }
|
|
41
|
+
@container (max-width:760px) {
|
|
42
|
+
.ds-orbit-entry { grid-template-columns:1fr; padding-bottom:300px; }
|
|
43
|
+
.ds-orbit-entry-background { width:100%; }
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Run against a fixed production preview on 18484 with playwright-cli run-code --filename.
|
|
2
|
+
async page => {
|
|
3
|
+
const errors=[],failures=[]; let filterCases=0,orbitCases=0;
|
|
4
|
+
page.on('pageerror',e=>errors.push(String(e)));
|
|
5
|
+
const check=(ok,detail)=>{if(!ok) failures.push(detail);};
|
|
6
|
+
const horizontal=async root=>root.evaluate(e=>{const r=e.getBoundingClientRect();return r.left>=-1&&r.right<=innerWidth+1&&e.scrollWidth<=e.clientWidth+1;});
|
|
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,height:1000});
|
|
9
|
+
await page.goto(`http://127.0.0.1:18484/?fixture=list-filters&theme=${theme}&zoom=${zoom}`);
|
|
10
|
+
const bars=page.locator('.rhine-list-filter-bar');
|
|
11
|
+
for(let i=0;i<3;i++) {
|
|
12
|
+
const bar=bars.nth(i), trigger=bar.getByRole('button',{name:/^更多筛选/});
|
|
13
|
+
if(i<2) check(await bar.locator('.rhine-list-filter-bar__search label').evaluate(e=>{const r=e.getBoundingClientRect();return r.width>20&&r.height>10;}),{theme,width,zoom,i,kind:'visible-search-label'});
|
|
14
|
+
check(await bar.getByRole('combobox').count()===2,{theme,width,zoom,i,kind:'primary-limit'});
|
|
15
|
+
await trigger.click(); const dialog=page.getByRole('dialog');
|
|
16
|
+
await dialog.evaluate(e=>Promise.all(e.getAnimations().map(a=>a.finished)));
|
|
17
|
+
check(await horizontal(dialog),{theme,width,zoom,i,kind:'drawer-overflow'});
|
|
18
|
+
if(i<2) await dialog.getByRole('checkbox',{name:'只看已审查'}).check();
|
|
19
|
+
else await dialog.getByRole('combobox',{name:'统计范围'}).selectOption({label:'示例团队 / 跨部门体验与可访问性协作小组'});
|
|
20
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
|
|
21
|
+
await page.waitForFunction(e=>e===document.activeElement,await trigger.elementHandle());
|
|
22
|
+
check(await trigger.evaluate(e=>e===document.activeElement),{theme,width,zoom,i,kind:'return-focus'});
|
|
23
|
+
check(await horizontal(bar),{theme,width,zoom,i,kind:'summary-overflow'});
|
|
24
|
+
const chip=bar.getByRole('button',{name:/^清除/}).first(); await chip.click();
|
|
25
|
+
check(await bar.evaluate(e=>e.contains(document.activeElement)),{theme,width,zoom,i,kind:'clear-focus'});
|
|
26
|
+
await page.getByRole('combobox',{name:'示例状态'}).selectOption('loading');
|
|
27
|
+
await trigger.click();
|
|
28
|
+
check(await dialog.getByRole('status').textContent().then(t=>t.includes('正在更新结果')),{theme,width,zoom,i,kind:'pending'});
|
|
29
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
|
|
30
|
+
await page.getByRole('combobox',{name:'示例状态'}).selectOption('error');
|
|
31
|
+
await trigger.click(); check(await dialog.getByText('未能更新结果',{exact:true}).isVisible(),{theme,width,zoom,i,kind:'error'});
|
|
32
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
|
|
33
|
+
await page.getByRole('combobox',{name:'示例状态'}).selectOption('empty');
|
|
34
|
+
check(await bar.getByRole('status').textContent().then(t=>/0 条|无用量/.test(t)),{theme,width,zoom,i,kind:'empty'});
|
|
35
|
+
await page.getByRole('combobox',{name:'示例状态'}).selectOption('disabled');
|
|
36
|
+
check(await bar.getByRole('button',{name:'刷新',exact:true}).isDisabled(),{theme,width,zoom,i,kind:'disabled'});
|
|
37
|
+
await page.getByRole('combobox',{name:'示例状态'}).selectOption('normal');
|
|
38
|
+
filterCases++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for(const theme of ['rhine','arknights','neo-brutalism']) for(const width of [400,520,880,1280]) for(const zoom of [1,2]) {
|
|
42
|
+
await page.setViewportSize({width,height:1000});
|
|
43
|
+
await page.goto(`http://127.0.0.1:18484/?fixture=orbit-entry&theme=${theme}&zoom=${zoom}`);
|
|
44
|
+
const input=page.getByRole('textbox',{name:'示例昵称'}); await input.fill('保留草稿');
|
|
45
|
+
const handle=await input.elementHandle();
|
|
46
|
+
const canvas=await page.locator('.rhine-orbit canvas').first().elementHandle();
|
|
47
|
+
const layout=await page.locator('.ds-orbit-entry').evaluate(e=>{
|
|
48
|
+
const r=e.getBoundingClientRect(), bg=e.querySelector('.rhine-orbit'), c=bg.querySelector('canvas').getBoundingClientRect(), intro=e.querySelector('.ds-orbit-entry-intro').getBoundingClientRect(), form=e.querySelector('.ds-orbit-entry-form');
|
|
49
|
+
return {inside:r.left>=0&&r.right<=innerWidth+1,clear:intro.bottom<=c.top,formClear:getComputedStyle(form).backgroundColor!=='rgba(0, 0, 0, 0)',noControl:!bg.querySelector('button'),noFrame:getComputedStyle(bg).borderTopWidth==='0px'&&getComputedStyle(bg).boxShadow==='none',full:Math.abs(bg.getBoundingClientRect().width-r.width)<2, narrow:e.parentElement.clientWidth<=760};
|
|
50
|
+
});
|
|
51
|
+
check(layout.inside&&layout.clear&&layout.formClear&&layout.noControl&&layout.noFrame&&layout.full===layout.narrow,{theme,width,zoom,kind:'orbit-layout',layout});
|
|
52
|
+
await page.getByRole('combobox',{name:'验收主题'}).selectOption(theme==='rhine'?'arknights':'rhine');
|
|
53
|
+
await page.setViewportSize({width:width+80,height:1000});
|
|
54
|
+
check(await handle.evaluate(e=>e.isConnected&&e.value==='保留草稿')&&await canvas.evaluate(e=>e.isConnected),{theme,width,zoom,kind:'stable-nodes'});
|
|
55
|
+
orbitCases++;
|
|
56
|
+
}
|
|
57
|
+
return {pass:!failures.length&&!errors.length,filterCases,orbitCases,failures,errors};
|
|
58
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
async page => {
|
|
2
|
+
await page.addInitScript(() => {
|
|
3
|
+
if (window.__donutProof) return;
|
|
4
|
+
const proof = window.__donutProof = { contexts: [], pending: new Set(), rafScheduled: 0, rafFired: 0, rafCancelled: 0, listeners: new Map(), observerCreates: {}, observerDisconnects: {}, orbitObservers: {}, orbitDisconnects: {} };
|
|
5
|
+
let targetID = 0, listenerID = 0;
|
|
6
|
+
const targets = new WeakMap(), listeners = new WeakMap();
|
|
7
|
+
const targetName = target => {
|
|
8
|
+
if (!targets.has(target)) targets.set(target, ++targetID);
|
|
9
|
+
const kind = target === window ? 'window' : target === document ? 'document' : target instanceof HTMLCanvasElement ? 'canvas' : target instanceof MediaQueryList ? 'media' : '';
|
|
10
|
+
return kind ? `${kind}:${targets.get(target)}` : '';
|
|
11
|
+
};
|
|
12
|
+
const nativeAdd = EventTarget.prototype.addEventListener;
|
|
13
|
+
const nativeRemove = EventTarget.prototype.removeEventListener;
|
|
14
|
+
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
|
15
|
+
const target = targetName(this);
|
|
16
|
+
if (target && listener && (typeof listener === 'object' || typeof listener === 'function')) {
|
|
17
|
+
if (!listeners.has(listener)) listeners.set(listener, ++listenerID);
|
|
18
|
+
const key = `${target}:${type}:${listeners.get(listener)}:${Boolean(typeof options === 'boolean' ? options : options?.capture)}`;
|
|
19
|
+
proof.listeners.set(key, { target, type });
|
|
20
|
+
}
|
|
21
|
+
return nativeAdd.call(this, type, listener, options);
|
|
22
|
+
};
|
|
23
|
+
EventTarget.prototype.removeEventListener = function (type, listener, options) {
|
|
24
|
+
const target = targetName(this);
|
|
25
|
+
if (target && listener) proof.listeners.delete(`${target}:${type}:${listeners.get(listener)}:${Boolean(typeof options === 'boolean' ? options : options?.capture)}`);
|
|
26
|
+
return nativeRemove.call(this, type, listener, options);
|
|
27
|
+
};
|
|
28
|
+
const nativeRequest = requestAnimationFrame, nativeCancel = cancelAnimationFrame;
|
|
29
|
+
window.requestAnimationFrame = callback => {
|
|
30
|
+
proof.rafScheduled++;
|
|
31
|
+
const id = nativeRequest(time => { proof.pending.delete(id); proof.rafFired++; callback(time); });
|
|
32
|
+
proof.pending.add(id);
|
|
33
|
+
return id;
|
|
34
|
+
};
|
|
35
|
+
window.cancelAnimationFrame = id => { proof.pending.delete(id); proof.rafCancelled++; nativeCancel(id); };
|
|
36
|
+
for (const name of ['ResizeObserver', 'IntersectionObserver', 'MutationObserver']) {
|
|
37
|
+
const Native = window[name];
|
|
38
|
+
window[name] = class extends Native {
|
|
39
|
+
constructor(callback) { super(callback); proof.observerCreates[name] = (proof.observerCreates[name] || 0) + 1; }
|
|
40
|
+
observe(target, ...args) { if (target instanceof HTMLCanvasElement && target.classList.contains('rhine-orbit-canvas') && !this.orbit) { this.orbit = true; proof.orbitObservers[name] = (proof.orbitObservers[name] || 0) + 1; } return super.observe(target, ...args); }
|
|
41
|
+
disconnect() { if(this.orbit) proof.orbitDisconnects[name] = (proof.orbitDisconnects[name] || 0) + 1; proof.observerDisconnects[name] = (proof.observerDisconnects[name] || 0) + 1; return super.disconnect(); }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const locations = new WeakMap(), contexts = new WeakMap();
|
|
45
|
+
const record = gl => {
|
|
46
|
+
if (!contexts.has(gl)) {
|
|
47
|
+
const entry = { canvas: gl.canvas, draws: 0, frames: 0, samples: [], time: null, matrices: [], resources: {}, lost: 0, restored: 0, queued: false };
|
|
48
|
+
contexts.set(gl, entry); proof.contexts.push(entry);
|
|
49
|
+
nativeAdd.call(gl.canvas, 'webglcontextlost', () => entry.lost++);
|
|
50
|
+
nativeAdd.call(gl.canvas, 'webglcontextrestored', () => entry.restored++);
|
|
51
|
+
}
|
|
52
|
+
return contexts.get(gl);
|
|
53
|
+
};
|
|
54
|
+
const scheduleSample = gl => {
|
|
55
|
+
const entry = record(gl);
|
|
56
|
+
if (entry.queued) return;
|
|
57
|
+
entry.queued = true;
|
|
58
|
+
queueMicrotask(() => {
|
|
59
|
+
entry.queued = false; entry.frames++;
|
|
60
|
+
const now = performance.now();
|
|
61
|
+
const retainSample = !entry.samples.length || now - entry.samples.at(-1).at >= 180;
|
|
62
|
+
try {
|
|
63
|
+
const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 72;
|
|
64
|
+
const context = canvas.getContext('2d'); context.drawImage(gl.canvas, 0, 0, 100, 72);
|
|
65
|
+
const pixels = context.getImageData(0, 0, 100, 72).data;
|
|
66
|
+
let hash = 2166136261, painted = 0, bright = 0, min = 255, max = 0;
|
|
67
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
68
|
+
for (let j = 0; j < 4; j++) hash = Math.imul(hash ^ pixels[i + j], 16777619);
|
|
69
|
+
if (pixels[i + 3]) painted++;
|
|
70
|
+
const luminance = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
|
|
71
|
+
if (luminance > 100) bright++;
|
|
72
|
+
min = Math.min(min, luminance); max = Math.max(max, luminance);
|
|
73
|
+
}
|
|
74
|
+
entry.latest = { at: now, time: entry.time, hash: hash >>> 0, painted, bright, min, max, matrices: entry.matrices.slice(0, 40) };
|
|
75
|
+
if (retainSample) entry.samples.push(entry.latest);
|
|
76
|
+
if (entry.samples.length > 700) entry.samples.shift();
|
|
77
|
+
} catch (error) { entry.sampleError = String(error); }
|
|
78
|
+
entry.matrices = [];
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
for (const Proto of [window.WebGLRenderingContext?.prototype, window.WebGL2RenderingContext?.prototype].filter(Boolean)) {
|
|
82
|
+
for (const name of ['drawArrays', 'drawElements', 'drawArraysInstanced', 'drawElementsInstanced']) {
|
|
83
|
+
const native = Proto[name]; if (!native || native.__donutProof) continue;
|
|
84
|
+
const wrapped = function (...args) { const result = native.apply(this, args); record(this).draws++; scheduleSample(this); return result; };
|
|
85
|
+
wrapped.__donutProof = true; Proto[name] = wrapped;
|
|
86
|
+
}
|
|
87
|
+
const nativeLocation = Proto.getUniformLocation;
|
|
88
|
+
if (!nativeLocation.__donutProof) {
|
|
89
|
+
const wrapped = function (program, name) { const value = nativeLocation.call(this, program, name); if (value) locations.set(value, name); return value; };
|
|
90
|
+
wrapped.__donutProof = true; Proto.getUniformLocation = wrapped;
|
|
91
|
+
}
|
|
92
|
+
for (const name of ['uniform1f', 'uniformMatrix4fv']) {
|
|
93
|
+
const native = Proto[name]; if (native.__donutProof) continue;
|
|
94
|
+
const wrapped = function (...args) {
|
|
95
|
+
const result = native.apply(this, args), uniform = locations.get(args[0]);
|
|
96
|
+
if (name === 'uniform1f' && /^(time|uTime)$/.test(uniform)) record(this).time = args[1];
|
|
97
|
+
if (name === 'uniformMatrix4fv' && uniform === 'modelViewMatrix') record(this).matrices.push(Array.from(args[2], value => Number(value.toFixed(5))));
|
|
98
|
+
return result;
|
|
99
|
+
};
|
|
100
|
+
wrapped.__donutProof = true; Proto[name] = wrapped;
|
|
101
|
+
}
|
|
102
|
+
for (const kind of ['Buffer', 'Texture', 'Program', 'Shader', 'Framebuffer', 'Renderbuffer', 'VertexArray']) {
|
|
103
|
+
for (const action of ['create', 'delete']) {
|
|
104
|
+
const name = action + kind, native = Proto[name]; if (!native || native.__donutProof) continue;
|
|
105
|
+
const wrapped = function (...args) { const result = native.apply(this, args); const entry = record(this); if (action === 'create' ? result : args[0]) entry.resources[name] = (entry.resources[name] || 0) + 1; return result; };
|
|
106
|
+
wrapped.__donutProof = true; Proto[name] = wrapped;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
window.__donutSnapshot = () => ({
|
|
111
|
+
pendingRafs: proof.pending.size, rafScheduled: proof.rafScheduled, rafFired: proof.rafFired, rafCancelled: proof.rafCancelled,
|
|
112
|
+
listeners: [...proof.listeners.values()], observerCreates: proof.observerCreates, observerDisconnects: proof.observerDisconnects, orbitObservers: proof.orbitObservers, orbitDisconnects: proof.orbitDisconnects,
|
|
113
|
+
contexts: proof.contexts.filter(entry => entry.canvas.classList.contains('rhine-orbit-canvas')).map(entry => ({
|
|
114
|
+
example: entry.canvas.closest('.rhine-orbit')?.getAttribute('aria-label') || null,
|
|
115
|
+
kind: entry.canvas.dataset.orbitArtwork || 'donut', active: !entry.canvas.hidden, canvasConnected: entry.canvas.isConnected, draws: entry.draws, frames: entry.frames, time: entry.time, samples: entry.samples, latest: entry.latest,
|
|
116
|
+
resources: entry.resources, lost: entry.lost, restored: entry.restored, sampleError: entry.sampleError, width: entry.canvas.width, height: entry.canvas.height,
|
|
117
|
+
})),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const results=[],errors=[]; page.on('pageerror',e=>errors.push(String(e)));
|
|
123
|
+
const snap=()=>page.evaluate(()=>window.__donutSnapshot());
|
|
124
|
+
for(const theme of ['rhine','arknights','neo-brutalism']) {
|
|
125
|
+
await page.setViewportSize({width:1280,height:1000});
|
|
126
|
+
await page.goto(`http://127.0.0.1:18484/?fixture=orbit-entry&theme=${theme}`);
|
|
127
|
+
const root=page.locator('.rhine-orbit'), canvas=root.locator('canvas').first();
|
|
128
|
+
await root.scrollIntoViewIfNeeded();
|
|
129
|
+
await page.waitForFunction(()=>window.__donutSnapshot().contexts.some(c=>c.canvasConnected&&c.active&&c.latest?.painted>50));
|
|
130
|
+
const active=s=>s.contexts.find(c=>c.canvasConnected&&c.active);
|
|
131
|
+
const a=active(await snap()); await page.waitForTimeout(450); const b=active(await snap());
|
|
132
|
+
const playing=a.latest.hash!==b.latest.hash&&b.draws>a.draws;
|
|
133
|
+
await page.getByRole('checkbox',{name:'减少入口动态'}).check();
|
|
134
|
+
await page.waitForTimeout(150);const c=active(await snap());await page.waitForTimeout(350); const d=active(await snap());
|
|
135
|
+
const manual=c.draws===d.draws&&c.latest.hash===d.latest.hash;
|
|
136
|
+
await page.getByRole('checkbox',{name:'减少入口动态'}).uncheck();
|
|
137
|
+
await page.emulateMedia({reducedMotion:'reduce'});await page.waitForTimeout(150);const e=active(await snap());await page.waitForTimeout(350);const f=active(await snap());
|
|
138
|
+
const system=e.draws===f.draws;
|
|
139
|
+
await page.emulateMedia({reducedMotion:'no-preference'});await page.waitForTimeout(350);const resumed=active(await snap()).draws>f.draws;
|
|
140
|
+
await root.evaluate(e=>e.style.display='none');await page.waitForTimeout(150);const g=active(await snap());await page.waitForTimeout(350);const h=active(await snap());
|
|
141
|
+
const empty=g.draws===h.draws;
|
|
142
|
+
await root.evaluate(e=>e.style.display='');await page.waitForTimeout(200);
|
|
143
|
+
await page.locator('.ds-orbit-entry-container').evaluate(e=>e.style.marginTop='2200px');await page.evaluate(()=>scrollTo(0,0));await page.waitForTimeout(150);const j=active(await snap());await page.waitForTimeout(350);const k=active(await snap());
|
|
144
|
+
const offscreen=j.draws===k.draws;
|
|
145
|
+
await page.locator('.ds-orbit-entry-container').evaluate(e=>e.style.marginTop=''); await root.scrollIntoViewIfNeeded();await page.waitForTimeout(250);
|
|
146
|
+
const other=await page.context().newPage();await other.goto('about:blank');await other.bringToFront();await page.waitForTimeout(250);
|
|
147
|
+
const actualHidden=await page.evaluate(()=>document.hidden);
|
|
148
|
+
await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));});
|
|
149
|
+
await page.waitForTimeout(150);
|
|
150
|
+
const hidden=await page.evaluate(()=>document.hidden);const l=active(await snap());await page.waitForTimeout(350);const m=active(await snap());
|
|
151
|
+
const hiddenPause=hidden&&l.draws===m.draws;
|
|
152
|
+
await other.close();await page.bringToFront();
|
|
153
|
+
await page.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'));});
|
|
154
|
+
await page.getByRole('checkbox',{name:'挂载入口'}).uncheck();await page.waitForTimeout(150);const disposed=await snap();
|
|
155
|
+
await page.waitForTimeout(350);const end=await snap();
|
|
156
|
+
const cleanup=end.contexts.every(c=>!c.canvasConnected)&&end.pendingRafs===0&&JSON.stringify(end.contexts.map(c=>c.draws))===JSON.stringify(disposed.contexts.map(c=>c.draws))&&['ResizeObserver','IntersectionObserver','MutationObserver'].every(n=>end.orbitObservers[n]>0&&end.orbitObservers[n]===end.orbitDisconnects[n]);
|
|
157
|
+
results.push({theme,playing,manual,system,resumed,empty,offscreen,actualHidden,hidden,hiddenPause,cleanup,pending:end.pendingRafs,observers:{created:end.observerCreates,disconnected:end.observerDisconnects,orbitCreated:end.orbitObservers,orbitDisconnected:end.orbitDisconnects},resources:end.contexts.map(c=>c.resources)});
|
|
158
|
+
}
|
|
159
|
+
const fallback=await page.context().newPage();
|
|
160
|
+
await fallback.addInitScript(()=>{HTMLCanvasElement.prototype.getContext=function(){return null;};});
|
|
161
|
+
const fallbacks=[];
|
|
162
|
+
for(const theme of ['rhine','arknights','neo-brutalism']) {
|
|
163
|
+
await fallback.goto(`http://127.0.0.1:18484/?fixture=orbit-entry&theme=${theme}`);
|
|
164
|
+
await fallback.waitForFunction(()=>document.querySelector('.rhine-orbit')?.dataset.rhineOrbitState==='unavailable');
|
|
165
|
+
fallbacks.push(await fallback.locator('.rhine-orbit').evaluate(e=>{const c=getComputedStyle(e.querySelector('canvas')),p=getComputedStyle(e,'::after');return {theme:e.closest('[data-rhine-theme]').dataset.rhineTheme,pass:c.width===p.width&&c.height===p.height&&c.right===p.right&&c.bottom===p.bottom&&p.backgroundImage.includes('svg')};}));
|
|
166
|
+
}
|
|
167
|
+
await fallback.close();
|
|
168
|
+
return {pass:results.every(r=>r.playing&&r.manual&&r.system&&r.resumed&&r.empty&&r.offscreen&&r.hiddenPause&&r.cleanup)&&fallbacks.every(r=>r.pass)&&!errors.length,results,fallbacks,errors};
|
|
169
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
async page => {
|
|
2
|
+
await page.goto(`http://127.0.0.1:18484/?fixture=list-filters&theme=neo-brutalism&proof=${Date.now()}#list-filter-bar`);
|
|
3
|
+
const checks = [];
|
|
4
|
+
const check = (name, pass, evidence = {}) => checks.push({ name, pass: Boolean(pass), ...evidence });
|
|
5
|
+
await page.getByRole('group', { name: '录制会话筛选', exact: true }).getByRole('button', { name: /^更多筛选/ }).click();
|
|
6
|
+
const dialog = page.getByRole('dialog', { name: '录制会话筛选', exact: true });
|
|
7
|
+
const start = dialog.getByLabel('开始日期', { exact: true });
|
|
8
|
+
const end = dialog.getByLabel('结束日期', { exact: true });
|
|
9
|
+
const inspect = () => start.evaluate(element => ({ value: element.value, badInput: element.validity.badInput, invalid: element.getAttribute('aria-invalid') }));
|
|
10
|
+
await start.fill('2026-10-20'); await end.fill('2026-10-24');
|
|
11
|
+
await start.focus(); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('Backspace');
|
|
12
|
+
await page.waitForTimeout(100);
|
|
13
|
+
let value = await inspect();
|
|
14
|
+
check('Deleting a native date segment keeps accepted range and reports invalid', value.badInput && value.invalid === 'true' && (await page.locator('#list-filter-bar .rhine-list-filter-bar__active').first().innerText()).includes('2026-10-20 — 2026-10-24'), value);
|
|
15
|
+
await page.keyboard.type('2026'); await page.waitForTimeout(100); value = await inspect();
|
|
16
|
+
check('Typing the complete year repairs native draft', value.value === '2026-10-20' && !value.badInput && value.invalid !== 'true', value);
|
|
17
|
+
await dialog.getByRole('button', { name: '清空日期', exact: true }).click();
|
|
18
|
+
await start.evaluate(element => { window.nativeDateEvents = []; for (const name of ['input', 'change', 'keyup', 'blur']) element.addEventListener(name, event => window.nativeDateEvents.push({ event: event.type, key: event.key, value: element.value, badInput: element.validity.badInput })); });
|
|
19
|
+
await start.focus(); await page.keyboard.type('2'); await page.waitForTimeout(100); value = await inspect();
|
|
20
|
+
check('Empty date native fragment is invalid even when no input/change fires', value.badInput && value.invalid === 'true' && await dialog.getByRole('button', { name: '清空日期', exact: true }).isEnabled(), { ...value, events: await page.evaluate(() => window.nativeDateEvents) });
|
|
21
|
+
if (await dialog.getByRole('button', { name: '清空日期', exact: true }).isEnabled()) {
|
|
22
|
+
await dialog.getByRole('button', { name: '清空日期', exact: true }).click(); await page.waitForTimeout(100); value = await inspect();
|
|
23
|
+
check('Clear removes an empty native fragment and validation error', value.value === '' && !value.badInput && value.invalid !== 'true', value);
|
|
24
|
+
}
|
|
25
|
+
await dialog.getByRole('button', { name: '今天', exact: true }).click();
|
|
26
|
+
await start.focus(); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('Backspace');
|
|
27
|
+
await dialog.getByRole('button', { name: '今天', exact: true }).click(); await page.waitForTimeout(100); value = await inspect();
|
|
28
|
+
check('Applying the same preset clears a rejected native draft', value.value === '2026-10-24' && !value.badInput && value.invalid !== 'true', value);
|
|
29
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
|
|
30
|
+
for (let i=0;i<3;i++) {
|
|
31
|
+
const bar=page.locator('.rhine-list-filter-bar').nth(i);
|
|
32
|
+
if(await bar.getByRole('button',{name:'重置筛选',exact:true}).count()) await bar.getByRole('button',{name:'重置筛选',exact:true}).click();
|
|
33
|
+
await bar.getByRole('button',{name:/^更多筛选/}).click(); const panel=page.getByRole('dialog');
|
|
34
|
+
if(i<2) await panel.getByRole('checkbox',{name:'只看已审查'}).check();
|
|
35
|
+
else await panel.getByRole('combobox',{name:'统计范围'}).selectOption({label:'示例团队 / 跨部门体验与可访问性协作小组'});
|
|
36
|
+
const date=panel.getByLabel('开始日期',{exact:true});
|
|
37
|
+
await date.focus();
|
|
38
|
+
if(i===2){await page.keyboard.press('ArrowLeft');await page.keyboard.press('ArrowLeft');await page.keyboard.press('Backspace');}
|
|
39
|
+
else await page.keyboard.type('2');
|
|
40
|
+
await page.waitForFunction(e=>e.validity.badInput&&e.getAttribute('aria-invalid')==='true',await date.elementHandle());
|
|
41
|
+
await panel.getByRole('button',{name:'重置筛选',exact:true}).click();
|
|
42
|
+
const clean=await date.evaluate(e=>!e.validity.badInput&&e.getAttribute('aria-invalid')!=='true');
|
|
43
|
+
check(`Overall reset clears default-valued native draft in example ${i}`,clean&&await panel.evaluate(e=>e.contains(document.activeElement)));
|
|
44
|
+
await page.keyboard.press('Escape');await panel.waitFor({state:'detached'});
|
|
45
|
+
}
|
|
46
|
+
return { complete: checks.every(item => item.pass), checks, scope: 'Real native date segmentation in isolated Chromium; keyboard presses use page.keyboard so locator focus does not reset year digit buffering.' };
|
|
47
|
+
}
|