Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookController.test.ts
T
suzmii 985db358a7 修复资源画本转场克隆的层级与静态不一致
转场层里的克隆只按自身 DOM 顺序叠放,而静态场景中卡片是 25、标题栏是 30,导致动画期间卡片盖住标题栏、结束后又瞬间跳回下方
快照记录元素自身或最近定位祖先的 z-index,克隆按该值设置层级,保持静态层叠关系
补充 resourceBookController 层级回归用例
2026-09-09 16:22:07 +08:00

272 lines
10 KiB
TypeScript

/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createResourceBookTransitionController,
resourceBookFlipTransform,
} from '../src/view/project-development/resourceBookController';
function rect(left = 0, top = 0, width = 100, height = 80): DOMRect {
return {
left,
top,
width,
height,
x: left,
y: top,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
};
}
function fixture() {
const root = document.createElement('div');
root.innerHTML = `<div class="game-resource-book-scene"><div class="game-resource-book-scene-world">
<div class="game-resource-card" data-resource-card-id="one"><img src="blob:card-preview" alt="" /><button id="original">Open</button></div>
</div></div><div class="game-resource-book-transition-layer" inert aria-hidden="true"></div>`;
document.body.append(root);
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600));
const card = root.querySelector<HTMLElement>('.game-resource-card')!;
const measure = vi
.spyOn(card, 'getBoundingClientRect')
.mockReturnValue(rect());
const flights: {
finish(): void;
reject(): void;
cancel: ReturnType<typeof vi.fn>;
frames: Keyframe[];
}[] = [];
vi.spyOn(Element.prototype, 'animate').mockImplementation((frames) => {
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({ finish, reject, cancel, frames: frames as Keyframe[] });
return { finished, cancel } as unknown as Animation;
});
return { root, card, measure, flights };
}
const originalAnimate = Object.getOwnPropertyDescriptor(
Element.prototype,
'animate',
);
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (originalAnimate)
Object.defineProperty(Element.prototype, 'animate', originalAnimate);
else delete (Element.prototype as Partial<Element>).animate;
document.body.replaceChildren();
});
function setup() {
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
writable: true,
value: () => ({}),
});
return fixture();
}
describe('resource book FLIP controller', () => {
it('uses screen geometry independently of either viewport', () => {
expect(
resourceBookFlipTransform(rect(30, 40, 50, 20), rect(10, 10, 100, 80)),
).toBe('translate(20px, 30px) scale(0.5, 0.25)');
});
it('finishes from animation completion, removes clones and preserves real controls', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
measure.mockReturnValue(rect(150, 100, 200, 160));
const done = vi.fn();
controller.play(root, token, done);
expect(root.dataset.bookMotion).toBe('running');
expect(root.querySelectorAll('#original')).toHaveLength(1);
expect(flights[0].frames[0].transform).toBe(
'translate(-150px, -100px) scale(0.5, 0.5)',
);
expect(done).not.toHaveBeenCalled();
flights[0].finish();
await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1));
expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
expect(root.hasAttribute('data-book-motion')).toBe(false);
controller.dispose();
});
it('freezes blob-backed card previews into the snapshot', () => {
const { root, measure } = setup();
const image = root.querySelector<HTMLImageElement>(
'.game-resource-card img',
)!;
Object.defineProperty(image, 'complete', {
configurable: true,
value: true,
});
Object.defineProperty(image, 'naturalWidth', {
configurable: true,
value: 8,
});
Object.defineProperty(image, 'naturalHeight', {
configurable: true,
value: 6,
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,FROZEN',
);
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
measure.mockReturnValue(rect(150, 100, 200, 160));
controller.play(root, token, () => undefined);
const snapshotImage = root.querySelector<HTMLImageElement>(
'.game-resource-book-transition-layer img',
);
expect(snapshotImage?.getAttribute('src')).toBe(
'data:image/png;base64,FROZEN',
);
controller.dispose();
});
it('keeps the static stacking order inside the transition layer', () => {
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
writable: true,
value: () => ({
finished: Promise.resolve({} as Animation),
cancel: () => undefined,
}),
});
const root = document.createElement('div');
root.innerHTML = `<div class="game-resource-book-scene"><div class="game-resource-book-scene-world">
<div class="game-resource-book-scene-titlebar" style="z-index: 30"><span>标题</span></div>
<div class="game-resource-book-scene-card" style="z-index: 25"><div class="game-resource-card" data-resource-card-id="one"><img src="blob:card-preview" alt="" /></div></div>
</div></div><div class="game-resource-book-transition-layer" inert aria-hidden="true"></div>`;
document.body.append(root);
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600));
for (const element of root.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-titlebar, .game-resource-card',
)) {
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect());
}
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
controller.play(root, token, () => undefined);
const wrappers = Array.from(
root.querySelectorAll<HTMLElement>('[data-motion-snapshot]'),
);
const titleWrapper = wrappers.find((element) =>
element.dataset.motionSnapshot?.startsWith('title:'),
);
const cardWrapper = wrappers.find((element) =>
element.dataset.motionSnapshot?.startsWith('card:'),
);
expect(Number(titleWrapper?.style.zIndex)).toBe(30);
expect(Number(cardWrapper?.style.zIndex)).toBe(25);
controller.dispose();
});
it('retargets from the current overlay geometry and ignores old completion', async () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();
const firstDone = vi.fn();
let token = controller.begin(root);
measure.mockReturnValue(rect(200, 100, 200, 160));
controller.play(root, token, firstDone);
const moving = root.querySelector<HTMLElement>('[data-motion-snapshot]')!;
vi.spyOn(moving, 'getBoundingClientRect').mockReturnValue(
rect(90, 40, 150, 120),
);
token = controller.begin(root);
measure.mockReturnValue(rect(300, 200, 100, 80));
const secondDone = vi.fn();
controller.play(root, token, secondDone);
expect(flights[1].frames[0].transform).toBe(
'translate(-210px, -160px) scale(1.5, 1.5)',
);
await Promise.resolve();
expect(firstDone).not.toHaveBeenCalled();
expect(secondDone).not.toHaveBeenCalled();
flights[1].finish();
await vi.waitFor(() => expect(secondDone).toHaveBeenCalledTimes(1));
controller.dispose();
});
it.each(['reduced', 'zero', 'unsupported'])(
'settles without a timer for %s motion',
(reason) => {
const { root, measure, flights } = setup();
if (reason === 'zero') measure.mockReturnValue(rect(0, 0, 0, 0));
if (reason === 'unsupported')
delete (Element.prototype as Partial<Element>).animate;
if (reason === 'reduced')
vi.stubGlobal('matchMedia', () => ({ matches: true }));
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
const done = vi.fn();
controller.play(root, token, done);
expect(done).toHaveBeenCalledTimes(1);
expect(flights).toHaveLength(0);
controller.dispose();
},
);
it('settles on resize and suppresses completion after disposal', async () => {
const { root, flights } = setup();
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
window.dispatchEvent(new Event('resize'));
expect(done).toHaveBeenCalledTimes(1);
controller.play(root, controller.begin(root), done);
controller.dispose();
flights[1].finish();
await Promise.resolve();
expect(done).toHaveBeenCalledTimes(1);
expect(root.hasAttribute('data-book-motion')).toBe(false);
});
it('cleans partially started animations when animation creation fails', () => {
const { root } = setup();
vi.mocked(Element.prototype.animate).mockImplementation(() => {
throw new Error('animation unavailable');
});
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
expect(done).toHaveBeenCalledTimes(1);
expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0);
expect(root.hasAttribute('data-book-motion')).toBe(false);
controller.dispose();
});
it('cancellation never waits for a CSS timer and leaves the viewport untouched', async () => {
const { root, flights } = setup();
const world = root.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
)!;
world.style.transform = 'translate(20px, 40px) scale(1.5)';
const controller = createResourceBookTransitionController();
const done = vi.fn();
controller.play(root, controller.begin(root), done);
controller.settle();
expect(flights[0].cancel).toHaveBeenCalledTimes(1);
expect(done).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(world.style.transform).toBe('translate(20px, 40px) scale(1.5)');
controller.dispose();
});
});