chore: rebuild CentOS 7 release package
This commit is contained in:
@@ -541,7 +541,13 @@ function assertFrontendSidebarCollapseContract() {
|
||||
});
|
||||
assert(source.includes("const SIDEBAR_COLLAPSE_STORAGE_KEY = 'cc-web-sidebar-collapsed'"), 'Desktop sidebar preference should use a stable storage key');
|
||||
assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint');
|
||||
assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities');
|
||||
assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px)'"), 'Wide sidebar collapse mode should be available by viewport width');
|
||||
assert(source.includes("const SIDEBAR_HOVER_PREVIEW_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Hover preview should retain the fine-pointer capability gate');
|
||||
assert(
|
||||
source.includes('const sidebarDesktopCollapseMedia = window.matchMedia(SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY)')
|
||||
&& source.includes('const sidebarHoverPreviewMedia = window.matchMedia(SIDEBAR_HOVER_PREVIEW_MEDIA_QUERY)'),
|
||||
'Wide fixed collapse and hover preview should use separate media queries'
|
||||
);
|
||||
assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root');
|
||||
assert(
|
||||
indexSource.includes('id="user-outline-panel" class="user-outline-panel"')
|
||||
@@ -566,19 +572,35 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Sidebar toggle should preserve the mobile drawer path before using desktop fixed collapse'
|
||||
);
|
||||
assert(
|
||||
source.includes('[sidebarDrawerMedia, sidebarDesktopCollapseMedia].forEach((media) =>')
|
||||
source.includes('[sidebarDrawerMedia, sidebarDesktopCollapseMedia, sidebarHoverPreviewMedia].forEach((media) =>')
|
||||
&& source.includes("media.addEventListener('change', handleSidebarInputModeChange)"),
|
||||
'Sidebar should reset transient state when either drawer or desktop collapse mode changes'
|
||||
);
|
||||
|
||||
assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail');
|
||||
const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)');
|
||||
const desktopCollapseStyleEnd = styleSource.indexOf('@media (prefers-reduced-motion: reduce)', desktopCollapseStyleStart);
|
||||
const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px)');
|
||||
const hoverPreviewStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)');
|
||||
const desktopCollapseStyleEnd = hoverPreviewStyleStart;
|
||||
assert(
|
||||
desktopCollapseStyleStart >= 0 && desktopCollapseStyleEnd > desktopCollapseStyleStart,
|
||||
'Desktop hover behavior should stay isolated from touch and narrow-screen drawers'
|
||||
desktopCollapseStyleStart >= 0
|
||||
&& hoverPreviewStyleStart > desktopCollapseStyleStart
|
||||
&& desktopCollapseStyleEnd > desktopCollapseStyleStart,
|
||||
'Wide fixed collapse styles should be separate from hover preview styles'
|
||||
);
|
||||
const desktopCollapseStyle = styleSource.slice(desktopCollapseStyleStart, desktopCollapseStyleEnd);
|
||||
assert(
|
||||
/\.menu-btn\s*\{[^}]*display:\s*inline-flex;/.test(desktopCollapseStyle),
|
||||
'Wide touch viewports should keep the sidebar toggle visible'
|
||||
);
|
||||
assert(
|
||||
!/\.app\.sidebar-collapsed \.sidebar:is\(:hover, :focus-within\)/.test(desktopCollapseStyle),
|
||||
'Wide fixed collapse should not enable hover preview on touch viewports'
|
||||
);
|
||||
const hoverPreviewStyle = styleSource.slice(hoverPreviewStyleStart, styleSource.indexOf('@media (prefers-reduced-motion: reduce)', hoverPreviewStyleStart));
|
||||
assert(
|
||||
/\.app\.sidebar-collapsed \.sidebar:is\(:hover, :focus-within\)/.test(hoverPreviewStyle),
|
||||
'Hover-capable desktops should retain temporary sidebar preview'
|
||||
);
|
||||
assert(
|
||||
styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))')
|
||||
&& styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'),
|
||||
@@ -641,7 +663,7 @@ function assertFrontendSidebarCollapseContract() {
|
||||
assert(
|
||||
source.includes('if (isSidebarDesktopCollapseMode())')
|
||||
&& source.includes("const expanded = isDrawer\n ? sidebar.classList.contains('open')\n : (isDesktopCollapsible ? !app.classList.contains('sidebar-collapsed') : true)"),
|
||||
'Unsupported wide-screen pointer modes should stay visually and accessibly expanded'
|
||||
'Wide touch viewports should use the fixed collapse state for labels and aria-expanded'
|
||||
);
|
||||
assert(
|
||||
styleSource.includes("html[data-theme='gilded'] .app.sidebar-collapsed .sidebar::after")
|
||||
@@ -895,7 +917,8 @@ function assertComposerSlashRoutingContract() {
|
||||
assert(source.includes('if (!msg.preserveComposerDraft) return false;'), 'Frontend should restore slash drafts only for explicit failure responses');
|
||||
assert(source.includes("if (msgInput.value !== '' || pendingAttachments.length > 0) return false;"), 'Frontend should not overwrite user input typed after a slash request');
|
||||
assert(source.includes('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);'), 'Frontend should remember slash draft before clearing the composer');
|
||||
assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId');
|
||||
assert(source.includes("const clientMessageId = isGoalSetCommandText(text) ? createLocalId('user') : '';"), 'Frontend should allocate a stable user-message id for setting /goal');
|
||||
assert(source.includes('...(clientMessageId ? { clientMessageId } : {}),'), 'Frontend should send the Goal user-message id without changing other slash payloads');
|
||||
assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error');
|
||||
|
||||
const frontendCommandsMatch = source.match(/const\s+SLASH_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
|
||||
@@ -919,6 +942,15 @@ function assertComposerSlashRoutingContract() {
|
||||
});
|
||||
assert(!knownSlashApi('/report/mcps?search') && !knownSlashApi('/help/topic'), 'Unknown slash paths should remain ordinary messages');
|
||||
|
||||
const goalSetSource = extractFunctionSource(source, 'isGoalSetCommandText');
|
||||
const goalSetApi = new Function(`
|
||||
${goalSetSource}
|
||||
return isGoalSetCommandText;
|
||||
`)();
|
||||
assert(goalSetApi('/goal improve benchmark coverage'), 'Frontend should recognize setting /goal as a Goal bubble command');
|
||||
assert(goalSetApi('/GOAL\nlong objective'), 'Frontend should recognize multiline and case-insensitive Goal objectives');
|
||||
assert(!goalSetApi('/goal') && !goalSetApi('/goal pause') && !goalSetApi('/goal resume') && !goalSetApi('/goal clear'), 'Frontend should keep Goal control commands out of user bubbles');
|
||||
|
||||
const tokenSource = extractFunctionSource(source, 'findActiveComposerToken');
|
||||
const tokenApi = new Function(`
|
||||
let msgInput = null;
|
||||
@@ -7032,17 +7064,40 @@ async function main() {
|
||||
assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message');
|
||||
assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalRequestId = 'regression-goal-request';
|
||||
const codexAppGoalMessageId = 'regression-goal-user-message';
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/goal improve benchmark coverage',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
requestId: codexAppGoalRequestId,
|
||||
clientMessageId: codexAppGoalMessageId,
|
||||
}));
|
||||
const codexAppGoalBubble = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
msg.message?.id === codexAppGoalMessageId
|
||||
), 5000);
|
||||
assert(codexAppGoalBubble.message.role === 'user', 'Setting /goal should render through the normal user-message role');
|
||||
assert(codexAppGoalBubble.message.content === 'improve benchmark coverage', 'Goal user bubble should contain only the objective');
|
||||
assert(codexAppGoalBubble.message.ccwebDisplayOnly === true, 'Goal user bubble should be display-only local history');
|
||||
assert(codexAppGoalBubble.message.ccwebGoalCommand?.action === 'set', 'Goal user bubble should carry dedicated Goal metadata');
|
||||
assert(codexAppGoalBubble.message.ccwebGoalCommand?.requestId === codexAppGoalRequestId, 'Goal metadata should preserve the stable request id');
|
||||
const codexAppGoalSyncing = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /正在同步 Goal/.test(msg.message || ''), 5000);
|
||||
assert(/正在同步 Goal/.test(codexAppGoalSyncing.message || ''), 'Codex App /goal should immediately show a syncing notice');
|
||||
assert(codexAppGoalSyncing.transient === true, 'Codex App /goal syncing notice should auto-dismiss');
|
||||
const codexAppGoalRunningList = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_list' &&
|
||||
Array.isArray(msg.sessions) &&
|
||||
msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
|
||||
), 5000);
|
||||
assert(codexAppGoalRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /goal RPC should mark the session running while waiting for app-server');
|
||||
const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
|
||||
const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || ''));
|
||||
assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal');
|
||||
assert(!/improve benchmark coverage/.test(codexAppGoalSet.message || ''), 'Goal success notice should not repeat the full objective');
|
||||
assert(codexAppGoalSet.transient === true, 'Goal success notice should auto-dismiss');
|
||||
const codexAppGoalBackgroundDelta = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
@@ -7056,6 +7111,24 @@ async function main() {
|
||||
msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning)
|
||||
), 5000);
|
||||
assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/goal improve benchmark coverage',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
requestId: codexAppGoalRequestId,
|
||||
clientMessageId: codexAppGoalMessageId,
|
||||
}));
|
||||
const codexAppGoalDuplicate = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'system_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/Goal 设置请求已处理/.test(msg.message || '')
|
||||
), 5000);
|
||||
assert(codexAppGoalDuplicate.transient === true, 'Duplicate Goal command ids should receive a compact transient acknowledgement');
|
||||
await sleep(250);
|
||||
assert(!messages.some((msg) => msg.type === 'session_message' && msg.message?.id === codexAppGoalMessageId), 'Duplicate Goal command ids should not append another user bubble');
|
||||
assert(!messages.some((msg) => msg.type === 'text_delta' && /Goal background output: improve benchmark coverage/.test(msg.text || '')), 'Duplicate Goal command ids should not trigger another Goal RPC background turn');
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
|
||||
assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal');
|
||||
@@ -7072,7 +7145,11 @@ async function main() {
|
||||
const codexAppGoalEmpty = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /用法: \/goal <目标描述>/.test(msg.message || ''));
|
||||
assert(/\/goal <目标描述>/.test(codexAppGoalEmpty.message || ''), 'Codex App /goal should show usage when no goal exists');
|
||||
const storedCodexAppAfterGoal = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Codex App /goal slash commands should not be persisted as normal user messages');
|
||||
const storedGoalMessages = storedCodexAppAfterGoal.messages.filter((message) => message.id === codexAppGoalMessageId);
|
||||
assert(storedGoalMessages.length === 1, 'Codex App should persist exactly one Goal user bubble for a stable command id');
|
||||
assert(storedGoalMessages[0].role === 'user' && storedGoalMessages[0].content === 'improve benchmark coverage', 'Persisted Goal bubble should restore as the objective-only user message');
|
||||
assert(storedGoalMessages[0].ccwebDisplayOnly === true && storedGoalMessages[0].ccwebGoalCommand?.action === 'set', 'Persisted Goal bubble should remain marked as display-only Goal history');
|
||||
assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Raw /goal commands should never enter ordinary user-message history');
|
||||
assert(storedCodexAppAfterGoal.messages.some((message) => (
|
||||
message.role === 'assistant' &&
|
||||
/Goal background output: improve benchmark coverage/.test(String(message.content || ''))
|
||||
|
||||
@@ -291,6 +291,23 @@ class FakeElement {
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
const wallColumn = this.closest('.ccweb-task-board__wall-column');
|
||||
if (wallColumn) {
|
||||
const wallGrid = wallColumn.parentNode;
|
||||
const cells = wallGrid?.children?.filter((node) => (
|
||||
node.classList.contains('ccweb-task-board__clock')
|
||||
|| node.classList.contains('ccweb-task-board__wall-column')
|
||||
)) || [];
|
||||
const cellIndex = Math.max(0, cells.indexOf(wallColumn));
|
||||
const columnCount = Number(wallGrid?.style?.getPropertyValue('--task-board-wall-columns')) || 1;
|
||||
const wallCards = this.parentNode?.children?.filter((node) => node.classList.contains('ccweb-task-board__wall-card')) || [];
|
||||
const wallCardIndex = Math.max(0, wallCards.indexOf(this));
|
||||
const left = 8 + ((cellIndex % columnCount) * 288);
|
||||
const top = 8 + (Math.floor(cellIndex / columnCount) * 210) + 42 + (wallCardIndex * 78);
|
||||
const width = 270;
|
||||
const height = 70;
|
||||
return { x: left, y: top, left, top, right: left + width, bottom: top + height, width, height };
|
||||
}
|
||||
const column = this.closest('.ccweb-task-board__column');
|
||||
const lanes = column?.parentNode;
|
||||
const columns = lanes?.children?.filter((node) => node.classList.contains('ccweb-task-board__column')) || [];
|
||||
@@ -440,6 +457,10 @@ async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function wait(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const source = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
||||
const css = fs.readFileSync(STYLE_PATH, 'utf8');
|
||||
@@ -464,6 +485,77 @@ async function main() {
|
||||
assert(css.includes('.ccweb-task-board__lanes'), 'CSS 应包含横向看板工作区');
|
||||
assert(/overflow-x:\s*auto/.test(css), '看板应允许横向滚动');
|
||||
assert(/grid-auto-columns:/.test(css), '看板列数和宽度应动态生成');
|
||||
assert(source.includes("'--task-board-column-count'"), '看板应把服务端动态列数暴露给响应式布局');
|
||||
assert(css.includes('@media (orientation: landscape) and (max-height: 768px)'), 'CSS 应覆盖桌面模式浏览器下的手机横屏高度');
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel\s*\{[^}]*position:\s*fixed;[^}]*inset:\s*0;/.test(css),
|
||||
'横屏看板必须覆盖整个网页视口而不是留在聊天内容区',
|
||||
);
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel\s*\{[^}]*height:\s*100vh;[^}]*height:\s*100dvh;/.test(css),
|
||||
'横屏看板必须为旧浏览器提供 100vh 回退并优先使用动态视口高度',
|
||||
);
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel__root\s*\{[^}]*height:\s*100vh;[^}]*height:\s*100dvh;/.test(css),
|
||||
'横屏挂载根必须把完整视口高度继续传给看板矩阵',
|
||||
);
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board\s*\{[^}]*grid-template-rows:\s*minmax\(0,\s*1fr\);/.test(css),
|
||||
'横屏隐藏工具栏后根网格必须只保留一行,不能留下空白的第二轨道',
|
||||
);
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel__header\s*\{[^}]*position:\s*absolute;/.test(css),
|
||||
'横屏返回按钮必须叠入看板顶带而不是单占一行',
|
||||
);
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board__toolbar\s*\{[^}]*display:\s*none;/.test(css),
|
||||
'横屏只读看板不得保留任务看板工具条',
|
||||
);
|
||||
assert(
|
||||
css.includes('.ccweb-task-board__clock-time') && css.includes('.ccweb-task-board__clock-date'),
|
||||
'数字时钟应具有明确的时间与日期层级',
|
||||
);
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board__lanes\s*\{[^}]*display:\s*none;/.test(css),
|
||||
'横屏展示不得暴露可操作的传统看板列',
|
||||
);
|
||||
assert(
|
||||
/@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board__wall-grid\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(var\(--task-board-wall-columns\),\s*minmax\(0,\s*1fr\)\);[^}]*grid-template-rows:\s*repeat\(var\(--task-board-wall-rows\),\s*minmax\(0,\s*1fr\)\);/.test(css),
|
||||
'横屏看板应把时钟格与状态格排成动态矩阵',
|
||||
);
|
||||
assert(
|
||||
css.includes('.ccweb-task-board__wall-column')
|
||||
&& css.includes('.ccweb-task-board__wall-card-title')
|
||||
&& css.includes('.ccweb-task-board__wall-card-summary'),
|
||||
'横屏看板应具有动态状态格和多任务卡层级',
|
||||
);
|
||||
assert(source.includes('wallRotationMs') && source.includes('wallUpdateHoldMs'), '状态格应提供列内轮换与更新延长停留时长');
|
||||
assert(!source.includes('ccweb-task-board__wall-focus'), '横屏看板不得再渲染单任务聚焦舞台');
|
||||
assert(!source.includes('ccweb-task-board__wall-stage'), '横屏看板不得再保留巨幅单任务舞台');
|
||||
for (const animationId of [
|
||||
'ccweb-task-board-wall-card-page-leave',
|
||||
'ccweb-task-board-wall-card-page-enter',
|
||||
'ccweb-task-board-wall-card-move',
|
||||
'ccweb-task-board-wall-card-update',
|
||||
]) {
|
||||
assert(source.includes(animationId), `横屏看板缺少卡片动效 ${animationId}`);
|
||||
}
|
||||
assert(
|
||||
/\.ccweb-task-board__wall-card-summary\s*\{[^}]*white-space:\s*normal;[^}]*\}/.test(css),
|
||||
'横屏任务摘要应在卡片内自然换行',
|
||||
);
|
||||
assert(
|
||||
!/\.ccweb-task-board__wall-card-(?:title|summary)\s*\{[^}]*-webkit-line-clamp:/.test(css),
|
||||
'横屏任务标题和摘要不得使用行数截断',
|
||||
);
|
||||
assert(
|
||||
/\.ccweb-task-board__clock\s*\{[^}]*background:\s*var\(--bg-secondary\);/.test(css),
|
||||
'时钟应作为矩阵中的普通主题格而不是巨型侧栏',
|
||||
);
|
||||
assert(
|
||||
/body:has\(\.task-board-panel:not\(\[hidden\]\)\)\s+\.menu-btn\s*\{[^}]*display:\s*none/.test(css),
|
||||
'横屏看板打开时应隐藏宿主侧栏浮动菜单按钮',
|
||||
);
|
||||
assert(css.includes(':focus-visible'), 'CSS 应提供键盘焦点样式');
|
||||
assert(css.includes('@media (prefers-reduced-motion: reduce)'), 'CSS 应尊重减少动态效果偏好');
|
||||
assert(
|
||||
@@ -639,6 +731,35 @@ async function main() {
|
||||
'状态列应按服务端 order 排序',
|
||||
);
|
||||
const lanesNode = byClass(root, 'ccweb-task-board__lanes')[0];
|
||||
assert.strictEqual(lanesNode.style.getPropertyValue('--task-board-column-count'), '4', '横屏布局列数应跟随服务端定义');
|
||||
assert.strictEqual(lanesNode.style.getPropertyValue('--task-board-grid-columns'), '', '看板不得再计算宫格列数');
|
||||
assert.strictEqual(lanesNode.dataset.columnCount, '4', 'CSS 应能读取真实泳道数量');
|
||||
assert.strictEqual(lanesNode.dataset.columnLayout, 'fit', '常规动态列数应使用整屏均分布局');
|
||||
const wall = byClass(root, 'ccweb-task-board__wall')[0];
|
||||
assert(wall, '独立看板应同时提供横屏只读看板');
|
||||
const wallGrid = byClass(wall, 'ccweb-task-board__wall-grid')[0];
|
||||
assert(wallGrid, '横屏看板应提供时钟与状态格共用的矩阵');
|
||||
const clock = byClass(root, 'ccweb-task-board__clock')[0];
|
||||
assert(clock, '横屏看板应提供数字时钟');
|
||||
assert.strictEqual(clock.parentNode, wallGrid, '数字时钟应直接占据矩阵中的一格');
|
||||
assert(/^\d{2}:\d{2}$/.test(byClass(clock, 'ccweb-task-board__clock-time')[0].textContent), '时钟应显示 24 小时制时分');
|
||||
assert(/\d{1,2}月\d{1,2}日/.test(byClass(clock, 'ccweb-task-board__clock-date')[0].textContent), '时钟应显示月日');
|
||||
assert.strictEqual(byClass(wall, 'ccweb-task-board__wall-column').length, definitions.length, '状态格必须由服务端定义动态生成');
|
||||
assert.strictEqual(wallGrid.style.getPropertyValue('--task-board-wall-columns'), '3', '五个矩阵格应排成三列两行');
|
||||
assert.strictEqual(wallGrid.style.getPropertyValue('--task-board-wall-rows'), '2', '五个矩阵格应排成三列两行');
|
||||
assert.strictEqual(byClass(wall, 'ccweb-task-board__wall-card').length, 3, '横屏看板应同时显示多个状态中的任务卡');
|
||||
assert.strictEqual(
|
||||
walk(wall).filter((node) => ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(node.tagName)).length,
|
||||
0,
|
||||
'横屏看板必须完全只读',
|
||||
);
|
||||
const runningWallCard = byClass(wall, 'ccweb-task-board__wall-card')
|
||||
.find((card) => card.dataset.sessionId === 'session-one');
|
||||
assert(runningWallCard, '运行任务应与其他任务一起出现在所属状态格');
|
||||
assert.strictEqual(runningWallCard.closest('.ccweb-task-board__wall-column').dataset.statusId, 'queue-x');
|
||||
assert(byClass(runningWallCard, 'ccweb-task-board__wall-card-title')[0].textContent.includes('定位协议边界'), '任务格应显示完整任务标题文本');
|
||||
assert.strictEqual(walk(runningWallCard).some((node) => node.tagName === 'IMG'), false, '横屏看板也不得把任务标题解析成 HTML');
|
||||
assert.strictEqual(byClass(wall, 'ccweb-task-board__wall-focus').length, 0, '横屏看板不得退化成单任务聚焦屏');
|
||||
const originalColumns = new Map(
|
||||
byClass(root, 'ccweb-task-board__column').map((column) => [column.dataset.statusId, column]),
|
||||
);
|
||||
@@ -666,7 +787,9 @@ async function main() {
|
||||
assert.strictEqual(firstCard.textContent.includes('42%'), false, '任务卡不得把历史百分比渲染为文案');
|
||||
assert.strictEqual(firstCard.textContent.includes('任务:入口'), false, '卡片不得重复显示只读业务状态');
|
||||
assert.strictEqual(byClass(firstCard, 'ccweb-task-board__business-state').length, 0, '卡片不得渲染业务状态胶囊');
|
||||
assert.strictEqual(byAction(firstCard, 'set-status')[0].value, 'queue-x', '状态选择器应作为业务状态的唯一卡片入口');
|
||||
assert.strictEqual(byAction(firstCard, 'set-status')[0].value, '', '移动控件不得重复显示当前所在列');
|
||||
assert.strictEqual(byAction(firstCard, 'set-status')[0].textContent.includes('入口'), false, '移动选项不得再次列出当前状态');
|
||||
assert(byAction(firstCard, 'set-status')[0].textContent.includes('移动到…'), '状态入口应明确表达移动动作');
|
||||
assert(firstCard.textContent.includes('运行:运行中'), '卡片应独立显示运行态');
|
||||
assert.strictEqual(firstTaskState.isRunning, true, '前端公开任务状态应保留服务端明确下发的运行布尔值');
|
||||
assert.strictEqual(firstCard.dataset.runtimeState, 'running', '运行卡片应暴露稳定的 data-runtime-state 契约');
|
||||
@@ -689,7 +812,7 @@ async function main() {
|
||||
board.setFilters({ archived: true });
|
||||
assert.strictEqual(byClass(root, 'ccweb-task-board__card').length, 1, '归档视图应只显示软归档任务');
|
||||
const archivedCard = byClass(root, 'ccweb-task-board__card')[0];
|
||||
assert.strictEqual(byAction(archivedCard, 'set-status')[0].value, 'queue-x', '归档不应改写任务业务状态');
|
||||
assert.strictEqual(byAction(archivedCard, 'set-status')[0].value, '', '归档任务的移动控件也不得重复当前状态');
|
||||
board.setFilters({ archived: false, search: '' });
|
||||
|
||||
const activeFirstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one');
|
||||
@@ -707,6 +830,7 @@ async function main() {
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(statusRequest, 'progress'), false, '状态写请求不得发送百分比进度');
|
||||
assert.strictEqual(statusRequest.expectedVersion, 5, '写请求应携带期望任务版本');
|
||||
assert(statusRequest.requestId, '写请求应携带 requestId');
|
||||
assert.strictEqual(statusSelect.value, '', '提交移动后应恢复为紧凑动作提示');
|
||||
|
||||
const archiveButton = byAction(activeFirstCard, 'set-archive')[0];
|
||||
emit(archiveButton, 'click');
|
||||
@@ -747,6 +871,9 @@ async function main() {
|
||||
assert.strictEqual(byClass(root, 'ccweb-task-board__toolbar')[0].hasAttribute('inert'), false, '关闭后应解除 inert');
|
||||
|
||||
const moveAnimationCount = activeFirstCard.animations.length;
|
||||
const activeWallCard = byClass(root, 'ccweb-task-board__wall-card')
|
||||
.find((card) => card.dataset.sessionId === 'session-one');
|
||||
const wallMoveAnimationCount = activeWallCard.animations.length;
|
||||
statusSelect.focus();
|
||||
board.handleMessage({
|
||||
type: 'task_board_event',
|
||||
@@ -776,6 +903,15 @@ async function main() {
|
||||
const movedStatusSelect = byAction(movedFirstCard, 'set-status')[0];
|
||||
assert.strictEqual(movedFirstCard, activeFirstCard, '跨列更新必须移动原卡片节点,而非销毁后新建');
|
||||
assert.strictEqual(movedFirstCard.parentNode.parentNode.dataset.statusId, 'review-custom', '卡片应进入服务端指定状态列');
|
||||
const movedWallCard = byClass(root, 'ccweb-task-board__wall-card')
|
||||
.find((card) => card.dataset.sessionId === 'session-one');
|
||||
assert.strictEqual(movedWallCard, activeWallCard, '横屏看板也必须复用并移动原只读任务卡');
|
||||
assert.strictEqual(movedWallCard.closest('.ccweb-task-board__wall-column').dataset.statusId, 'review-custom');
|
||||
assert(
|
||||
movedWallCard.animations.slice(wallMoveAnimationCount)
|
||||
.some((animation) => animation.options.id === 'ccweb-task-board-wall-card-move'),
|
||||
'横屏任务卡跨状态格时应触发可见的 FLIP 位移动画',
|
||||
);
|
||||
assert.strictEqual(document.activeElement, movedStatusSelect, '卡片内容更新后应把键盘焦点恢复到状态控件');
|
||||
assert.strictEqual(byClass(root, 'ccweb-task-board__lanes')[0], lanesNode, '数据事件不得替换看板滚动节点');
|
||||
for (const [statusId, originalColumn] of originalColumns) {
|
||||
@@ -891,20 +1027,139 @@ async function main() {
|
||||
});
|
||||
assert.strictEqual(board.getState().tasks.length, 1, '最新查询结果应替换当前快照');
|
||||
|
||||
const rotatingRoot = document.createElement('div');
|
||||
document.body.appendChild(rotatingRoot);
|
||||
const rotatingBoard = api.mount(rotatingRoot, {
|
||||
document,
|
||||
autoLoad: false,
|
||||
wallRotationMs: 50,
|
||||
wallUpdateHoldMs: 140,
|
||||
wallCardsPerPage: 2,
|
||||
initialData: {
|
||||
statusDefinitions: definitions,
|
||||
tasks: [
|
||||
{
|
||||
sessionId: 'wall-running',
|
||||
title: '正在执行的任务',
|
||||
summary: '这是一段应在状态格内完整换行展示的任务摘要。',
|
||||
isRunning: true,
|
||||
runtimeState: '运行中',
|
||||
updatedAt: '2026-08-11T06:00:00.000Z',
|
||||
taskTracking: { enabled: true, statusId: 'doing-z', version: 1 },
|
||||
},
|
||||
{
|
||||
sessionId: 'wall-newer',
|
||||
title: '同列最近更新的任务',
|
||||
updatedAt: '2026-08-11T07:00:00.000Z',
|
||||
taskTracking: { enabled: true, statusId: 'doing-z', version: 1 },
|
||||
},
|
||||
{
|
||||
sessionId: 'wall-old',
|
||||
title: '同列较早任务',
|
||||
updatedAt: '2026-08-11T05:00:00.000Z',
|
||||
taskTracking: { enabled: true, statusId: 'doing-z', version: 1 },
|
||||
},
|
||||
{
|
||||
sessionId: 'wall-review',
|
||||
title: '另一状态中的任务',
|
||||
updatedAt: '2026-08-11T04:00:00.000Z',
|
||||
taskTracking: { enabled: true, statusId: 'review-custom', version: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const rotatingWall = byClass(rotatingRoot, 'ccweb-task-board__wall')[0];
|
||||
const doingWallColumn = byClass(rotatingWall, 'ccweb-task-board__wall-column')
|
||||
.find((column) => column.dataset.statusId === 'doing-z');
|
||||
const reviewWallColumn = byClass(rotatingWall, 'ccweb-task-board__wall-column')
|
||||
.find((column) => column.dataset.statusId === 'review-custom');
|
||||
const initialDoingCards = byClass(doingWallColumn, 'ccweb-task-board__wall-card');
|
||||
assert.deepStrictEqual(
|
||||
initialDoingCards.map((card) => card.dataset.sessionId),
|
||||
['wall-running', 'wall-newer'],
|
||||
'状态格第一页应同时显示多张任务卡,并优先显示运行与最近更新任务',
|
||||
);
|
||||
assert.strictEqual(
|
||||
byClass(initialDoingCards[0], 'ccweb-task-board__wall-card-summary')[0].textContent,
|
||||
'这是一段应在状态格内完整换行展示的任务摘要。',
|
||||
'横屏任务卡必须保留完整摘要文本',
|
||||
);
|
||||
const stableReviewCard = byClass(reviewWallColumn, 'ccweb-task-board__wall-card')[0];
|
||||
await wait(70);
|
||||
const rotatedDoingCards = byClass(doingWallColumn, 'ccweb-task-board__wall-card');
|
||||
assert.deepStrictEqual(
|
||||
rotatedDoingCards.map((card) => card.dataset.sessionId),
|
||||
['wall-old'],
|
||||
'溢出时只应轮换当前状态格内的下一页任务',
|
||||
);
|
||||
assert.strictEqual(byClass(reviewWallColumn, 'ccweb-task-board__wall-card')[0], stableReviewCard, '列内轮换不得替换其他状态格的任务');
|
||||
assert(
|
||||
initialDoingCards.some((card) => card.animations.some((animation) => animation.options.id === 'ccweb-task-board-wall-card-page-leave')),
|
||||
'列内换页应让旧页卡片收缩离场',
|
||||
);
|
||||
assert(
|
||||
rotatedDoingCards[0].animations.some((animation) => animation.options.id === 'ccweb-task-board-wall-card-page-enter'),
|
||||
'列内换页应让新页卡片展开进入',
|
||||
);
|
||||
assert.strictEqual(byClass(doingWallColumn, 'ccweb-task-board__wall-column-page')[0].textContent, '2 / 2', '溢出状态格应显示当前页码');
|
||||
rotatingBoard.handleMessage({
|
||||
type: 'task_board_event',
|
||||
task: {
|
||||
sessionId: 'wall-running',
|
||||
title: '刚刚更新的任务',
|
||||
isRunning: true,
|
||||
runtimeState: '运行中',
|
||||
updatedAt: '2026-08-11T08:00:00.000Z',
|
||||
statusId: 'doing-z',
|
||||
trackingEnabled: true,
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
const updatedWallCard = byClass(doingWallColumn, 'ccweb-task-board__wall-card')
|
||||
.find((card) => card.dataset.sessionId === 'wall-running');
|
||||
assert(updatedWallCard, '任务更新后必须立即切回并显示该任务所在页');
|
||||
assert.strictEqual(byClass(updatedWallCard, 'ccweb-task-board__wall-card-title')[0].textContent, '刚刚更新的任务');
|
||||
assert(
|
||||
updatedWallCard.animations.some((animation) => animation.options.id === 'ccweb-task-board-wall-card-update'),
|
||||
'更新任务应只在所属状态格内触发强调动画',
|
||||
);
|
||||
await wait(80);
|
||||
assert(byClass(doingWallColumn, 'ccweb-task-board__wall-card').some((card) => card.dataset.sessionId === 'wall-running'), '更新任务所在页应获得更长停留时间');
|
||||
await wait(85);
|
||||
assert.deepStrictEqual(
|
||||
byClass(doingWallColumn, 'ccweb-task-board__wall-card').map((card) => card.dataset.sessionId),
|
||||
['wall-old'],
|
||||
'延长停留结束后应恢复该状态格的列内轮换',
|
||||
);
|
||||
rotatingBoard.destroy();
|
||||
|
||||
const reducedEnvironment = loadTaskBoard(source, { reducedMotion: true });
|
||||
const reducedRoot = reducedEnvironment.document.createElement('div');
|
||||
reducedEnvironment.document.body.appendChild(reducedRoot);
|
||||
const reducedBoard = reducedEnvironment.api.mount(reducedRoot, {
|
||||
document: reducedEnvironment.document,
|
||||
autoLoad: false,
|
||||
initialData: { statusDefinitions: definitions, tasks: [tasks[0]] },
|
||||
wallRotationMs: 30,
|
||||
wallCardsPerPage: 1,
|
||||
initialData: { statusDefinitions: definitions, tasks: [tasks[0], tasks[1], tasks[2]] },
|
||||
});
|
||||
const reducedCard = byClass(reducedRoot, 'ccweb-task-board__card')[0];
|
||||
const reducedWallColumn = byClass(reducedRoot, 'ccweb-task-board__wall-column')
|
||||
.find((column) => column.dataset.statusId === 'queue-x');
|
||||
const reducedWallSessionId = byClass(reducedWallColumn, 'ccweb-task-board__wall-card')[0]?.dataset.sessionId;
|
||||
await wait(45);
|
||||
assert.strictEqual(
|
||||
byClass(reducedWallColumn, 'ccweb-task-board__wall-card')[0]?.dataset.sessionId,
|
||||
reducedWallSessionId,
|
||||
'减少动态偏好下应停止状态格自动换页',
|
||||
);
|
||||
const reducedCard = byClass(reducedRoot, 'ccweb-task-board__card')
|
||||
.find((card) => card.dataset.sessionId === 'session-one');
|
||||
reducedBoard.handleMessage({
|
||||
type: 'task_board_event',
|
||||
task: { sessionId: 'session-one', statusId: 'review-custom', trackingEnabled: true, version: 6 },
|
||||
});
|
||||
const reducedMovedCard = byClass(reducedRoot, 'ccweb-task-board__card')[0];
|
||||
const reducedMovedCard = byClass(reducedRoot, 'ccweb-task-board__card')
|
||||
.find((card) => card.dataset.sessionId === 'session-one');
|
||||
assert.strictEqual(reducedMovedCard, reducedCard, '减少动态效果时仍应复用并移动原卡片节点');
|
||||
assert.strictEqual(reducedMovedCard.parentNode.parentNode.dataset.statusId, 'review-custom');
|
||||
assert.strictEqual(reducedMovedCard.animations.length, 0, '减少动态效果偏好下不得启动脚本动画');
|
||||
@@ -918,8 +1173,39 @@ async function main() {
|
||||
initialData: { statusDefinitions: definitions.slice(0, 1), tasks: [] },
|
||||
});
|
||||
assert.strictEqual(secondBoard.getState().statusDefinitions.length, 1, '不同挂载实例应保持状态隔离');
|
||||
assert.strictEqual(
|
||||
byClass(secondRoot, 'ccweb-task-board__lanes')[0].style.getPropertyValue('--task-board-column-count'),
|
||||
'1',
|
||||
'不同挂载实例应独立维护响应式列数',
|
||||
);
|
||||
secondBoard.destroy();
|
||||
|
||||
const crowdedRoot = document.createElement('div');
|
||||
document.body.appendChild(crowdedRoot);
|
||||
const crowdedDefinitions = Array.from({ length: 7 }, (_, index) => ({
|
||||
id: `crowded-${index}`,
|
||||
label: `状态 ${index + 1}`,
|
||||
prompt: `归入状态 ${index + 1} 的任务。`,
|
||||
order: index,
|
||||
enabled: true,
|
||||
}));
|
||||
const crowdedBoard = api.mount(crowdedRoot, {
|
||||
document,
|
||||
autoLoad: false,
|
||||
initialData: { statusDefinitions: crowdedDefinitions, tasks: [] },
|
||||
});
|
||||
assert.strictEqual(
|
||||
byClass(crowdedRoot, 'ccweb-task-board__lanes')[0].dataset.columnLayout,
|
||||
'scroll',
|
||||
'超过六列时应降级为可读的横向滚动布局',
|
||||
);
|
||||
assert.strictEqual(
|
||||
byClass(crowdedRoot, 'ccweb-task-board__lanes')[0].style.getPropertyValue('--task-board-grid-columns'),
|
||||
'',
|
||||
'七列降级布局仍不得拆成上下两排',
|
||||
);
|
||||
crowdedBoard.destroy();
|
||||
|
||||
const openedBeforeDestroy = opened.length;
|
||||
board.destroy();
|
||||
emit(openButton, 'click');
|
||||
|
||||
Reference in New Issue
Block a user