feat: add edutainment drawing and visual package flows
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { BabyLoveDrawingRuntimeShell } from './BabyLoveDrawingRuntimeShell';
|
||||
|
||||
const saveBabyLoveDrawingMock = vi.fn();
|
||||
const createBabyLoveDrawingMagicImageMock = vi.fn();
|
||||
|
||||
const mocapMock = vi.hoisted(() => ({
|
||||
command: null as null | {
|
||||
actions: string[];
|
||||
leftHand?: {
|
||||
x: number;
|
||||
y: number;
|
||||
state: 'open_palm' | 'grab' | 'unknown';
|
||||
side: 'left';
|
||||
} | null;
|
||||
rightHand?: {
|
||||
x: number;
|
||||
y: number;
|
||||
state: 'open_palm' | 'grab' | 'unknown';
|
||||
side: 'right';
|
||||
} | null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/useMocapInput', () => ({
|
||||
useMocapInput: () => ({
|
||||
status: 'idle',
|
||||
latestCommand: mocapMock.command,
|
||||
rawPacketPreview: null,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/edutainment-baby-drawing', () => ({
|
||||
createBabyLoveDrawingMagicImage: (...args: unknown[]) =>
|
||||
createBabyLoveDrawingMagicImageMock(...args),
|
||||
saveBabyLoveDrawing: (...args: unknown[]) => saveBabyLoveDrawingMock(...args),
|
||||
}));
|
||||
|
||||
function installCanvasMock() {
|
||||
const context = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
set fillStyle(_value: string) {},
|
||||
set strokeStyle(_value: string) {},
|
||||
set lineWidth(_value: number) {},
|
||||
set lineCap(_value: CanvasLineCap) {},
|
||||
set lineJoin(_value: CanvasLineJoin) {},
|
||||
set globalCompositeOperation(_value: GlobalCompositeOperation) {},
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
|
||||
'data:image/png;base64,original',
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installCanvasMock();
|
||||
mocapMock.command = null;
|
||||
saveBabyLoveDrawingMock.mockReturnValue({
|
||||
record: {
|
||||
drawingId: 'baby-love-drawing-local-1',
|
||||
templateId: 'baby-love-drawing',
|
||||
templateName: '宝贝爱画',
|
||||
originalImageSrc: 'data:image/png;base64,original',
|
||||
magicImageSrc: null,
|
||||
strokeTrace: [],
|
||||
saveMode: 'original-only',
|
||||
themeTags: ['寓教于乐', '宝贝爱画'],
|
||||
createdAt: '2026-05-13T08:00:00.000Z',
|
||||
updatedAt: '2026-05-13T08:00:00.000Z',
|
||||
},
|
||||
});
|
||||
createBabyLoveDrawingMagicImageMock.mockResolvedValue({
|
||||
magicImageSrc: 'data:image/png;base64,magic',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '绘本风格',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders drawing board, seven colors and tool buttons', () => {
|
||||
render(<BabyLoveDrawingRuntimeShell />);
|
||||
|
||||
expect(screen.getByTestId('baby-love-drawing-runtime')).toBeTruthy();
|
||||
expect(screen.getByLabelText('画板')).toBeTruthy();
|
||||
expect(screen.getByLabelText('红')).toBeTruthy();
|
||||
expect(screen.getByLabelText('紫')).toBeTruthy();
|
||||
expect(screen.getAllByRole('button')).toHaveLength(11);
|
||||
expect(screen.getByLabelText('画笔')).toBeTruthy();
|
||||
expect(screen.getByLabelText('橡皮')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('finish then save stores original drawing in local demo service', () => {
|
||||
render(<BabyLoveDrawingRuntimeShell />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(saveBabyLoveDrawingMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
originalImageSrc: 'data:image/png;base64,original',
|
||||
magicImageSrc: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText('已保存')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('back button calls onBack callback', () => {
|
||||
const onBack = vi.fn();
|
||||
render(<BabyLoveDrawingRuntimeShell onBack={onBack} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('返回'));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('mocap camera-left hand drives the player right hand brush cursor', () => {
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
};
|
||||
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
|
||||
const cursor = container.querySelector(
|
||||
'.baby-love-drawing-runtime__cursor',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(cursor.style.left).toBe('72%');
|
||||
expect(cursor.style.top).toBe('34%');
|
||||
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.18, y: 0.82, state: 'grab', side: 'right' },
|
||||
};
|
||||
rerender(<BabyLoveDrawingRuntimeShell />);
|
||||
|
||||
expect(cursor.style.left).toBe('72%');
|
||||
expect(cursor.style.top).toBe('34%');
|
||||
});
|
||||
|
||||
test('mocap camera-right hand renders the player left hand color indicator', () => {
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
|
||||
};
|
||||
const { container } = render(<BabyLoveDrawingRuntimeShell />);
|
||||
const indicator = container.querySelector(
|
||||
'.baby-love-drawing-runtime__left-hand-indicator',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(indicator).toBeTruthy();
|
||||
expect(indicator.style.left).toBe('16%');
|
||||
expect(indicator.style.top).toBe('42%');
|
||||
});
|
||||
|
||||
test('left hand indicator stays visible through brief mocap hand loss', () => {
|
||||
vi.useFakeTimers();
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
|
||||
};
|
||||
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
|
||||
|
||||
vi.advanceTimersByTime(120);
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
};
|
||||
rerender(<BabyLoveDrawingRuntimeShell />);
|
||||
|
||||
const indicator = container.querySelector(
|
||||
'.baby-love-drawing-runtime__left-hand-indicator',
|
||||
) as HTMLElement;
|
||||
expect(indicator).toBeTruthy();
|
||||
expect(indicator.style.left).toBe('16%');
|
||||
expect(indicator.style.top).toBe('42%');
|
||||
});
|
||||
|
||||
test('player left hand never takes over the right hand brush cursor', () => {
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: { x: 0.68, y: 0.32, state: 'open_palm', side: 'left' },
|
||||
rightHand: { x: 0.18, y: 0.78, state: 'open_palm', side: 'right' },
|
||||
};
|
||||
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
|
||||
const cursor = container.querySelector(
|
||||
'.baby-love-drawing-runtime__cursor',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(cursor.style.left).toBe('68%');
|
||||
expect(cursor.style.top).toBe('32%');
|
||||
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.7, y: 0.3, state: 'grab', side: 'right' },
|
||||
};
|
||||
rerender(<BabyLoveDrawingRuntimeShell />);
|
||||
|
||||
expect(cursor.style.left).toBe('68%');
|
||||
expect(cursor.style.top).toBe('32%');
|
||||
});
|
||||
|
||||
test('large camera-left jump is rejected to prevent left hand stealing brush', () => {
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
};
|
||||
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
|
||||
const cursor = container.querySelector(
|
||||
'.baby-love-drawing-runtime__cursor',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(cursor.style.left).toBe('72%');
|
||||
expect(cursor.style.top).toBe('34%');
|
||||
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
leftHand: { x: 0.16, y: 0.82, state: 'grab', side: 'left' },
|
||||
rightHand: null,
|
||||
};
|
||||
rerender(<BabyLoveDrawingRuntimeShell />);
|
||||
|
||||
expect(cursor.style.left).toBe('72%');
|
||||
expect(cursor.style.top).toBe('34%');
|
||||
});
|
||||
Reference in New Issue
Block a user