beebcca4de
- 生成任务锚点新增「资源总览」档:资源总览页仍贴画布顶边内缩 0.85rem,资源栏目画布页让开顶部钉住的整宽栏目标题栏(42px + 0.5rem),两档切换走 margin-top 过渡(减动效下关掉)。 - 生成浮层的顶边与可用高度改为按占位卡的真实屏幕位置算(卡下沿到安全底边),不再假设卡片贴在安全带上沿——占位落到下半屏时浮层底边不再越过画布下沿被切掉。 - 生成浮层新增水平收口:按「浮层左右各留 12px」把锚点收进画布可视范围,卡片靠边时浮层不再有一半落到画布之外;宽度口径与面板 CSS 同源(min(560px, 100% - 24px))。 - 新增画布卡片手势的共享判据 resourceCanvasGestureExceededDragThreshold(阈值仍取 RESOURCE_CANVAS_DRAG_THRESHOLD):资源卡与生成占位卡共用同一份「拖动还是点击」判断。 - 生成占位卡修掉拖动收尾误触点击:越过阈值才算拖动、登记一次性点击抑制(consumeDragClick)由宿主消费,「拖动中」样式也从越过阈值那一刻起才亮。 - 生成占位卡的删除按钮起手手势不再被当成「点卡片」:按起手点判定,松手落回卡片时不触发开合生成浮层。 - 图标规范面板的参考选择注释按现行判据改写(只有图集 icon-spritesheet 不接受用户参考),入口矩阵文档同步补「参考图(@ 引用)」口径并修掉「本地 IPC 没有参考图入参」的旧说法。 - 补齐回归用例:手势模型判据、占位卡拖动/点击与阈值、宿主级「拖完不弹浮层且普通点击仍开合」、浮层顶边与水平收口、锚点分档与跨文件标题栏高度守卫、逐条生成入口的 @ 与判据一致、图标规范面板的 @ 与提交带引用。 - 共享记忆 pitfalls.md 记录两条排障口径:卡类手势必须只有一份阈值与点击抑制判据;画布浮层几何只能按真实屏幕位置算、分页锚点必须让开钉死的标题栏。
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
import { describe, expect, test } from 'vitest';
|
||
|
||
import { repoPath } from './repoPath';
|
||
import {
|
||
declaration,
|
||
parseStyleSheet,
|
||
resolveDeclarations,
|
||
type StyleRule,
|
||
} from './styleCascade';
|
||
|
||
/**
|
||
* 侧栏样式的声明级断言。
|
||
*
|
||
* jsdom 不加载这个 CSS 文件,所以这里按仓库既有做法(`styleCascade`)直接解析**真实生效的声明**:
|
||
* 「状态 tone 映射」「等宽数字」「圆角 / 悬停」「过渡」「reduced-motion 关动效」都用声明钉住。
|
||
*/
|
||
const SIDEBAR_CSS_PATH = repoPath(
|
||
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css',
|
||
);
|
||
const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8'));
|
||
|
||
/**
|
||
* 锚点在「资源栏目画布」那一档要让开的、钉死的栏目标题栏(`.game-resource-book-scene-titlebar`)。
|
||
* 它的高度定义在全局样式表里,而让开多少写在侧栏样式里——两个文件,必须一起改,
|
||
* 所以这里读真实声明把关系钉住。
|
||
*/
|
||
const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
|
||
const globalRules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8'));
|
||
// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。
|
||
const globalWidthRules = globalRules.filter(
|
||
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
|
||
);
|
||
|
||
/** 声明里允许的形状就这两种:单个长度,或「几个长度相加」的 `calc`(本文件不解析别的函数)。 */
|
||
function cssLengthToPx(value: string): number {
|
||
const raw = value.trim();
|
||
const calc = /^calc\((.+)\)$/u.exec(raw);
|
||
if (calc) {
|
||
return calc[1]
|
||
.split('+')
|
||
.map((term) => cssLengthToPx(term))
|
||
.reduce((total, term) => total + term, 0);
|
||
}
|
||
const match = raw.match(/^(-?\d+(?:\.\d+)?)(px|rem)$/u);
|
||
if (!match) {
|
||
throw new Error(`不是可换算的长度:${value}`);
|
||
}
|
||
const [, amount, unit] = match;
|
||
// 根字号取浏览器 / WebView 的默认 16px(全局样式表没有改写 `html` 的字号),
|
||
// 这个换算只用在那条「让开多少 ≥ 标题栏多高」的守卫上。
|
||
return Number(amount) * (unit === 'rem' ? 16 : 1);
|
||
}
|
||
|
||
/**
|
||
* `styleCascade` 的求值器只认识宽度媒体查询,遇到 `prefers-reduced-motion` 会主动报错;
|
||
* 那条规则的生效与否单独在下面按声明断言,所以求值时先把它排除掉。
|
||
*/
|
||
const widthRules = rules.filter(
|
||
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
|
||
);
|
||
|
||
function resolved(
|
||
selectors: readonly string[],
|
||
viewportWidth = 1280,
|
||
): Map<string, string> {
|
||
return resolveDeclarations(widthRules, selectors, viewportWidth);
|
||
}
|
||
|
||
function toneRule(tone: string): StyleRule | undefined {
|
||
return rules.find((rule) =>
|
||
rule.selectors.includes(
|
||
`.game-resource-generation-task-badge[data-tone='${tone}']`,
|
||
),
|
||
);
|
||
}
|
||
|
||
describe('「生成任务」侧栏样式', () => {
|
||
test('四种状态各有一档 tone,取值全部来自平台 token 而不硬编码颜色', () => {
|
||
const tones = ['queued', 'running', 'completed', 'failed'] as const;
|
||
const resolvedTones = tones.map((tone) =>
|
||
resolved([
|
||
'.game-resource-generation-task-badge',
|
||
`.game-resource-generation-task-badge[data-tone='${tone}']`,
|
||
]),
|
||
);
|
||
const signature = (map: Map<string, string>) =>
|
||
['border-color', 'background', 'color']
|
||
.map((property) => declaration(map, property))
|
||
.join(' | ');
|
||
|
||
const signatures = resolvedTones.map(signature);
|
||
// 四档必须互不相同,否则「状态徽标成体系」这条就退化成同一个样子。
|
||
expect(new Set(signatures).size).toBe(4);
|
||
expect(signatures[0]).toContain('--platform-neutral');
|
||
expect(signatures[1]).toContain('--platform-accent');
|
||
expect(signatures[2]).toContain('--platform-success');
|
||
expect(signatures[3]).toContain('--platform-button-danger');
|
||
for (const one of signatures) {
|
||
expect(one).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu);
|
||
}
|
||
// 四档 tone 必须在 CSS 里真实存在(少一档这条断言就红)。
|
||
for (const tone of tones) {
|
||
expect(toneRule(tone)).toBeDefined();
|
||
}
|
||
});
|
||
|
||
test('生成中只有「在动」的呼吸感,没有伪造百分比', () => {
|
||
const running = resolved([
|
||
'.game-resource-generation-task-badge',
|
||
".game-resource-generation-task-badge[data-tone='running']",
|
||
]);
|
||
// 徽标本身不承载进度数值:文案由组件按后端状态给,样式里不出现百分比工具。
|
||
expect(declaration(running, 'color')).toBe('var(--platform-accent)');
|
||
const pulse = rules.find((rule) =>
|
||
rule.selectors.includes(
|
||
".game-resource-generation-task-badge[data-tone='running']::before",
|
||
),
|
||
);
|
||
expect(pulse?.declarations.get('animation')).toContain('pulse');
|
||
});
|
||
|
||
test('阶段是次要信息、已耗时等宽数字右对齐', () => {
|
||
const phase = resolved(['.game-resource-generation-task-card-phase']);
|
||
expect(declaration(phase, 'color')).toBe('var(--platform-text-muted)');
|
||
expect(declaration(phase, 'font-size')).toBe('0.7rem');
|
||
|
||
const elapsed = resolved(['.game-resource-generation-task-card-elapsed']);
|
||
expect(declaration(elapsed, 'font-variant-numeric')).toBe('tabular-nums');
|
||
expect(declaration(elapsed, 'text-align')).toBe('right');
|
||
});
|
||
|
||
test('条目卡片有圆角与悬停反馈,失败原因可折行不截断', () => {
|
||
const card = resolved(['.game-resource-generation-task-card']);
|
||
expect(declaration(card, 'border-radius')).toBe('0.5rem');
|
||
|
||
const hover = resolved([
|
||
'.game-resource-generation-task-card',
|
||
'.game-resource-generation-task-card:hover',
|
||
]);
|
||
expect(declaration(hover, 'box-shadow')).toBe(
|
||
'var(--platform-desktop-hover-shadow)',
|
||
);
|
||
expect(declaration(hover, 'transform')).toBe('translateY(-1px)');
|
||
|
||
const error = resolved(['.game-resource-generation-task-card-error']);
|
||
expect(declaration(error, 'white-space')).toBe('normal');
|
||
expect(declaration(error, 'overflow-wrap')).toBe('anywhere');
|
||
|
||
const locate = resolved(['.game-resource-generation-task-locate']);
|
||
expect(declaration(locate, 'text-decoration')).toContain('underline');
|
||
});
|
||
|
||
test('进场动画留在侧栏上,删除的折叠把手不再有样式残留', () => {
|
||
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
|
||
expect(declaration(sidebar, 'animation')).toContain(
|
||
'game-resource-generation-tasks-enter',
|
||
);
|
||
expect(declaration(sidebar, 'border-radius')).toBe('0.75rem');
|
||
expect(declaration(sidebar, 'backdrop-filter')).toBe('blur(10px)');
|
||
|
||
// 折叠把手与底部关闭条都已删除:对应类名不得再出现在样式表里。
|
||
for (const removed of [
|
||
'.game-resource-generation-tasks-handle',
|
||
'.game-resource-generation-tasks-handle-label',
|
||
'.game-resource-generation-tasks-handle-badge',
|
||
'.game-resource-generation-tasks-sidebar-footer',
|
||
]) {
|
||
expect(rules.some((rule) => rule.selectors.includes(removed))).toBe(
|
||
false,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('收起与进场同源反向:is-leaving 挂上收起动画,减动效下同样关掉', () => {
|
||
const leaving = resolved([
|
||
'.game-resource-generation-tasks-sidebar',
|
||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||
]);
|
||
const animation = declaration(leaving, 'animation');
|
||
expect(animation).toContain('game-resource-generation-tasks-leave');
|
||
expect(animation).toContain('160ms');
|
||
// 动画期间不该还能点到里面(此刻它在播退场,点它只会在卸载前留下半截状态)。
|
||
expect(declaration(leaving, 'pointer-events')).toBe('none');
|
||
expect(
|
||
rules.some((rule) =>
|
||
rule.selectors.includes(
|
||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||
),
|
||
),
|
||
).toBe(true);
|
||
|
||
// 减少动效:收起动画同样关掉,组件那边会立即卸载,不白等 160ms。
|
||
const reduced = rules.filter((rule) =>
|
||
(rule.media ?? '').includes('prefers-reduced-motion'),
|
||
);
|
||
expect(
|
||
reduced.some(
|
||
(rule) =>
|
||
rule.selectors.includes(
|
||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||
) && rule.declarations.get('animation') === 'none',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
test('列表滚动条是细样式,滚动区域有界', () => {
|
||
const scroll = resolved(['.game-resource-generation-tasks-scroll']);
|
||
expect(declaration(scroll, 'overflow-y')).toBe('auto');
|
||
expect(declaration(scroll, 'scrollbar-width')).toBe('thin');
|
||
expect(declaration(scroll, 'overscroll-behavior')).toBe('contain');
|
||
const thumb = rules.find((rule) =>
|
||
rule.selectors.includes(
|
||
'.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb',
|
||
),
|
||
);
|
||
expect(thumb?.declarations.get('background')).toBe(
|
||
'var(--platform-line-soft)',
|
||
);
|
||
|
||
// 面板高度有界:不再靠上下边界拉满,改成锚点里的浮动卡片 + 封顶高度(内容再多也只滚列表)。
|
||
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
|
||
expect(declaration(sidebar, 'width')).toBe('min(300px, 80vw)');
|
||
expect(declaration(sidebar, 'max-height')).toBe(
|
||
'min(30rem, calc(100vh - 12rem))',
|
||
);
|
||
// 面板自己不再定位:坐标只由右上角锚点一处决定,避免两处各锚一处互相漂移。
|
||
expect(sidebar.has('position')).toBe(false);
|
||
expect(sidebar.has('left')).toBe(false);
|
||
});
|
||
|
||
test('锚点贴在工作面右上角,运行表现层让开版本入口,开关沿用次级胶囊样式', () => {
|
||
const anchor = resolved(['.game-resource-generation-tasks-anchor']);
|
||
// 锚点是 stage 网格里与工作面同一个单元格的条目:贴右贴顶由网格给,不再用写死 top 的绝对定位
|
||
//(写死的 top 会被工具条换行顶穿,验收现场那条「生成任务和菜单栏重叠」就是这么来的)。
|
||
expect(anchor.has('position')).toBe(false);
|
||
expect(declaration(anchor, 'grid-row')).toBe('3');
|
||
expect(declaration(anchor, 'grid-column')).toBe('1');
|
||
expect(declaration(anchor, 'justify-self')).toBe('end');
|
||
expect(declaration(anchor, 'align-self')).toBe('start');
|
||
expect(declaration(anchor, 'z-index')).toBe('40');
|
||
// 画布顶边内缩一小段,不压在工具条那一行上。
|
||
expect(declaration(anchor, 'margin')).toBe('0.85rem');
|
||
|
||
// 运行表现层右上角被版本入口(`game-run-version-picker`:top 20px / right 20px)占着,
|
||
// 这一档必须把开关压到那枚版本入口下面(内缩上边距 3.5rem)。
|
||
const runAnchor = resolved([
|
||
'.game-resource-generation-tasks-anchor',
|
||
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']",
|
||
]);
|
||
expect(declaration(runAnchor, 'grid-row')).toBe('2 / -1');
|
||
expect(declaration(runAnchor, 'margin')).toContain('3.5rem');
|
||
|
||
// UI 编辑器那一档与资源画布同档(第二行),不引入第三套坐标。
|
||
const editorAnchor = resolved([
|
||
'.game-resource-generation-tasks-anchor',
|
||
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='editor']",
|
||
]);
|
||
expect(declaration(editorAnchor, 'grid-row')).toBe('2');
|
||
expect(declaration(editorAnchor, 'margin')).toBe('0.85rem');
|
||
|
||
/*
|
||
* 资源总览页与资源栏目画布页的**锚点高度不同**:画布页顶部钉着整宽的栏目标题栏
|
||
* (右端就是「返回资源总览」入口),锚点必须让开它,否则那枚开关会压在标题栏上
|
||
* (验收现场那条「生成任务和标题栏重叠」)。让开的高度与标题栏真实高度是跨文件关系,
|
||
* 所以这里读全局样式表里的 min-height 断言「让开得比它高」。
|
||
*/
|
||
const titlebarMinHeight = cssLengthToPx(
|
||
declaration(
|
||
resolveDeclarations(globalWidthRules, [
|
||
'.game-resource-book-scene-titlebar',
|
||
]),
|
||
'min-height',
|
||
),
|
||
);
|
||
const canvasAnchor = resolved([
|
||
'.game-resource-generation-tasks-anchor',
|
||
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas']",
|
||
]);
|
||
const canvasAnchorTop = cssLengthToPx(
|
||
declaration(canvasAnchor, 'margin-top'),
|
||
);
|
||
expect(canvasAnchorTop).toBeGreaterThan(titlebarMinHeight);
|
||
|
||
const overviewAnchor = resolved([
|
||
'.game-resource-generation-tasks-anchor',
|
||
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas-overview']",
|
||
]);
|
||
const overviewAnchorTop = cssLengthToPx(
|
||
declaration(overviewAnchor, 'margin-top'),
|
||
);
|
||
// 总览页没有那条标题栏:仍贴画布顶边内缩,而且确实比画布页高一档。
|
||
expect(overviewAnchorTop).toBe(cssLengthToPx('0.85rem'));
|
||
expect(overviewAnchorTop).toBeLessThan(canvasAnchorTop);
|
||
|
||
// 开关保留原来工具条那一枚的外观(次级胶囊),只是搬到画布上并加一档面板投影;
|
||
// 取值仍然全部走平台 token,不引入第二套色板。
|
||
const toggle = resolved(['.game-resource-generation-tasks-toggle']);
|
||
expect(declaration(toggle, 'border-radius')).toBe('999px');
|
||
expect(declaration(toggle, 'border')).toBe(
|
||
'1px solid var(--platform-surface-border)',
|
||
);
|
||
expect(declaration(toggle, 'background')).toBe(
|
||
'var(--platform-button-secondary-fill)',
|
||
);
|
||
expect(declaration(toggle, 'color')).toBe(
|
||
'var(--platform-button-secondary-text)',
|
||
);
|
||
expect(declaration(toggle, 'box-shadow')).toBe(
|
||
'var(--platform-panel-shadow)',
|
||
);
|
||
for (const property of ['border', 'background', 'color', 'box-shadow']) {
|
||
expect(declaration(toggle, property)).not.toMatch(
|
||
/#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('窄屏(360px)下锚点拉满画布那一格,面板宽度自适应不溢出', () => {
|
||
const narrowAnchor = resolved(
|
||
['.game-resource-generation-tasks-anchor'],
|
||
360,
|
||
);
|
||
expect(declaration(narrowAnchor, 'justify-self')).toBe('stretch');
|
||
expect(declaration(narrowAnchor, 'margin')).toBe('0.5rem');
|
||
|
||
const narrowSidebar = resolved(
|
||
['.game-resource-generation-tasks-sidebar'],
|
||
360,
|
||
);
|
||
expect(declaration(narrowSidebar, 'width')).toBe('auto');
|
||
});
|
||
|
||
test('prefers-reduced-motion 下进场动画与过渡都被关掉', () => {
|
||
const reduced = rules.filter((rule) =>
|
||
(rule.media ?? '').includes('prefers-reduced-motion'),
|
||
);
|
||
expect(reduced.length).toBeGreaterThan(0);
|
||
const motionless = (selector: string, property: string) =>
|
||
reduced.some(
|
||
(rule) =>
|
||
rule.selectors.includes(selector) &&
|
||
rule.declarations.get(property) === 'none',
|
||
);
|
||
expect(
|
||
motionless('.game-resource-generation-tasks-sidebar', 'animation'),
|
||
).toBe(true);
|
||
expect(
|
||
motionless('.game-resource-generation-task-card', 'transition'),
|
||
).toBe(true);
|
||
});
|
||
|
||
test('资源总览 ↔ 资源画布切换时锚点高度有过渡,减动效下关掉', () => {
|
||
const anchor = resolved(['.game-resource-generation-tasks-anchor']);
|
||
expect(declaration(anchor, 'transition')).toContain('margin-top');
|
||
|
||
const reduced = rules.filter((rule) =>
|
||
(rule.media ?? '').includes('prefers-reduced-motion'),
|
||
);
|
||
expect(
|
||
reduced.some(
|
||
(rule) =>
|
||
rule.selectors.includes('.game-resource-generation-tasks-anchor') &&
|
||
rule.declarations.get('transition') === 'none',
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
test('焦点环可见(键盘可见焦点用平台 token)', () => {
|
||
const focusRule = rules.find((rule) =>
|
||
rule.selectors.includes(
|
||
'.game-resource-generation-tasks-sidebar-icon-button:focus-visible',
|
||
),
|
||
);
|
||
expect(focusRule?.declarations.get('outline')).toBe(
|
||
'2px solid var(--platform-accent)',
|
||
);
|
||
expect(focusRule?.declarations.get('box-shadow')).toBe(
|
||
'0 0 0 3px var(--platform-input-focus-ring)',
|
||
);
|
||
});
|
||
});
|