1023 lines
46 KiB
JavaScript
1023 lines
46 KiB
JavaScript
'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 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 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(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: '<img src=x onerror=alert(1)>定位协议边界',
|
|
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];
|
|
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, 'queue-x', '状态选择器应作为业务状态的唯一卡片入口');
|
|
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('<img src=x onerror=alert(1)>'), '标题应作为纯文本保留');
|
|
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, 'queue-x', '归档不应改写任务业务状态');
|
|
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');
|
|
|
|
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;
|
|
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', '卡片应进入服务端指定状态列');
|
|
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 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]] },
|
|
});
|
|
const reducedCard = byClass(reducedRoot, 'ccweb-task-board__card')[0];
|
|
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];
|
|
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, '不同挂载实例应保持状态隔离');
|
|
secondBoard.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;
|
|
});
|