91f335468a
上一版在布局变化时重启整段动画,导致此前修好的遮挡顺序与加载态全部复现,已回滚该做法 控制器新增 retarget:临时解除场景隐藏后重新测量真实卡片,只把运行中动画的结束帧改成映射到新几何;克隆几何、动画进度与叠加层都不重建 布局变化 effect 在动画期间调用 retarget,空闲时才 settle 补充 resourceBookController 用例:重定向不新建动画,结束帧映射到新目标
389 lines
15 KiB
TypeScript
389 lines
15 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>;
|
|
setKeyframes: 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);
|
|
const setKeyframes = vi.fn();
|
|
flights.push({
|
|
finish,
|
|
reject,
|
|
cancel,
|
|
setKeyframes,
|
|
frames: frames as Keyframe[],
|
|
});
|
|
return { finished, cancel, effect: { setKeyframes } } 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)).toBeGreaterThan(
|
|
Number(cardWrapper?.style.zIndex),
|
|
);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('appends clones in the target DOM order so equal z-index cards keep their occlusion', () => {
|
|
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-card"><div class="game-resource-card" data-resource-card-id="a"></div></div>
|
|
<div class="game-resource-book-scene-card"><div class="game-resource-card" data-resource-card-id="b"></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-card',
|
|
)) {
|
|
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect());
|
|
}
|
|
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(root);
|
|
const cardWrapper = root
|
|
.querySelector<HTMLElement>('[data-resource-card-id="a"]')!
|
|
.closest<HTMLElement>('.game-resource-book-scene-card')!;
|
|
cardWrapper.parentElement!.append(cardWrapper);
|
|
controller.play(root, token, () => undefined);
|
|
|
|
const wrappers = Array.from(
|
|
root.querySelectorAll<HTMLElement>('[data-motion-snapshot]'),
|
|
);
|
|
expect(wrappers.map((element) => element.dataset.motionSnapshot)).toEqual([
|
|
'card:b',
|
|
'card:a',
|
|
]);
|
|
// 同层级时靠 DOM 顺序决定遮挡:目标顺序里 b 在前,b 的层级必须更低。
|
|
expect(Number(wrappers[0]?.style.zIndex)).toBeLessThan(
|
|
Number(wrappers[1]?.style.zIndex),
|
|
);
|
|
controller.dispose();
|
|
});
|
|
|
|
it('keeps the source stack order while flying out of the overview', () => {
|
|
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-card"><div class="game-resource-card" data-resource-card-id="a"></div></div>
|
|
<div class="game-resource-book-scene-card"><div class="game-resource-card" data-resource-card-id="b"></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-card',
|
|
)) {
|
|
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect());
|
|
}
|
|
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(root);
|
|
const cardWrapper = root
|
|
.querySelector<HTMLElement>('[data-resource-card-id="a"]')!
|
|
.closest<HTMLElement>('.game-resource-book-scene-card')!;
|
|
cardWrapper.parentElement!.append(cardWrapper);
|
|
controller.play(root, token, () => undefined, 'source');
|
|
|
|
const wrappers = Array.from(
|
|
root.querySelectorAll<HTMLElement>('[data-motion-snapshot]'),
|
|
);
|
|
expect(wrappers.map((element) => element.dataset.motionSnapshot)).toEqual([
|
|
'card:a',
|
|
'card:b',
|
|
]);
|
|
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('retargets a running animation to new geometry without restarting it', () => {
|
|
const { root, measure, flights } = setup();
|
|
const controller = createResourceBookTransitionController();
|
|
const token = controller.begin(root);
|
|
measure.mockReturnValue(rect(150, 100, 200, 160));
|
|
controller.play(root, token, () => undefined);
|
|
expect(flights).toHaveLength(1);
|
|
|
|
measure.mockReturnValue(rect(300, 200, 100, 80));
|
|
controller.retarget(root);
|
|
|
|
expect(flights).toHaveLength(1);
|
|
expect(flights[0]?.setKeyframes).toHaveBeenCalledTimes(1);
|
|
const frames = flights[0]?.setKeyframes.mock.calls[0]?.[0] as Keyframe[];
|
|
expect(frames[1]?.transform).toBe(
|
|
resourceBookFlipTransform(
|
|
rect(300, 200, 100, 80),
|
|
rect(150, 100, 200, 160),
|
|
),
|
|
);
|
|
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();
|
|
});
|
|
});
|