kt-blog-web/src/__tests__/BlogLive2D.spec.ts

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();
});
});