4a5b2ad524
- begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First - 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点 - 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置 - 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出 - 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出 - 同步技术方案转场段与 pitfalls 经验条目
595 lines
23 KiB
TypeScript
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();
|
|
});
|
|
});
|