b41b26cab8
- apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts:begin() 顺带按 (栏目, 类型摞列号) 记下同一摞最深那张卡的 First 矩形当"堆锚点",play() 时没有 First 帧的卡片认领它当合成 First(仍是同一套 FLIP,不新增动画系统、不为动画多渲染节点);堆锚点只在本次转场有效(maybeFinish / settle / invalidate 清掉),转场中重基(已在跑动画)不认领锚点,起点仍是"此刻像素"。 - apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts:展开态(allOpen)的卡片补上真实类型摞列号,不再一律 0——否则第 2 摞之后的卡片会全从第 1 摞飞出来。 - apps/ai-game-creator-shell/src/view/project-development/index.tsx:卡片宿主补 data-resource-book-stack-column / data-resource-book-stack-index,把"哪一摞"的摞身份交给转场层。 - apps/ai-game-creator-shell/tests/resourceBookController.test.ts:新增 3 条用例——每一张卡都从自己那一摞位移出来(含第 0/1 摞分别认领各自锚点、总览已渲染的仍用自己 First)、转场中重基从此刻像素而不是被拉回摞上、转场收尾后挂载的卡片不从过期锚点飞出。 - apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts:新增"展开态每张卡带自己那一摞的列号"用例(同栏目两摞 ⇒ [0,0,1])。 - apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:在既有总览/进栏目用例里钉住宿主上的摞身份属性(总览 3 张 = 下标 0,1,2;进栏目后 5 张 = 下标 0..4、同类型列号 0)。 - docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md:把资源画本转场一节更新为当前状态(堆锚点来源、锚点生命周期、展开态列号)。 - docs/project-memory/shared-memory/pitfalls.md:记录本次排障——现象、已用代码核实的原因(总览每摞只铺 3 张 ⇒ 其余卡片没有节点、没有 First 帧)、"钉住的标题栏移出 world"那笔改动不是原因的证据、处理、变异验证与真机判据。
949 lines
35 KiB
TypeScript
949 lines
35 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
import { afterEach, describe, expect, it, type Mock, vi } from 'vitest';
|
|
|
|
import {
|
|
createResourceBookTransitionController,
|
|
RESOURCE_BOOK_MOTION_DURATION,
|
|
RESOURCE_BOOK_MOTION_MIN_DURATION,
|
|
resourceBookFlipTransform,
|
|
resourceBookValidMotionRect,
|
|
} from '../src/view/project-development/resourceBookController';
|
|
|
|
type MotionRect = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
function toDomRect(rect: MotionRect): DOMRect {
|
|
return {
|
|
left: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
x: rect.left,
|
|
y: rect.top,
|
|
right: rect.left + rect.width,
|
|
bottom: rect.top + rect.height,
|
|
toJSON: () => ({}),
|
|
} as DOMRect;
|
|
}
|
|
|
|
type Flight = {
|
|
host: Element;
|
|
frames: Keyframe[];
|
|
options: KeyframeAnimationOptions;
|
|
cancel: Mock;
|
|
finish: () => void;
|
|
reject: () => void;
|
|
};
|
|
|
|
const originalAnimate = Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
'animate',
|
|
);
|
|
|
|
function installAnimateMock() {
|
|
const flights: Flight[] = [];
|
|
Object.defineProperty(Element.prototype, 'animate', {
|
|
configurable: true,
|
|
writable: true,
|
|
value(
|
|
this: Element,
|
|
frames: Keyframe[],
|
|
options: KeyframeAnimationOptions,
|
|
) {
|
|
let finish!: () => void;
|
|
let reject!: () => void;
|
|
const finished = new Promise<Animation>((resolve, fail) => {
|
|
finish = () => resolve({} as Animation);
|
|
reject = () => fail(new Error('canceled'));
|
|
});
|
|
const cancel = vi.fn(reject);
|
|
flights.push({ host: this, frames, options, cancel, finish, reject });
|
|
return {
|
|
finished,
|
|
cancel,
|
|
effect: { getComputedTiming: () => ({}) },
|
|
} as unknown as Animation;
|
|
},
|
|
});
|
|
return flights;
|
|
}
|
|
|
|
function fixture(worldScale = 1, options: { pinnedTitlebar?: boolean } = {}) {
|
|
const manager = document.createElement('div');
|
|
manager.className = 'game-resource-book-manager';
|
|
manager.innerHTML = `<div class="game-resource-book-scene">
|
|
<div class="game-resource-book-scene-world">
|
|
<div class="game-resource-book-scene-titlebar" data-resource-book-category="document"></div>
|
|
<div class="game-resource-book-scene-card" data-resource-book-category="document">
|
|
<div class="game-resource-card" data-resource-card-id="one"></div>
|
|
</div>
|
|
</div>
|
|
${
|
|
options.pinnedTitlebar
|
|
? '<div class="game-resource-book-scene-titlebar is-active" data-resource-book-category="unclassified"></div>'
|
|
: ''
|
|
}
|
|
</div>`;
|
|
document.body.append(manager);
|
|
const world = manager.querySelector<HTMLElement>(
|
|
'.game-resource-book-scene-world',
|
|
)!;
|
|
const cardHost = manager.querySelector<HTMLElement>(
|
|
'.game-resource-book-scene-card',
|
|
)!;
|
|
const cardBox = manager.querySelector<HTMLElement>('.game-resource-card')!;
|
|
const titleHost = manager.querySelector<HTMLElement>(
|
|
'.game-resource-book-scene-world .game-resource-book-scene-titlebar',
|
|
)!;
|
|
const pinnedTitleHost = manager.querySelector<HTMLElement>(
|
|
'.game-resource-book-scene > .game-resource-book-scene-titlebar',
|
|
);
|
|
const rects = new Map<Element, MotionRect>();
|
|
const setRect = (element: Element, rect: MotionRect) => {
|
|
rects.set(element, rect);
|
|
};
|
|
for (const element of [
|
|
manager,
|
|
world,
|
|
cardHost,
|
|
cardBox,
|
|
titleHost,
|
|
...(pinnedTitleHost ? [pinnedTitleHost] : []),
|
|
]) {
|
|
vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() =>
|
|
toDomRect(rects.get(element) ?? { left: 0, top: 0, width: 0, height: 0 }),
|
|
);
|
|
}
|
|
setRect(manager, { left: 0, top: 0, width: 800, height: 600 });
|
|
setRect(world, { left: 0, top: 0, width: 800, height: 600 });
|
|
setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 });
|
|
const realGetComputedStyle = window.getComputedStyle.bind(window);
|
|
vi.spyOn(window, 'getComputedStyle').mockImplementation(((
|
|
element: Element,
|
|
pseudo?: string | null,
|
|
) => {
|
|
const style = realGetComputedStyle(element, pseudo);
|
|
if (element !== world) return style;
|
|
return new Proxy(style, {
|
|
get: (target, property) =>
|
|
property === 'transform'
|
|
? `matrix(${worldScale}, 0, 0, ${worldScale}, 0, 0)`
|
|
: Reflect.get(target, property),
|
|
});
|
|
}) as typeof window.getComputedStyle);
|
|
const signatures = new Map<string, string>([
|
|
['card:one', 'child:10,20:180,128'],
|
|
['title:document', 'idle:0,0,280'],
|
|
]);
|
|
return {
|
|
manager,
|
|
world,
|
|
cardHost,
|
|
cardBox,
|
|
titleHost,
|
|
pinnedTitleHost,
|
|
setRect,
|
|
signatures,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
if (originalAnimate)
|
|
Object.defineProperty(Element.prototype, 'animate', originalAnimate);
|
|
else delete (Element.prototype as Partial<Element>).animate;
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
type StackCardSpec = {
|
|
id: string;
|
|
/** 总览里的类型摞列号(与 `resourceBookOverviewCardLayout` 的 `stackColumn` 同口径)。 */
|
|
column: number;
|
|
/** 摞内下标:越大越深、越靠后。 */
|
|
index: number;
|
|
rect: MotionRect;
|
|
};
|
|
|
|
/**
|
|
* 多卡宿主:把场景世界换成"一根栏目摞"。
|
|
*
|
|
* 宿主是 0x0 的定位包装,它的原点就是卡片自己的落点(经典 FLIP 起点);真假两态都复用它,
|
|
* 差别只在传入的卡片清单与矩形——总览一摞只铺 3 张,进栏目后这一摞铺满。
|
|
*/
|
|
function mountStackCards(world: HTMLElement, cards: StackCardSpec[]) {
|
|
world.replaceChildren();
|
|
const hosts = new Map<string, HTMLElement>();
|
|
const boxes = new Map<string, HTMLElement>();
|
|
for (const card of cards) {
|
|
const host = document.createElement('div');
|
|
host.className = 'game-resource-book-scene-card';
|
|
host.dataset.resourceBookCategory = 'unclassified';
|
|
host.dataset.resourceBookStackColumn = String(card.column);
|
|
host.dataset.resourceBookStackIndex = String(card.index);
|
|
const box = document.createElement('div');
|
|
box.className = 'game-resource-card';
|
|
box.dataset.resourceCardId = card.id;
|
|
host.append(box);
|
|
world.append(host);
|
|
vi.spyOn(host, 'getBoundingClientRect').mockImplementation(() =>
|
|
toDomRect({
|
|
left: card.rect.left,
|
|
top: card.rect.top,
|
|
width: 0,
|
|
height: 0,
|
|
}),
|
|
);
|
|
vi.spyOn(box, 'getBoundingClientRect').mockImplementation(() =>
|
|
toDomRect(card.rect),
|
|
);
|
|
hosts.set(card.id, host);
|
|
boxes.set(card.id, box);
|
|
}
|
|
return { hosts, boxes };
|
|
}
|
|
|
|
/** 卡片宿主上必须正好有一段动画;返回它,避免逐处 `flights.find(...)!`。 */
|
|
function flightFor(flights: Flight[], host: Element | undefined) {
|
|
const matches = flights.filter((flight) => flight.host === host);
|
|
expect(matches).toHaveLength(1);
|
|
return matches[0]!;
|
|
}
|
|
|
|
describe('resource book pile anchors', () => {
|
|
/**
|
|
* 用户口径:进栏目时**每一张卡**都要从那摞里位移出来,而不是只有总览里已经渲染的那几张。
|
|
*
|
|
* 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张(其余数量写在"N 项"里),所以
|
|
* 大部分卡片在总览侧没有节点、拿不到 First 帧;它们必须从自己那一摞起飞(合成 First),
|
|
* 而不是原地淡入。
|
|
*/
|
|
it('flies every card of the opened category out of its own overview pile', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, world, signatures } = fixture();
|
|
// 总览:第 0 摞铺满 3 张(下标 2 最深,就是"那一摞"的落点),第 1 摞只有 1 张。
|
|
mountStackCards(world, [
|
|
{
|
|
id: 'art-0',
|
|
column: 0,
|
|
index: 0,
|
|
rect: { left: 100, top: 100, width: 92, height: 64 },
|
|
},
|
|
{
|
|
id: 'art-1',
|
|
column: 0,
|
|
index: 1,
|
|
rect: { left: 100, top: 105, width: 92, height: 64 },
|
|
},
|
|
{
|
|
id: 'art-2',
|
|
column: 0,
|
|
index: 2,
|
|
rect: { left: 100, top: 110, width: 92, height: 64 },
|
|
},
|
|
{
|
|
id: 'art-3',
|
|
column: 1,
|
|
index: 0,
|
|
rect: { left: 202, top: 110, width: 92, height: 64 },
|
|
},
|
|
]);
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
// 提交后:这一摞铺满全部卡片,art-4 / art-5 / art-6 在总览侧从来没有节点。
|
|
const { hosts } = mountStackCards(world, [
|
|
{
|
|
id: 'art-0',
|
|
column: 0,
|
|
index: 0,
|
|
rect: { left: 400, top: 500, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-1',
|
|
column: 0,
|
|
index: 1,
|
|
rect: { left: 400, top: 700, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-2',
|
|
column: 0,
|
|
index: 2,
|
|
rect: { left: 400, top: 900, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-4',
|
|
column: 0,
|
|
index: 3,
|
|
rect: { left: 700, top: 500, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-5',
|
|
column: 0,
|
|
index: 4,
|
|
rect: { left: 700, top: 700, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-3',
|
|
column: 1,
|
|
index: 0,
|
|
rect: { left: 1_000, top: 500, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-6',
|
|
column: 1,
|
|
index: 1,
|
|
rect: { left: 1_000, top: 700, width: 180, height: 128 },
|
|
},
|
|
]);
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
// 名单:这一栏目进的每一张卡都要有位移(起点 ≠ 终点),不是只有总览那几张。
|
|
for (const [id, host] of hosts) {
|
|
const flight = flightFor(flights, host);
|
|
expect(flight.frames[0]!.transform, id).not.toBe(
|
|
flight.frames[1]!.transform,
|
|
);
|
|
expect(flight.frames[1]!.transform, id).toBe('none');
|
|
}
|
|
|
|
// 起点是"自己那一摞"最深那张卡的矩形。
|
|
expect(flightFor(flights, hosts.get('art-4')).frames[0]!.transform).toBe(
|
|
'translate(-600px, -390px) scale(0.511, 0.5)',
|
|
);
|
|
// 第 1 摞的卡片从第 1 摞起飞,不会被贴到第 0 摞的落点上。
|
|
expect(flightFor(flights, hosts.get('art-6')).frames[0]!.transform).toBe(
|
|
'translate(-798px, -590px) scale(0.511, 0.5)',
|
|
);
|
|
// 总览里已经渲染的那几张仍从它自己的 First 帧起飞。
|
|
expect(flightFor(flights, hosts.get('art-0')).frames[0]!.transform).toBe(
|
|
'translate(-300px, -400px) scale(0.511, 0.5)',
|
|
);
|
|
// 从摞里出来是"看得见地飞",不叠一层原地淡入。
|
|
expect(flightFor(flights, hosts.get('art-4')).frames[0]!.opacity).toBe(1);
|
|
expect(flightFor(flights, hosts.get('art-4')).frames[1]!.opacity).toBe(1);
|
|
controller.dispose();
|
|
});
|
|
|
|
/**
|
|
* 卡片已经飞出那一摞、正在动的时候,预览图尺寸才到(布局再变一次)。
|
|
*
|
|
* 重基必须从"此刻像素"续上:堆锚点在整段转场里都还活着,一旦重基也去认领它,
|
|
* 卡片会被拉回摞上再飞一次——用户看到的是"飞一半又跳回卡片堆"。
|
|
*/
|
|
it('rebases a flying card from the current pixels instead of the pile anchor', () => {
|
|
const flights = installAnimateMock();
|
|
const now = vi.spyOn(performance, 'now');
|
|
now.mockReturnValue(1_000);
|
|
const { manager, world, signatures } = fixture();
|
|
mountStackCards(world, [
|
|
{
|
|
id: 'art-0',
|
|
column: 0,
|
|
index: 0,
|
|
rect: { left: 100, top: 100, width: 92, height: 64 },
|
|
},
|
|
]);
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
const { hosts, boxes } = mountStackCards(world, [
|
|
{
|
|
id: 'art-0',
|
|
column: 0,
|
|
index: 0,
|
|
rect: { left: 400, top: 500, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-4',
|
|
column: 0,
|
|
index: 1,
|
|
rect: { left: 700, top: 500, width: 180, height: 128 },
|
|
},
|
|
]);
|
|
signatures.set('card:art-0', 'child:400,500:180,128');
|
|
signatures.set('card:art-4', 'child:700,500:180,128');
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
const flying = flightFor(flights, hosts.get('art-4'));
|
|
expect(flying.frames[0]!.transform).not.toBe('none');
|
|
|
|
now.mockReturnValue(1_300);
|
|
const visual: MotionRect = { left: 650, top: 470, width: 140, height: 100 };
|
|
const clean: MotionRect = { left: 720, top: 520, width: 220, height: 160 };
|
|
vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() =>
|
|
toDomRect(visual),
|
|
);
|
|
flying.cancel.mockImplementation(() => {
|
|
vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() =>
|
|
toDomRect(clean),
|
|
);
|
|
flying.reject();
|
|
});
|
|
signatures.set('card:art-4', 'child:720,520:220,160');
|
|
controller.sync(manager, signatures);
|
|
|
|
const rebased = flights.filter(
|
|
(flight) => flight.host === hosts.get('art-4'),
|
|
);
|
|
expect(rebased).toHaveLength(2);
|
|
expect(rebased[1]!.frames[0]!.transform).toBe(
|
|
resourceBookFlipTransform(visual, clean, { left: 700, top: 500 }),
|
|
);
|
|
expect(rebased[1]!.frames[1]!.transform).toBe('none');
|
|
controller.dispose();
|
|
});
|
|
|
|
it('does not fly a card mounted after the transition out of a stale pile', async () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, world, signatures } = fixture();
|
|
mountStackCards(world, [
|
|
{
|
|
id: 'art-0',
|
|
column: 0,
|
|
index: 0,
|
|
rect: { left: 100, top: 100, width: 92, height: 64 },
|
|
},
|
|
]);
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
mountStackCards(world, [
|
|
{
|
|
id: 'art-0',
|
|
column: 0,
|
|
index: 0,
|
|
rect: { left: 400, top: 500, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-4',
|
|
column: 0,
|
|
index: 1,
|
|
rect: { left: 700, top: 500, width: 180, height: 128 },
|
|
},
|
|
]);
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
for (const flight of flights) flight.finish();
|
|
await vi.waitFor(() => expect(controller.isAnimating()).toBe(false));
|
|
|
|
// 转场收尾后堆锚点必须失效:否则此后挂载的卡片会从一段过期位置飞出来。
|
|
const { hosts: lateHosts } = mountStackCards(world, [
|
|
{
|
|
id: 'art-0',
|
|
column: 0,
|
|
index: 0,
|
|
rect: { left: 400, top: 500, width: 180, height: 128 },
|
|
},
|
|
{
|
|
id: 'art-9',
|
|
column: 0,
|
|
index: 1,
|
|
rect: { left: 900, top: 900, width: 180, height: 128 },
|
|
},
|
|
]);
|
|
controller.sync(manager, signatures);
|
|
|
|
expect(
|
|
flights.filter((flight) => flight.host === lateHosts.get('art-9')),
|
|
).toHaveLength(0);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
describe('resource book FLIP geometry', () => {
|
|
it('uses the classic FLIP transform when the origin is the last box', () => {
|
|
expect(
|
|
resourceBookFlipTransform(
|
|
{ left: 30, top: 40, width: 50, height: 20 },
|
|
{ left: 10, top: 10, width: 100, height: 80 },
|
|
),
|
|
).toBe('translate(20px, 30px) scale(0.5, 0.25)');
|
|
});
|
|
|
|
it('accounts for a host origin that is not the last box', () => {
|
|
// 卡片包装节点是 0x0,缩放围绕它的原点,位置要按原点偏移换算。
|
|
expect(
|
|
resourceBookFlipTransform(
|
|
{ left: 30, top: 40, width: 50, height: 20 },
|
|
{ left: 10, top: 10, width: 100, height: 80 },
|
|
{ left: 0, top: 0 },
|
|
),
|
|
).toBe('translate(25px, 37.5px) scale(0.5, 0.25)');
|
|
});
|
|
|
|
it('divides screen translation by the world scale', () => {
|
|
expect(
|
|
resourceBookFlipTransform(
|
|
{ left: 300, top: 250, width: 90, height: 64 },
|
|
{ left: 200, top: 200, width: 180, height: 128 },
|
|
{ left: 100, top: 100 },
|
|
2,
|
|
),
|
|
).toBe('translate(75px, 50px) scale(0.5, 0.5)');
|
|
});
|
|
|
|
it('rejects degenerate rectangles', () => {
|
|
expect(
|
|
resourceBookValidMotionRect({ left: 0, top: 0, width: 0, height: 10 }),
|
|
).toBe(false);
|
|
expect(
|
|
resourceBookValidMotionRect({ left: 0, top: 0, width: 10, height: 10 }),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resource book real element FLIP', () => {
|
|
it('animates the same card node instead of cloning it', async () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 });
|
|
const done = vi.fn();
|
|
controller.play(manager, token, done, signatures);
|
|
|
|
const cardFlights = flights.filter((flight) => flight.host === cardHost);
|
|
expect(cardFlights).toHaveLength(1);
|
|
expect(cardFlights[0]!.frames[0]!.transform).toBe(
|
|
'translate(-100px, -50px) scale(0.511, 0.5)',
|
|
);
|
|
expect(cardFlights[0]!.frames[1]!.transform).toBe('none');
|
|
expect(cardFlights[0]!.options.duration).toBe(
|
|
RESOURCE_BOOK_MOTION_DURATION,
|
|
);
|
|
// 真实元素:没有克隆节点,也没有快照层。
|
|
expect(manager.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
|
|
expect(manager.querySelectorAll('.game-resource-card')).toHaveLength(1);
|
|
expect(controller.isAnimating()).toBe(true);
|
|
|
|
expect(done).not.toHaveBeenCalled();
|
|
cardFlights[0]!.finish();
|
|
await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1));
|
|
expect(controller.isAnimating()).toBe(false);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('composes the FLIP transform with the titlebar base transform', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, titleHost, cardBox, setRect, signatures } = fixture();
|
|
const base = 'translate(-10px, -20px) scale(0.5)';
|
|
vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() =>
|
|
titleHost.style.transform
|
|
? toDomRect({ left: -10, top: -20, width: 140, height: 21 })
|
|
: toDomRect({ left: 0, top: 0, width: 280, height: 42 }),
|
|
);
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
titleHost.style.transform = base;
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const flight = flights.find((entry) => entry.host === titleHost)!;
|
|
expect(flight.frames[0]!.transform).toBe(
|
|
`translate(20px, 40px) scale(2, 2) ${base}`,
|
|
);
|
|
expect(flight.frames[1]!.transform).toBe(base);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('pins a leaving node in place and fades it out', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
// 提交后该栏目不再可见(内联 opacity 0),world 也变了导致屏幕位置改变。
|
|
cardHost.style.opacity = '0';
|
|
setRect(cardBox, { left: 700, top: 500, width: 184, height: 128 });
|
|
setRect(cardHost, { left: 700, top: 500, width: 0, height: 0 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const flight = flights.find((entry) => entry.host === cardHost)!;
|
|
expect(flight.frames[0]!.transform).toBe(flight.frames[1]!.transform);
|
|
expect(flight.frames[0]!.opacity).toBe(1);
|
|
expect(flight.frames[1]!.opacity).toBe(0);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('does not move a titlebar that was invisible when the transition started', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardHost, cardBox, titleHost, setRect, signatures } =
|
|
fixture();
|
|
setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 });
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
// 子画布静止态:非活动标题栏不可见,屏幕位置来自子画布 world,可能远在屏幕外。
|
|
titleHost.style.opacity = '0';
|
|
setRect(titleHost, { left: -2_000, top: -1_500, width: 280, height: 42 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
// 返回总览:标题栏回到总览矩形并可见,卡片收回摞上。
|
|
titleHost.style.opacity = '1';
|
|
setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 });
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const titleFlight = flights.find((flight) => flight.host === titleHost);
|
|
// 允许淡入,但几何首末必须相等:不能从屏幕外飞进来。
|
|
if (titleFlight) {
|
|
expect(titleFlight.frames[0]!.transform).toBe(
|
|
titleFlight.frames[1]!.transform,
|
|
);
|
|
}
|
|
const cardFlight = flights.find((flight) => flight.host === cardHost)!;
|
|
expect(cardFlight.frames[0]!.transform).not.toBe(
|
|
cardFlight.frames[1]!.transform,
|
|
);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('still animates the active titlebar back to the overview stack', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardBox, titleHost, setRect, signatures } = fixture();
|
|
// 子画布静止态:标题栏的屏幕矩形与 world 局部矩形不一致(world 有平移/缩放)。
|
|
vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() =>
|
|
titleHost.style.transform
|
|
? toDomRect({ left: 0, top: 0, width: 560, height: 84 })
|
|
: toDomRect({ left: 500, top: 300, width: 280, height: 42 }),
|
|
);
|
|
titleHost.style.transform = 'translate(-300px, -200px) scale(2)';
|
|
titleHost.style.opacity = '1';
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
titleHost.style.transform = '';
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const flight = flights.find((entry) => entry.host === titleHost)!;
|
|
expect(flight.frames[0]!.transform).not.toBe(flight.frames[1]!.transform);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('flips a pinned titlebar in screen space, not in world space', () => {
|
|
const flights = installAnimateMock();
|
|
// 钉在视口上的标题栏是缩放层 world 的兄弟节点(见 ResourceBookScene):它不在 world 坐标系里,
|
|
// 反向平移不能再除以 world 缩放,否则动画终点会被缩放除错。
|
|
const { manager, cardBox, pinnedTitleHost, setRect, signatures } = fixture(
|
|
2,
|
|
{
|
|
pinnedTitlebar: true,
|
|
},
|
|
);
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
setRect(pinnedTitleHost!, { left: 40, top: 30, width: 560, height: 84 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
// 收起回总览:钉住的那一条换成总览矩形(同位、更窄更矮)。
|
|
setRect(pinnedTitleHost!, { left: 0, top: 0, width: 800, height: 42 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const flight = flights.find((entry) => entry.host === pinnedTitleHost)!;
|
|
// world 缩放为 2:世界层里的节点会被除以 2;屏幕坐标系里的宿主必须原样保留 (40, 30)。
|
|
expect(flight.frames[0]!.transform).toBe(
|
|
'translate(40px, 30px) scale(0.7, 2)',
|
|
);
|
|
expect(flight.frames[1]!.transform).toBe('none');
|
|
controller.dispose();
|
|
});
|
|
|
|
it('still divides a world-space card translation by the world scale', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardHost, cardBox, setRect, signatures } = fixture(2);
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const flight = flights.find((entry) => entry.host === cardHost)!;
|
|
expect(flight.frames[0]!.transform).toBe(
|
|
'translate(-50px, -25px) scale(0.511, 0.5)',
|
|
);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('fades a titlebar out in place while entering a category', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardBox, titleHost, setRect, signatures } = fixture();
|
|
titleHost.style.opacity = '1';
|
|
setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 });
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
// 进入栏目:标题栏不可见,屏幕位置来自子画布 world。
|
|
titleHost.style.opacity = '0';
|
|
setRect(titleHost, { left: -2_000, top: -1_500, width: 280, height: 42 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const flight = flights.find((entry) => entry.host === titleHost)!;
|
|
expect(flight.frames[0]!.transform).toBe(flight.frames[1]!.transform);
|
|
expect(flight.frames[0]!.opacity).toBe(1);
|
|
expect(flight.frames[1]!.opacity).toBe(0);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('fades in a card that mounts during the transition', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
const flight = flights.find((entry) => entry.host === cardHost)!;
|
|
expect(flight.frames[0]!.opacity).toBe(0);
|
|
expect(flight.frames[1]!.opacity).toBe(1);
|
|
expect(flight.frames[0]!.transform).toBe('none');
|
|
expect(flight.frames[1]!.transform).toBe('none');
|
|
controller.dispose();
|
|
});
|
|
|
|
it('settles immediately without Web Animations support', () => {
|
|
const { manager, signatures } = fixture();
|
|
const controller = createResourceBookTransitionController();
|
|
const done = vi.fn();
|
|
controller.play(manager, controller.begin(manager), done, signatures);
|
|
expect(done).toHaveBeenCalledTimes(1);
|
|
expect(controller.isAnimating()).toBe(false);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('settles immediately for reduced motion', () => {
|
|
installAnimateMock();
|
|
vi.stubGlobal('matchMedia', () => ({ matches: true }));
|
|
const { manager, signatures } = fixture();
|
|
const controller = createResourceBookTransitionController();
|
|
const done = vi.fn();
|
|
controller.play(manager, controller.begin(manager), done, signatures);
|
|
expect(done).toHaveBeenCalledTimes(1);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('cancels running animations on settle and reports completion once', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
const controller = createResourceBookTransitionController();
|
|
const done = vi.fn();
|
|
const token = controller.begin(manager);
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
controller.play(manager, token, done, signatures);
|
|
expect(controller.isAnimating()).toBe(true);
|
|
|
|
controller.settle();
|
|
|
|
expect(done).toHaveBeenCalledTimes(1);
|
|
expect(controller.isAnimating()).toBe(false);
|
|
expect(
|
|
flights.every((flight) => flight.cancel.mock.calls.length >= 1),
|
|
).toBe(true);
|
|
controller.dispose();
|
|
});
|
|
});
|
|
|
|
describe('resource book layout rebase', () => {
|
|
it('rebases a running transition from the current pixels', () => {
|
|
const flights = installAnimateMock();
|
|
const now = vi.spyOn(performance, 'now');
|
|
now.mockReturnValue(1_000);
|
|
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
expect(flights).toHaveLength(1);
|
|
|
|
// 预览图尺寸晚到:动画跑到一半时布局再变一次。取消旧动画后几何才变成新布局。
|
|
now.mockReturnValue(1_300);
|
|
const visual: MotionRect = { left: 150, top: 130, width: 140, height: 100 };
|
|
const clean: MotionRect = { left: 210, top: 170, width: 220, height: 160 };
|
|
setRect(cardBox, visual);
|
|
flights[0]!.cancel.mockImplementation(() => {
|
|
setRect(cardBox, clean);
|
|
flights[0]!.reject();
|
|
});
|
|
signatures.set('card:one', 'child:10,20:220,160');
|
|
controller.sync(manager, signatures);
|
|
|
|
expect(flights).toHaveLength(2);
|
|
const rebase = flights[1]!;
|
|
expect(rebase.frames[0]!.transform).toBe(
|
|
resourceBookFlipTransform(visual, clean, { left: 200, top: 150 }),
|
|
);
|
|
expect(rebase.frames[1]!.transform).toBe('none');
|
|
expect(rebase.options.duration).toBe(120);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('clamps the rebased duration to the minimum', () => {
|
|
const flights = installAnimateMock();
|
|
const now = vi.spyOn(performance, 'now');
|
|
now.mockReturnValue(1_000);
|
|
const { manager, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
now.mockReturnValue(1_000 + RESOURCE_BOOK_MOTION_DURATION + 50);
|
|
setRect(cardBox, { left: 150, top: 130, width: 140, height: 100 });
|
|
flights[0]!.cancel.mockImplementation(() => {
|
|
setRect(cardBox, { left: 210, top: 170, width: 220, height: 160 });
|
|
flights[0]!.reject();
|
|
});
|
|
signatures.set('card:one', 'child:10,20:220,160');
|
|
controller.sync(manager, signatures);
|
|
|
|
expect(flights[1]!.options.duration).toBe(
|
|
RESOURCE_BOOK_MOTION_MIN_DURATION,
|
|
);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('animates an idle layout change with the full duration', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardHost, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 });
|
|
const previous: MotionRect = {
|
|
left: 200,
|
|
top: 150,
|
|
width: 180,
|
|
height: 128,
|
|
};
|
|
setRect(cardBox, previous);
|
|
const controller = createResourceBookTransitionController();
|
|
controller.sync(manager, signatures);
|
|
expect(flights).toHaveLength(0);
|
|
|
|
// 预览图尺寸在动画结束之后才到:位置/尺寸变化必须是动画,而不是瞬移。
|
|
const next: MotionRect = { left: 260, top: 150, width: 220, height: 160 };
|
|
setRect(cardBox, next);
|
|
signatures.set('card:one', 'child:60,20:220,160');
|
|
controller.sync(manager, signatures);
|
|
|
|
expect(flights).toHaveLength(1);
|
|
expect(flights[0]!.frames[0]!.transform).toBe(
|
|
resourceBookFlipTransform(previous, next, { left: 0, top: 0 }),
|
|
);
|
|
expect(flights[0]!.frames[1]!.transform).toBe('none');
|
|
expect(flights[0]!.options.duration).toBe(RESOURCE_BOOK_MOTION_DURATION);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('ignores world-only changes such as pan and zoom', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardBox, setRect, signatures } = fixture(2);
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
const controller = createResourceBookTransitionController();
|
|
controller.sync(manager, signatures);
|
|
|
|
setRect(cardBox, { left: 260, top: 190, width: 180, height: 128 });
|
|
controller.sync(manager, signatures);
|
|
|
|
expect(flights).toHaveLength(0);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('keeps the layout baseline valid across a pan', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, world, cardHost, cardBox, setRect, signatures } =
|
|
fixture();
|
|
setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 });
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
const controller = createResourceBookTransitionController();
|
|
controller.sync(manager, signatures);
|
|
|
|
// 平移主画布:world 原点移动,卡片跟着移动,布局签名不变。
|
|
setRect(world, { left: 40, top: 30, width: 800, height: 600 });
|
|
setRect(cardHost, { left: 40, top: 30, width: 0, height: 0 });
|
|
setRect(cardBox, { left: 240, top: 180, width: 180, height: 128 });
|
|
controller.sync(manager, signatures);
|
|
expect(flights).toHaveLength(0);
|
|
|
|
// 平移之后预览图尺寸才到:起点必须是卡片"此刻"的屏幕位置,不是平移前的。
|
|
const next: MotionRect = { left: 300, top: 180, width: 220, height: 160 };
|
|
setRect(cardBox, next);
|
|
signatures.set('card:one', 'child:60,20:220,160');
|
|
controller.sync(manager, signatures);
|
|
|
|
expect(flights).toHaveLength(1);
|
|
expect(flights[0]!.frames[0]!.transform).toBe(
|
|
resourceBookFlipTransform(
|
|
{ left: 240, top: 180, width: 180, height: 128 },
|
|
next,
|
|
{ left: 40, top: 30 },
|
|
),
|
|
);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('freezes the dragged card', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
const controller = createResourceBookTransitionController();
|
|
controller.sync(manager, signatures);
|
|
|
|
setRect(cardBox, { left: 320, top: 260, width: 180, height: 128 });
|
|
signatures.set('card:one', 'child:120,110:180,128');
|
|
controller.sync(manager, signatures, new Set(['card:one']));
|
|
|
|
expect(flights).toHaveLength(0);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('does not animate when the manager size changes', () => {
|
|
const flights = installAnimateMock();
|
|
const { manager, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
const controller = createResourceBookTransitionController();
|
|
controller.sync(manager, signatures);
|
|
|
|
setRect(manager, { left: 0, top: 0, width: 640, height: 480 });
|
|
setRect(cardBox, { left: 160, top: 120, width: 180, height: 128 });
|
|
signatures.set('card:one', 'child:20,10:180,128');
|
|
controller.sync(manager, signatures);
|
|
|
|
expect(flights).toHaveLength(0);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('reports remaining motion for fading exit nodes', () => {
|
|
installAnimateMock();
|
|
const now = vi.spyOn(performance, 'now');
|
|
now.mockReturnValue(500);
|
|
const { manager, cardBox, setRect, signatures } = fixture();
|
|
setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 });
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(manager);
|
|
setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 });
|
|
controller.play(manager, token, () => undefined, signatures);
|
|
|
|
expect(controller.motionRemainingMs()).toBe(RESOURCE_BOOK_MOTION_DURATION);
|
|
now.mockReturnValue(800);
|
|
expect(controller.motionRemainingMs()).toBe(
|
|
RESOURCE_BOOK_MOTION_DURATION - 300,
|
|
);
|
|
controller.dispose();
|
|
});
|
|
});
|