Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookController.test.ts
T
suzmii 4a5b2ad524 修复返回总览时非活动栏目标题栏从屏幕外飞入
- begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First
- 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点
- 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置
- 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出
- 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出
- 同步技术方案转场段与 pitfalls 经验条目
2026-09-09 21:39:20 +08:00

595 lines
23 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) {
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>
</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-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]) {
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,
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();
});
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 的内联 transform 钉在屏幕上。
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('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();
});
});