342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import { flushPromises, mount } from '@vue/test-utils';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { fetchBlogLive2DManifest } from '@/api/live2dManifest';
|
|
import BlogLive2D from '@/components/blog/BlogLive2D';
|
|
import { mountOfficialPioRuntime } from '@/components/blog/live2d/officialRuntimeBridge';
|
|
import { createBlogLive2DIdleAnimator } from '@/factories/blogAnimationFactory';
|
|
|
|
const pioManifest = {
|
|
character: 'pio',
|
|
desktopOnly: true,
|
|
fallback: null,
|
|
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
motionGroups: {
|
|
FlickHead: 6,
|
|
Idle: 6,
|
|
Sleepy: 1,
|
|
TapBody: 9,
|
|
},
|
|
runtimeScript: '/api/blog/live2d/pio/v1/assets/runtime/pio-runtime.js',
|
|
version: 'v1',
|
|
wordpressParity: {
|
|
hitAreasCustom: {
|
|
body_x: [-0.3, -0.25],
|
|
body_y: [0.3, -0.9],
|
|
head_x: [-0.35, 0.6],
|
|
head_y: [0.19, -0.2],
|
|
},
|
|
layout: {
|
|
center_x: 0,
|
|
center_y: -0.05,
|
|
width: 2,
|
|
},
|
|
motionGroups: {
|
|
FlickHead: {
|
|
legacyName: 'flick_head',
|
|
sourceFiles: ['motions/Touch Dere1.mtn'],
|
|
},
|
|
Idle: {
|
|
legacyName: 'idle',
|
|
sourceFiles: ['motions/Breath1.mtn'],
|
|
},
|
|
Sleepy: {
|
|
legacyName: 'sleepy',
|
|
sourceFiles: ['motions/Sleeping.mtn'],
|
|
},
|
|
TapBody: {
|
|
legacyName: 'tap_body',
|
|
sourceFiles: ['motions/Touch1.mtn'],
|
|
},
|
|
},
|
|
parameterRig: {
|
|
breath: [{ cycle: 3.2345, id: 'ParamBreath', offset: 0.5, peak: 0.5, weight: 1 }],
|
|
look: [{ factorX: 30, factorXY: 0, factorY: 0, id: 'ParamAngleX' }],
|
|
},
|
|
version: '2026-07-05-wordpress-v1',
|
|
},
|
|
} as const;
|
|
|
|
describe('fetchBlogLive2DManifest', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('accepts only the Pio manifest without fallback model chains', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })),
|
|
);
|
|
|
|
const manifest = await fetchBlogLive2DManifest('/manifest.json');
|
|
|
|
expect(manifest.character).toBe('pio');
|
|
expect(manifest.fallback).toBeNull();
|
|
expect(manifest.model3).toBe('/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json');
|
|
});
|
|
|
|
it('rejects non-Pio or fallback manifests before runtime loading', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
...pioManifest,
|
|
character: 'haru',
|
|
fallback: 'https://cdn.example.com/model.json',
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow(
|
|
'Only Pio Live2D manifest is allowed.',
|
|
);
|
|
});
|
|
|
|
it('rejects malformed manifest payloads before runtime loading', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => new Response(JSON.stringify(null), { status: 200 })),
|
|
);
|
|
|
|
await expect(fetchBlogLive2DManifest('/manifest.json')).rejects.toThrow(
|
|
'Only Pio Live2D manifest is allowed.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('mountOfficialPioRuntime', () => {
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
delete window.KtPioLive2D;
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('loads official runtime script once and mounts with the manifest model URL', async () => {
|
|
const destroy = vi.fn();
|
|
const runtime = {
|
|
mount: vi.fn(async () => ({ destroy })),
|
|
};
|
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
|
const element = originalAppendChild(node);
|
|
if (element instanceof HTMLScriptElement) {
|
|
window.KtPioLive2D = runtime;
|
|
element.onload?.(new Event('load'));
|
|
}
|
|
return element;
|
|
});
|
|
|
|
const canvas = document.createElement('canvas');
|
|
await mountOfficialPioRuntime(canvas, pioManifest);
|
|
await mountOfficialPioRuntime(canvas, pioManifest);
|
|
|
|
expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1);
|
|
expect(runtime.mount).toHaveBeenCalledTimes(2);
|
|
expect(runtime.mount).toHaveBeenCalledWith({
|
|
canvas,
|
|
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
wordpressParity: pioManifest.wordpressParity,
|
|
});
|
|
});
|
|
|
|
it('coalesces concurrent runtime script loads before mounting', async () => {
|
|
const runtime = {
|
|
mount: vi.fn(async () => ({ destroy: vi.fn() })),
|
|
};
|
|
const runtimeScripts: HTMLScriptElement[] = [];
|
|
const originalAppendChild = document.body.appendChild.bind(document.body);
|
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
|
const element = originalAppendChild(node);
|
|
if (element instanceof HTMLScriptElement) {
|
|
runtimeScripts.push(element);
|
|
}
|
|
return element;
|
|
});
|
|
|
|
const firstCanvas = document.createElement('canvas');
|
|
const secondCanvas = document.createElement('canvas');
|
|
const firstMount = mountOfficialPioRuntime(firstCanvas, pioManifest);
|
|
const secondMount = mountOfficialPioRuntime(secondCanvas, pioManifest);
|
|
await Promise.resolve();
|
|
|
|
expect(document.querySelectorAll('script#kt-blog-pio-live2d-runtime')).toHaveLength(1);
|
|
|
|
window.KtPioLive2D = runtime;
|
|
expect(runtimeScripts).toHaveLength(1);
|
|
runtimeScripts[0]?.onload?.(new Event('load'));
|
|
|
|
await Promise.all([firstMount, secondMount]);
|
|
|
|
expect(runtime.mount).toHaveBeenCalledTimes(2);
|
|
expect(runtime.mount).toHaveBeenNthCalledWith(1, {
|
|
canvas: firstCanvas,
|
|
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
wordpressParity: pioManifest.wordpressParity,
|
|
});
|
|
expect(runtime.mount).toHaveBeenNthCalledWith(2, {
|
|
canvas: secondCanvas,
|
|
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
wordpressParity: pioManifest.wordpressParity,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('BlogLive2D', () => {
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('does not fetch the manifest below the desktop viewport', async () => {
|
|
vi.stubGlobal('innerWidth', 1199);
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
mount(BlogLive2D);
|
|
await flushPromises();
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(document.querySelector('#live2d')).toBeNull();
|
|
});
|
|
|
|
it('mounts Pio runtime on desktop and destroys it when unmounted', async () => {
|
|
vi.stubGlobal('innerWidth', 1280);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => new Response(JSON.stringify(pioManifest), { status: 200 })),
|
|
);
|
|
const requestFrame = vi.spyOn(window, 'requestAnimationFrame');
|
|
const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame');
|
|
const destroy = vi.fn();
|
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
|
const element = Node.prototype.appendChild.call(document.body, node);
|
|
if (element instanceof HTMLScriptElement) {
|
|
window.KtPioLive2D = {
|
|
mount: vi.fn(async () => ({ destroy })),
|
|
};
|
|
element.onload?.(new Event('load'));
|
|
}
|
|
return element;
|
|
});
|
|
|
|
const wrapper = mount(BlogLive2D);
|
|
await flushPromises();
|
|
|
|
const canvas = wrapper.find('canvas#live2d');
|
|
expect(canvas.exists()).toBe(true);
|
|
expect(window.KtPioLive2D?.mount).toHaveBeenCalledWith({
|
|
canvas: canvas.element,
|
|
model3: '/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
|
|
wordpressParity: pioManifest.wordpressParity,
|
|
});
|
|
|
|
expect((canvas.element as HTMLCanvasElement).style.transform).toBe('');
|
|
|
|
wrapper.unmount();
|
|
|
|
expect(destroy).toHaveBeenCalledTimes(1);
|
|
expect(requestFrame).not.toHaveBeenCalled();
|
|
expect(cancelFrame).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not start canvas transform fallback when WordPress parity owns motion', async () => {
|
|
vi.stubGlobal('innerWidth', 1280);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
...pioManifest,
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
),
|
|
);
|
|
const requestFrame = vi.spyOn(window, 'requestAnimationFrame');
|
|
vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
|
const element = Node.prototype.appendChild.call(document.body, node);
|
|
if (element instanceof HTMLScriptElement) {
|
|
window.KtPioLive2D = {
|
|
mount: vi.fn(async () => ({ destroy: vi.fn() })),
|
|
};
|
|
element.onload?.(new Event('load'));
|
|
}
|
|
return element;
|
|
});
|
|
|
|
const wrapper = mount(BlogLive2D);
|
|
await flushPromises();
|
|
|
|
const canvas = wrapper.find('canvas#live2d');
|
|
expect(canvas.exists()).toBe(true);
|
|
const canvasElement = canvas.element as HTMLCanvasElement;
|
|
vi.spyOn(canvasElement, 'getBoundingClientRect').mockReturnValue({
|
|
bottom: 420,
|
|
height: 320,
|
|
left: 100,
|
|
right: 320,
|
|
toJSON: () => ({}),
|
|
top: 100,
|
|
width: 220,
|
|
x: 100,
|
|
y: 100,
|
|
} as DOMRect);
|
|
|
|
expect(canvasElement.style.transform).toBe('');
|
|
expect(requestFrame).not.toHaveBeenCalled();
|
|
|
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }));
|
|
|
|
expect(canvasElement.style.transform).toBe('');
|
|
|
|
canvasElement.dispatchEvent(new MouseEvent('pointermove', { clientX: 210, clientY: 260 }));
|
|
|
|
expect(canvasElement.style.transform).toBe('');
|
|
|
|
canvasElement.dispatchEvent(new MouseEvent('pointerleave'));
|
|
|
|
expect(canvasElement.style.transform).toBe('');
|
|
|
|
wrapper.unmount();
|
|
});
|
|
});
|
|
|
|
describe('createBlogLive2DIdleAnimator', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('keeps the Pio canvas visibly moving between animation frames', () => {
|
|
const frames: FrameRequestCallback[] = [];
|
|
const cancelFrame = vi.fn();
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
|
frames.push(callback);
|
|
return frames.length;
|
|
});
|
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(cancelFrame);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.style.transform = 'translateX(1px)';
|
|
|
|
const handle = createBlogLive2DIdleAnimator(canvas);
|
|
frames.shift()?.(1000);
|
|
const firstTransform = canvas.style.transform;
|
|
frames.shift()?.(2200);
|
|
const secondTransform = canvas.style.transform;
|
|
|
|
expect(firstTransform).not.toBe('translateX(1px)');
|
|
expect(secondTransform).not.toBe(firstTransform);
|
|
|
|
handle.destroy();
|
|
|
|
expect(canvas.style.transform).toBe('translateX(1px)');
|
|
expect(cancelFrame).toHaveBeenCalled();
|
|
});
|
|
});
|