'use strict'; const assert = require('node:assert'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const REPO_DIR = path.resolve(__dirname, '..'); const SCRIPT_PATH = path.join(REPO_DIR, 'public', 'task-board.js'); const STYLE_PATH = path.join(REPO_DIR, 'public', 'task-board.css'); class FakeStyle { constructor() { this.values = new Map(); } setProperty(name, value) { this.values.set(String(name), String(value)); } getPropertyValue(name) { return this.values.get(String(name)) || ''; } } class FakeClassList { constructor(element) { this.element = element; } values() { return String(this.element.className || '').split(/\s+/).filter(Boolean); } contains(name) { return this.values().includes(name); } add(...names) { const values = new Set(this.values()); names.forEach((name) => values.add(name)); this.element.className = Array.from(values).join(' '); } remove(...names) { const removed = new Set(names); this.element.className = this.values().filter((name) => !removed.has(name)).join(' '); } toggle(name, force) { const present = this.contains(name); const enabled = force === undefined ? !present : !!force; if (enabled) this.add(name); else this.remove(name); return enabled; } } function dataPropertyName(attributeName) { return attributeName .slice(5) .split('-') .map((part, index) => (index ? `${part.slice(0, 1).toUpperCase()}${part.slice(1)}` : part)) .join(''); } function selectorAttributeValue(element, name) { if (name === 'class') return element.className; if (name === 'id') return element.id; if (name === 'name') return element.name; if (name === 'disabled') return element.disabled ? '' : null; if (name === 'tabindex') return element.tabIndex === undefined ? null : String(element.tabIndex); if (name.startsWith('data-')) { const key = dataPropertyName(name); return Object.prototype.hasOwnProperty.call(element.dataset, key) ? String(element.dataset[key]) : null; } return element.getAttribute(name); } function matchesSelector(element, rawSelector) { let selector = String(rawSelector || '').trim(); if (!selector) return false; const notAttributes = []; selector = selector.replace(/:not\(\[([^\]=]+)(?:="([^"]*)")?\]\)/g, (_, name, value) => { notAttributes.push({ name, value }); return ''; }); for (const condition of notAttributes) { const actual = selectorAttributeValue(element, condition.name); if (actual !== null && (condition.value === undefined || actual === condition.value)) return false; } const tagMatch = selector.match(/^[A-Za-z][A-Za-z0-9-]*/); if (tagMatch && element.tagName !== tagMatch[0].toUpperCase()) return false; const idMatches = Array.from(selector.matchAll(/#([A-Za-z0-9_-]+)/g)); if (idMatches.some((match) => element.id !== match[1])) return false; const classMatches = Array.from(selector.matchAll(/\.([A-Za-z0-9_-]+)/g)); if (classMatches.some((match) => !element.classList.contains(match[1]))) return false; const attributeMatches = Array.from(selector.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g)); for (const match of attributeMatches) { const actual = selectorAttributeValue(element, match[1]); if (actual === null) return false; if (match[2] !== undefined && actual !== match[2]) return false; } return true; } class FakeElement { constructor(tagName, ownerDocument) { this.nodeType = 1; this.tagName = String(tagName).toUpperCase(); this.ownerDocument = ownerDocument; this.parentNode = null; this.children = []; this.dataset = {}; this.style = new FakeStyle(); this.attributes = new Map(); this.listeners = new Map(); this.className = ''; this.id = ''; this.name = ''; this.value = ''; this.type = ''; this.checked = false; this.selected = false; this.disabled = false; this.hidden = false; this.required = false; this.tabIndex = undefined; this._textContent = ''; this.animations = []; this.classList = new FakeClassList(this); } get textContent() { return `${this._textContent}${this.children.map((child) => child.textContent).join('')}`; } set textContent(value) { this._textContent = value === null || value === undefined ? '' : String(value); this.children.forEach((child) => { child.parentNode = null; }); this.children = []; } get firstElementChild() { return this.children[0] || null; } get parentElement() { return this.parentNode instanceof FakeElement ? this.parentNode : null; } appendChild(child) { assert(child instanceof FakeElement, 'MiniDOM 只接受元素节点'); if (child.parentNode) child.remove(); child.parentNode = this; this.children.push(child); return child; } append(...children) { children.forEach((child) => this.appendChild(child)); } prepend(...children) { const normalized = children.filter(Boolean); normalized.forEach((child) => { if (child.parentNode) child.remove(); child.parentNode = this; }); this.children.unshift(...normalized); } replaceChildren(...children) { this.children.forEach((child) => { child.parentNode = null; }); this.children = []; this._textContent = ''; children.forEach((child) => this.appendChild(child)); } remove() { if (!this.parentNode || !Array.isArray(this.parentNode.children)) return; const index = this.parentNode.children.indexOf(this); if (index >= 0) this.parentNode.children.splice(index, 1); this.parentNode = null; } setAttribute(name, value) { const normalizedName = String(name); const normalizedValue = String(value); this.attributes.set(normalizedName, normalizedValue); if (normalizedName === 'class') this.className = normalizedValue; if (normalizedName === 'id') this.id = normalizedValue; if (normalizedName === 'tabindex') this.tabIndex = Number(normalizedValue); if (normalizedName.startsWith('data-')) this.dataset[dataPropertyName(normalizedName)] = normalizedValue; } getAttribute(name) { const normalizedName = String(name); if (normalizedName === 'class') return this.className || null; if (normalizedName === 'id') return this.id || null; if (normalizedName === 'tabindex' && this.tabIndex !== undefined) return String(this.tabIndex); if (normalizedName.startsWith('data-')) { const key = dataPropertyName(normalizedName); return Object.prototype.hasOwnProperty.call(this.dataset, key) ? String(this.dataset[key]) : null; } return this.attributes.has(normalizedName) ? this.attributes.get(normalizedName) : null; } hasAttribute(name) { return this.getAttribute(name) !== null; } removeAttribute(name) { const normalizedName = String(name); this.attributes.delete(normalizedName); if (normalizedName === 'class') this.className = ''; if (normalizedName === 'id') this.id = ''; if (normalizedName === 'tabindex') this.tabIndex = undefined; if (normalizedName.startsWith('data-')) delete this.dataset[dataPropertyName(normalizedName)]; } addEventListener(type, listener) { const listeners = this.listeners.get(type) || []; listeners.push(listener); this.listeners.set(type, listeners); } removeEventListener(type, listener) { const listeners = this.listeners.get(type) || []; this.listeners.set(type, listeners.filter((candidate) => candidate !== listener)); } dispatchEvent(event) { const normalized = event || {}; normalized.type = String(normalized.type || ''); if (!normalized.target) normalized.target = this; normalized.currentTarget = this; normalized.defaultPrevented = !!normalized.defaultPrevented; normalized.preventDefault ||= function preventDefault() { this.defaultPrevented = true; }; normalized.stopPropagation ||= function stopPropagation() { this.propagationStopped = true; }; for (const listener of (this.listeners.get(normalized.type) || []).slice()) listener.call(this, normalized); if (normalized.bubbles && !normalized.propagationStopped && this.parentNode?.dispatchEvent) { this.parentNode.dispatchEvent(normalized); } return !normalized.defaultPrevented; } matches(selector) { return String(selector).split(',').some((part) => matchesSelector(this, part)); } closest(selector) { let current = this; while (current instanceof FakeElement) { if (current.matches(selector)) return current; current = current.parentNode; } return null; } querySelectorAll(selector) { const selectors = String(selector).split(',').map((part) => part.trim()).filter(Boolean); const matches = []; const visit = (node) => { for (const child of node.children) { if (selectors.some((part) => matchesSelector(child, part))) matches.push(child); visit(child); } }; visit(this); return matches; } querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } contains(candidate) { if (candidate === this) return true; return this.children.some((child) => child.contains(candidate)); } focus() { this.ownerDocument.activeElement = this; } 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')) || []; const columnIndex = Math.max(0, columns.indexOf(column)); const cards = this.parentNode?.children?.filter((node) => node.classList.contains('ccweb-task-board__card')) || []; const cardIndex = Math.max(0, cards.indexOf(this)); const left = 16 + (columnIndex * 352); const top = 72 + (cardIndex * 176); const width = 320; const height = 160; return { x: left, y: top, left, top, right: left + width, bottom: top + height, width, height }; } animate(keyframes, options = {}) { let timer = null; const animation = { keyframes, options, onfinish: null, oncancel: null, finished: false, canceled: false, finish() { if (this.finished || this.canceled) return; this.finished = true; if (timer !== null) clearTimeout(timer); timer = null; if (typeof this.onfinish === 'function') this.onfinish(); }, cancel() { if (this.finished || this.canceled) return; this.canceled = true; if (timer !== null) clearTimeout(timer); timer = null; if (typeof this.oncancel === 'function') this.oncancel(); }, }; this.animations.push(animation); timer = setTimeout(() => animation.finish(), 0); return animation; } } class FakeDocument { constructor() { this.listeners = new Map(); this.activeElement = null; this.documentElement = new FakeElement('html', this); this.body = new FakeElement('body', this); this.documentElement.appendChild(this.body); } createElement(tagName) { return new FakeElement(tagName, this); } querySelector(selector) { if (this.documentElement.matches(selector)) return this.documentElement; return this.documentElement.querySelector(selector); } addEventListener(type, listener) { const listeners = this.listeners.get(type) || []; listeners.push(listener); this.listeners.set(type, listeners); } removeEventListener(type, listener) { const listeners = this.listeners.get(type) || []; this.listeners.set(type, listeners.filter((candidate) => candidate !== listener)); } dispatchEvent(event) { const normalized = event || {}; normalized.preventDefault ||= function preventDefault() { this.defaultPrevented = true; }; for (const listener of (this.listeners.get(normalized.type) || []).slice()) listener.call(this, normalized); } } function walk(root) { const nodes = []; const visit = (node) => { nodes.push(node); node.children.forEach(visit); }; visit(root); return nodes; } function byClass(root, className) { return walk(root).filter((node) => node.classList?.contains(className)); } function byAction(root, action) { return walk(root).filter((node) => node.dataset?.action === action); } function emit(element, type, extra = {}) { element.dispatchEvent({ type, bubbles: true, ...extra }); } function loadTaskBoard(source, options = {}) { const document = new FakeDocument(); let uuidSequence = 0; const window = { document, setTimeout, clearTimeout, Date, Intl, Promise, matchMedia(query) { return { media: String(query), matches: options.reducedMotion === true && String(query).includes('prefers-reduced-motion'), }; }, crypto: { randomUUID: () => `uuid-${++uuidSequence}` }, CSS: { supports(property, value) { if (property !== 'color') return true; return /^#[0-9a-f]{3,8}$/i.test(String(value)) || /^[a-z]+$/i.test(String(value)); }, }, }; window.window = window; const context = vm.createContext({ window, document, setTimeout, clearTimeout, Date, Intl, Promise, console, }); vm.runInContext(source, context, { filename: SCRIPT_PATH }); return { api: window.CcwebTaskBoard, document }; } function cloneMessage(message) { return JSON.parse(JSON.stringify(message)); } async function flush() { await Promise.resolve(); 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'); // 静态契约:独立全局 API、协议前缀和样式能力必须可由集成层直接识别。 assert(source.includes('global.CcwebTaskBoard = api'), '应导出 window.CcwebTaskBoard'); assert(!/(?:window|global)\.(?:ws|sessions|currentSessionId)\b/.test(source), '模块不得读取 app.js 私有全局状态'); assert(!source.includes('.innerHTML'), '服务端文本必须通过 textContent 渲染'); for (const type of [ 'task_board_query', 'task_tracking_set', 'task_status_set', 'task_status_definition_upsert', 'task_status_definition_remove', 'task_archive_set', ]) { assert(source.includes(`'${type}'`), `缺少协议类型 ${type}`); } for (const statusId of ['unassigned', 'in_progress', 'waiting_user', 'blocked', 'completed']) { assert(!source.includes(`'${statusId}'`), `前端不应硬编码系统状态 ${statusId}`); } 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( css.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::before") && css.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::after"), '暗金荒野运行卡片应保留左侧竖条和活动轨迹', ); assert( !/html\[data-theme='wasteland'\] \.ccweb-task-board__card\[data-runtime-state='running'\]\s*\{/.test(css) && !css.includes('@keyframes ccweb-task-board-wasteland-runtime-card'), '暗金荒野不得再给整张运行卡片添加背景、边框或呼吸泛光', ); assert( css.includes('@keyframes ccweb-task-board-wasteland-runtime-tracer'), '暗金荒野运行卡片应提供独立的边缘活动轨迹', ); assert( /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.ccweb-task-board__card\[data-runtime-state='running'\]::after\s*\{[^}]*animation:\s*none;/.test(css), '减少动态效果时左侧活动点必须停止移动', ); assert(css.includes('.ccweb-task-tracking'), 'CSS 应覆盖可复用任务跟踪开关'); assert(!source.includes('refs.board.replaceChildren'), '数据更新不得清空并重建整个看板'); assert(source.includes('getBoundingClientRect'), '卡片移动应记录更新前后的几何位置'); assert(source.includes('card.animate(keyframes'), '卡片动效应使用原生 Web Animations API'); assert(source.includes('ccweb-task-board-card-move'), '应提供跨列 FLIP 位移动画'); assert(source.includes('ccweb-task-board-card-enter'), '应提供新增卡片展开动画'); assert(source.includes('ccweb-task-board-card-leave'), '应提供移除卡片收缩动画'); assert(source.includes("matchMedia('(prefers-reduced-motion: reduce)')"), '脚本动效应尊重减少动态效果偏好'); assert(css.includes('.ccweb-task-board__card[data-motion="moving"]'), 'CSS 应为移动中的卡片提供独立层级'); assert(css.includes('.ccweb-task-board__card[data-motion="leaving"]'), 'CSS 应定义卡片离场状态'); assert(!source.includes('baseStatus'), '生产前端不得保留基础状态映射语义'); assert(!source.includes('reportingStatus'), '生产前端不得保留轮次上报审计字段'); assert(!source.includes('本轮未上报'), '生产前端不得渲染轮次上报缺失文案'); assert(!source.includes('facets'), '生产前端不得保留 Agent facet 状态'); assert(!source.includes('agentSelect'), '生产前端不得创建 Agent 筛选控件'); assert(!source.includes('filters.agent'), '生产前端不得保留 Agent 筛选字段'); assert(!source.includes('按 Agent 筛选'), '生产前端不得出现 Agent 筛选文案'); assert(!source.includes('Agent ·'), '任务卡不得渲染 Agent 运行器署名'); assert(!source.includes('任务:'), '任务卡不得在状态选择器之外重复显示业务状态'); assert(!css.includes('.ccweb-task-board__business-state'), 'CSS 不得保留重复业务状态胶囊样式'); assert(!css.includes('.ccweb-task-board__state-dot'), 'CSS 不得保留重复业务状态圆点样式'); assert(!/\bprogress\b/i.test(source), '生产看板脚本不得保留百分比进度语义'); assert(!css.includes('ccweb-task-board__progress'), '生产看板样式不得保留进度条组件'); assert(!/\bdescription\b/.test(source), '状态定义不得再用 description 代替分类提示词'); assert(source.includes("promptControl.name = 'prompt'"), '状态定义请求应读取 prompt 字段'); assert(source.includes("createField('分类提示词'"), '状态管理表单必须明确标注分类提示词'); assert(css.includes('.ccweb-task-board__prompt'), 'CSS 应提供分类提示词编辑区样式'); assert(!source.includes("'create-task'"), '看板不得提供伪“新建任务”动作'); assert(!source.includes('createSession'), '前端看板不得把新建会话包装成新建任务'); assert(!source.includes("dataset.action = 'refresh'"), '看板不得渲染刷新按钮'); assert(!source.includes('开启后,该会话会进入独立任务看板并接受状态跟踪。'), '跟踪控件不得重复长说明'); assert(!/\bpriority\b/i.test(source), '生产前端不得保留伪会话优先级字段'); assert(!source.includes('优先级'), '生产前端不得出现优先级文案'); assert(!css.includes('ccweb-task-board__priority'), 'CSS 不得保留优先级标签样式'); assert(!css.includes('--tb-'), '看板不得维护独立主题变量体系'); assert(!css.includes('color-scheme: dark'), '看板不得强制深色模式'); assert(!/(?:#[0-9a-f]{3,8}|rgba?\()/i.test(css), '看板常规颜色不得硬编码'); for (const themeVariable of [ '--bg-primary', '--bg-secondary', '--bg-tertiary', '--surface-strong', '--text-primary', '--text-secondary', '--text-muted', '--border-color', '--accent', '--accent-hover', '--accent-light', '--danger', '--success', '--accent-ink', ]) { assert(css.includes(`var(${themeVariable}`), `CSS 应继承主题变量 ${themeVariable}`); } assert(/\.ccweb-task-tracking\s*\{[^}]*background:\s*var\(--bg-tertiary\);/.test(css), '跟踪控件应复用控制栏按钮表面'); assert(/\.ccweb-task-tracking\s*\{[\s\S]*?display:\s*inline-flex;/.test(css), '跟踪根节点应为内联 flex 控件'); assert(/\.ccweb-task-tracking\s*\{[\s\S]*?width:\s*auto;/.test(css), '跟踪根节点不得要求全宽布局'); assert(/\.ccweb-task-tracking\s*\{[^}]*border:\s*1px solid var\(--border-color\);/.test(css), '跟踪控件应复用控制栏按钮边框'); assert(/\.task-tracking-control\s*\{[\s\S]*?display:\s*inline-flex;[\s\S]*?width:\s*auto;/.test(css), '挂载容器不得占据独立整行'); assert(css.includes('.chat-controls > .task-tracking-control'), '集成挂载点应明确作为会话控制栏尾项'); assert(css.includes('.ccweb-task-tracking__dot'), '跟踪按钮应包含二态圆点'); assert(/\.ccweb-task-tracking__status\s*\{[^}]*position:\s*absolute;[^}]*width:\s*1px;/.test(css), '状态文案应仅供无障碍读取而不占视觉空间'); assert(!source.includes('ccweb-task-tracking__switch'), '跟踪控件不得再渲染开关容器'); assert(!source.includes('ccweb-task-tracking__track'), '跟踪控件不得再渲染开关轨道'); assert(!source.includes('ccweb-task-tracking__thumb'), '跟踪控件不得再渲染开关滑块'); assert(css.includes('@media (max-width: 820px)'), 'CSS 应提供窄屏工具条布局'); const { api, document } = loadTaskBoard(source); assert(api && Object.isFrozen(api), 'CcwebTaskBoard API 应存在且不可变'); for (const method of ['mount', 'renderTrackingControl', 'mountTrackingToggle', 'createTrackingControl']) { assert.strictEqual(typeof api[method], 'function', `缺少公共方法 ${method}`); } const definitions = [ { id: 'doing-z', label: '执行区', prompt: '任务正在实际推进时归入此列。', color: '#268cff', order: 10, system: true, enabled: true }, { id: 'review-custom', label: '人工复核', prompt: '任务需要人工检查结果时归入此列。', color: '#f2aa3b', order: 20, system: false, enabled: true }, { id: 'queue-x', label: '入口', prompt: '任务尚未开始处理时归入此列。', color: '#91a6bb', order: 30, system: true, enabled: true }, { id: 'disabled-hold', label: '历史暂停', prompt: '仅保留历史任务,不再接收新任务。', color: 'url(javascript:bad)', order: 40, system: false, enabled: false }, ]; const tasks = [ { sessionId: 'session-one', title: '定位协议边界', agent: 'Agent Alpha', isRunning: true, project: 'cc-web', taskTracking: { enabled: true, statusId: 'queue-x', summary: '验证动态列与运行态分离', progress: 42, reportingStatus: 'reported', version: 5, statusUpdatedAt: '2026-08-11T04:00:00.000Z', }, }, { sessionId: 'session-two', title: '复核自定义状态', agent: 'Agent Beta', runtimeState: '等待进程', taskTracking: { enabled: true, statusId: 'review-custom', version: 2 }, }, { sessionId: 'session-three', title: '停用状态仍需可见', agent: 'Agent Alpha', taskTracking: { enabled: true, statusId: 'disabled-hold', version: 1 }, }, { sessionId: 'session-archived', title: '已归档但未删除', agent: 'Agent Beta', taskTracking: { enabled: true, statusId: 'queue-x', archivedAt: '2026-08-11T05:00:00.000Z', version: 4, }, }, ]; const sent = []; const opened = []; let requestSequence = 0; const root = document.createElement('div'); document.body.appendChild(root); const board = api.mount(root, { document, autoLoad: false, initialData: { statusDefinitions: definitions, definitionsVersion: 7, tasks, }, send(message) { sent.push(cloneMessage(message)); }, openSession(sessionId) { opened.push(sessionId); }, createRequestId(prefix) { requestSequence += 1; return `${prefix}-${requestSequence}`; }, }); assert.strictEqual(byClass(root, 'ccweb-task-board__column').length, 4, '列数必须由状态定义动态决定'); assert.deepStrictEqual( byClass(root, 'ccweb-task-board__column').map((column) => column.dataset.statusId), ['doing-z', 'review-custom', 'queue-x', 'disabled-hold'], '状态列应按服务端 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]), ); assert.strictEqual(byClass(root, 'ccweb-task-board__card').length, 3, '默认视图只显示未归档任务'); const toolbarControls = byClass(root, 'ccweb-task-board__controls')[0]; assert.strictEqual(walk(toolbarControls).filter((node) => node.tagName === 'BUTTON').length, 2, '工具条只能保留归档和状态管理两个按钮'); assert.strictEqual(byClass(toolbarControls, 'ccweb-task-board__input').length, 1, '工具条应保留搜索'); assert.strictEqual(byClass(toolbarControls, 'ccweb-task-board__select').length, 0, '工具条不得保留 Agent 筛选'); assert.strictEqual(toolbarControls.textContent.includes('Agent'), false, '工具条 DOM 不得出现 Agent 筛选'); assert.strictEqual(toolbarControls.textContent.includes('列管理'), true, '工具条应使用明确的列管理文案'); assert.strictEqual(toolbarControls.textContent.includes('优先级'), false, '工具条 DOM 不得出现优先级筛选'); assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState(), 'facets'), false, '公开状态不得暴露 Agent facets'); assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState().filters, 'agent'), false, '筛选状态不得暴露 Agent 字段'); assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState().filters, 'priority'), false, '筛选状态不得暴露 priority'); assert.strictEqual(byAction(root, 'create-task').length, 0, '工具条不得出现新建任务按钮'); assert.strictEqual(byAction(root, 'refresh').length, 0, '工具条不得出现刷新按钮'); assert.strictEqual(byAction(root, 'toggle-archive').length, 1, '工具条应保留归档切换'); assert.strictEqual(byAction(root, 'manage-statuses').length, 1, '工具条应保留状态管理'); const firstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one'); assert(firstCard, '应渲染首个任务卡'); const firstTaskState = board.getState().tasks.find((task) => task.sessionId === 'session-one'); assert.strictEqual(Object.prototype.hasOwnProperty.call(firstTaskState, 'progress'), false, '历史百分比字段不得进入前端公开任务状态'); assert.strictEqual(byClass(firstCard, 'ccweb-task-board__progress').length, 0, '任务卡不得渲染进度条'); 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, '', '移动控件不得重复显示当前所在列'); 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 契约'); const idleCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-three'); assert.strictEqual(idleCard.dataset.runtimeState, 'idle', '未运行卡片应保持静态 idle 契约'); assert.strictEqual(firstCard.textContent.includes('Agent Alpha'), false, '任务卡不得显示 Agent 运行器名称'); assert(firstCard.textContent.includes(''), '标题应作为纯文本保留'); assert.strictEqual(walk(firstCard).some((node) => node.tagName === 'IMG'), false, '恶意标题不得解析成 DOM'); assert.strictEqual(firstCard.parentNode.parentNode.dataset.statusId, 'queue-x', '运行态不得改变业务状态列'); const unsafeColumn = byClass(root, 'ccweb-task-board__column').find((column) => column.dataset.statusId === 'disabled-hold'); assert.strictEqual( unsafeColumn.style.getPropertyValue('--task-board-status-color'), '', '非法服务端颜色不应覆盖主题回退色', ); board.setFilters({ agent: 'Agent Alpha', archived: false }); assert.strictEqual(byClass(root, 'ccweb-task-board__card').length, 3, '未知的 Agent 筛选输入应被忽略'); assert.strictEqual(Object.prototype.hasOwnProperty.call(board.getState().filters, 'agent'), false, 'setFilters 不得重新引入 Agent 字段'); 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, '', '归档任务的移动控件也不得重复当前状态'); board.setFilters({ archived: false, search: '' }); const activeFirstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one'); const openButton = byAction(activeFirstCard, 'open-session')[0]; emit(openButton, 'click'); assert.deepStrictEqual(opened, ['session-one'], '卡片主操作应准确打开原会话一次'); const statusSelect = byAction(activeFirstCard, 'set-status')[0]; statusSelect.value = 'review-custom'; emit(statusSelect, 'change'); const statusRequest = sent.at(-1); assert.strictEqual(statusRequest.type, 'task_status_set'); assert.strictEqual(statusRequest.sessionId, 'session-one'); assert.strictEqual(statusRequest.statusId, 'review-custom'); 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'); const archiveRequest = sent.at(-1); assert.strictEqual(archiveRequest.type, 'task_archive_set'); assert.strictEqual(archiveRequest.archived, true); assert.strictEqual(archiveRequest.expectedVersion, 5); board.openStatusManager(); assert.strictEqual(board.getState().statusManagerOpen, true, '应能打开状态管理面板'); assert.strictEqual(byClass(root, 'ccweb-task-board__status-layer')[0].hidden, false); assert(byClass(root, 'ccweb-task-board__toolbar')[0].hasAttribute('inert'), '模态面板打开时背景应 inert'); const definitionForms = byClass(root, 'ccweb-task-board__definition'); assert.strictEqual(definitionForms.length, definitions.length + 1, '面板应渲染全部状态列及新增列表单'); const addDefinitionForm = definitionForms.find((form) => form.dataset.definitionId === ''); const promptControl = addDefinitionForm.querySelector('[name="prompt"]'); assert(promptControl, '新增列表单必须包含 prompt 控件'); assert.strictEqual(promptControl.tagName, 'TEXTAREA'); assert(addDefinitionForm.textContent.includes('分类提示词'), 'prompt 控件必须使用“分类提示词”标签'); assert.strictEqual(addDefinitionForm.querySelector('[name="baseStatus"]'), null, '新增列不得选择基础状态'); assert.strictEqual(addDefinitionForm.querySelector('[name="description"]'), null, '新增列不得用说明代替分类提示词'); const reviewForm = definitionForms.find((form) => form.dataset.definitionId === 'review-custom'); assert.strictEqual(reviewForm.querySelector('[name="prompt"]').value, definitions[1].prompt, '已有列应直接编辑服务端 prompt'); addDefinitionForm.querySelector('[name="id"]').value = 'ready-release'; addDefinitionForm.querySelector('[name="label"]').value = '等待发布'; addDefinitionForm.querySelector('[name="prompt"]').value = '任务实现完成,但仍需等待发布窗口时归入此列。'; addDefinitionForm.querySelector('[name="order"]').value = '45'; emit(addDefinitionForm, 'submit'); const definitionRequest = sent.at(-1); assert.strictEqual(definitionRequest.type, 'task_status_definition_upsert'); assert.strictEqual(definitionRequest.definition.id, 'ready-release'); assert.strictEqual(definitionRequest.definition.prompt, '任务实现完成,但仍需等待发布窗口时归入此列。'); assert.strictEqual(Object.prototype.hasOwnProperty.call(definitionRequest.definition, 'baseStatus'), false, 'definition 不得发送 baseStatus'); assert.strictEqual(Object.prototype.hasOwnProperty.call(definitionRequest.definition, 'description'), false, 'definition 不得发送 description'); assert.strictEqual(definitionRequest.expectedVersion, 7); board.closeStatusManager(); 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', task: { sessionId: 'session-one', statusId: 'review-custom', trackingEnabled: true, version: 4 }, }); assert.strictEqual( board.getState().tasks.find((task) => task.sessionId === 'session-one').statusId, 'queue-x', '低版本广播不得覆盖新任务状态', ); assert.strictEqual( byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one'), activeFirstCard, '无效广播也不得替换原任务卡节点', ); assert.strictEqual(document.activeElement, statusSelect, '无可见变化的数据事件不得打断卡片内键盘焦点'); board.handleMessage({ type: 'task_board_event', task: { sessionId: 'session-one', statusId: 'review-custom', trackingEnabled: true, version: 6 }, }); assert.strictEqual( board.getState().tasks.find((task) => task.sessionId === 'session-one').statusId, 'review-custom', '高版本广播应更新任务状态', ); const movedFirstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one'); 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) { const currentColumn = byClass(root, 'ccweb-task-board__column').find((column) => column.dataset.statusId === statusId); assert.strictEqual(currentColumn, originalColumn, `数据事件不得替换状态列 ${statusId}`); } const moveAnimation = movedFirstCard.animations .slice(moveAnimationCount) .find((animation) => animation.options.id === 'ccweb-task-board-card-move'); assert(moveAnimation, '跨列更新应触发 FLIP 位移动画'); assert.notStrictEqual( moveAnimation.keyframes[0].transform, moveAnimation.keyframes.at(-1).transform, 'FLIP 动画起点应包含真实位置差值', ); board.handleMessage({ type: 'task_board_event', task: { sessionId: 'session-motion-new', title: '动效契约任务', statusId: 'doing-z', trackingEnabled: true, version: 1, }, }); const enteringCard = byClass(root, 'ccweb-task-board__card') .find((card) => card.dataset.sessionId === 'session-motion-new'); assert(enteringCard, '新增任务应立即进入目标列'); assert.strictEqual(enteringCard.dataset.motion, 'entering', '新增任务应处于展开入场状态'); assert(enteringCard.animations.some((animation) => animation.options.id === 'ccweb-task-board-card-enter'), '新增任务应触发展开动画'); board.handleMessage({ type: 'task_board_event', removedSessionId: 'session-motion-new' }); assert.strictEqual(enteringCard.dataset.motion, 'leaving', '移除任务应先进入收缩离场状态'); assert(enteringCard.animations.some((animation) => animation.options.id === 'ccweb-task-board-card-leave'), '移除任务应触发收缩动画'); await flush(); assert.strictEqual( byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-motion-new'), false, '离场动画完成后才应移除任务节点', ); board.handleMessage({ type: 'task_board_event', task: { sessionId: 'session-one', taskTracking: { enabled: true, archivedAt: '2026-08-11T06:00:00.000Z', archivedBy: 'ccweb-ui', version: 7, }, }, }); board.setFilters({ archived: true }); assert(byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-one'), '归档事件应把目标卡片放入归档视图'); board.handleMessage({ type: 'task_board_event', task: { sessionId: 'session-one', taskTracking: { enabled: true, archivedAt: null, archivedBy: null, version: 8, }, }, }); const restoredTask = board.getState().tasks.find((task) => task.sessionId === 'session-one'); assert.strictEqual(restoredTask.archivedAt, null, '显式 archivedAt:null 必须清除旧归档时间'); assert.strictEqual(restoredTask.archivedBy, '', '恢复归档必须清除旧归档操作者'); const restoringCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one'); assert.strictEqual(restoringCard?.dataset.motion, 'leaving', '恢复后的卡片应先在归档视图中收缩'); await flush(); assert.strictEqual(byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-one'), false, '恢复后的卡片应在离场动画完成后离开归档视图'); board.setFilters({ archived: false }); assert(byClass(root, 'ccweb-task-board__card').some((card) => card.dataset.sessionId === 'session-one'), '恢复后的卡片应回到活动视图'); board.handleMessage({ type: 'task_board_event', definitionsVersion: 6, statusDefinitions: definitions.map((definition) => ({ ...definition, label: '过期名称' })), }); assert.strictEqual(board.getState().statusDefinitions[0].label, '执行区', '旧状态集合版本不得覆盖新定义'); board.refresh({ query: '协议', archived: false }); const oldQuery = sent.at(-1); board.refresh({ query: '最新', archived: false }); const latestQuery = sent.at(-1); assert.strictEqual(latestQuery.type, 'task_board_query'); assert.strictEqual(latestQuery.query, '最新'); assert.strictEqual(latestQuery.filters.search, '最新'); assert.strictEqual(latestQuery.archived, false); assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery, 'agent'), false, '查询顶层不得发送 Agent 字段'); assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery.filters, 'agent'), false, '查询 filters 不得发送 Agent 字段'); assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery, 'priority'), false, '查询顶层不得发送 priority'); assert.strictEqual(Object.prototype.hasOwnProperty.call(latestQuery.filters, 'priority'), false, '查询 filters 不得发送 priority'); assert(latestQuery.requestId && latestQuery.requestId !== oldQuery.requestId, '查询应生成递增 requestId'); board.handleMessage({ type: 'task_board_result', requestId: oldQuery.requestId, tasks: [], statusDefinitions: [], definitionsVersion: 99, }); assert.strictEqual(board.getState().tasks.length, 4, '过期查询结果必须忽略'); board.handleMessage({ type: 'task_board_result', requestId: latestQuery.requestId, tasks: [tasks[0]], statusDefinitions: definitions, definitionsVersion: 8, }); 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, wallRotationMs: 30, wallCardsPerPage: 1, initialData: { statusDefinitions: definitions, tasks: [tasks[0], tasks[1], tasks[2]] }, }); 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') .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, '减少动态效果偏好下不得启动脚本动画'); reducedBoard.destroy(); const secondRoot = document.createElement('div'); document.body.appendChild(secondRoot); const secondBoard = api.mount(secondRoot, { document, autoLoad: false, 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'); assert.strictEqual(opened.length, openedBeforeDestroy, 'destroy 后应卸载看板事件'); assert.strictEqual(root.children.length, 0, 'destroy 后应清理挂载 DOM'); const toggleContainer = document.createElement('div'); document.body.appendChild(toggleContainer); const trackingMessages = []; const trackingErrors = []; let trackingRequest = 0; const toggle = api.mountTrackingToggle(toggleContainer, { document, sessionId: 'toggle-session', enabled: false, version: 3, send(message) { trackingMessages.push(cloneMessage(message)); }, createRequestId(prefix) { trackingRequest += 1; return `${prefix}-${trackingRequest}`; }, onError(error) { trackingErrors.push(error); }, }); assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__description').length, 0, '跟踪控件不得渲染长说明'); assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__dot').length, 1, '跟踪按钮应只渲染一个二态圆点'); assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__switch').length, 0, '跟踪按钮不得显示开关'); assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__track').length, 0, '跟踪按钮不得显示开关轨道'); assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__thumb').length, 0, '跟踪按钮不得显示开关滑块'); assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__title')[0].textContent, '看板'); assert.strictEqual(byClass(toggle.element, 'ccweb-task-tracking__status')[0].textContent, '未加入'); assert.strictEqual(toggle.element.dataset.enabled, 'false', '未加入状态应暴露给圆点和文字配色'); assert.strictEqual(toggle.input.getAttribute('aria-label'), '加入任务看板', '未加入时应提供明确的无障碍动作名称'); toggle.input.checked = true; emit(toggle.input, 'change'); assert.strictEqual(toggle.getState().pending, true, 'WebSocket 写入后应等待对应响应'); assert.strictEqual(toggle.input.disabled, true, '写入 pending 时应禁用开关'); assert.strictEqual(trackingMessages[0].type, 'task_tracking_set'); assert.strictEqual(trackingMessages[0].expectedVersion, 3); assert.strictEqual(toggle.handleMessage({ type: 'task_tracking_result', requestId: 'stale-request', sessionId: 'toggle-session', ok: true, enabled: true, }), false, '不匹配的 requestId 不应结束 pending'); assert.strictEqual(toggle.getState().pending, true); assert.strictEqual(toggle.handleMessage({ type: 'task_tracking_result', requestId: trackingMessages[0].requestId, sessionId: 'toggle-session', ok: true, taskTracking: { enabled: true, version: 4 }, }), true); assert.strictEqual(toggle.getState().enabled, true); assert.strictEqual(toggle.getState().version, 4); assert.strictEqual(toggle.getState().pending, false); assert.strictEqual(toggle.element.dataset.enabled, 'true', '已加入状态应切换圆点和文字配色'); assert.strictEqual(toggle.input.getAttribute('aria-label'), '移出任务看板', '已加入时应提供明确的无障碍动作名称'); toggle.input.checked = false; emit(toggle.input, 'change'); const failedTrackingRequest = trackingMessages.at(-1); toggle.handleMessage({ type: 'task_tracking_result', requestId: failedTrackingRequest.requestId, sessionId: 'toggle-session', ok: false, code: 'task_version_conflict', message: '状态版本已变化', }); assert.strictEqual(toggle.getState().enabled, true, '跟踪开关失败时应回滚'); assert.strictEqual(toggle.input.checked, true); assert.strictEqual(trackingErrors.length, 1, '稳定错误应交给集成层呈现'); const directToggle = api.createTrackingControl({ document, sessionId: 'direct-session', enabled: false, setTracking: async () => ({ ok: true, enabled: true, version: 2 }), }); directToggle.input.checked = true; emit(directToggle.input, 'change'); await flush(); assert.strictEqual(directToggle.getState().enabled, true, 'Promise 适配器应自动提交成功状态'); assert.strictEqual(directToggle.getState().pending, false); directToggle.destroy(); toggle.destroy(); assert.strictEqual(toggleContainer.children.length, 0, '开关 destroy 后应清理 DOM'); console.log('Task board frontend unit checks passed.'); } main().catch((error) => { console.error(error.stack || error); process.exitCode = 1; });