方洞结果页接入宿主图片导入
方洞结果页图片槽位在原生壳内优先调用 file.importImage 保留浏览器文件输入回退并覆盖原生导入、回退和取消选择测试 同步方洞玩法链路、HostBridge 方案和团队决策记录
This commit is contained in:
@@ -8,9 +8,13 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
importHostImageFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { SquareHoleResultView } from './SquareHoleResultView';
|
||||
|
||||
vi.mock('../../services/square-hole-works', () => ({
|
||||
@@ -22,6 +26,19 @@ vi.mock('../../services/square-hole-works', () => ({
|
||||
updateSquareHoleWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||
canUseNativeHostCapability: vi.fn(() => false),
|
||||
importHostImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
|
||||
const importHostImageFileMock = vi.mocked(importHostImageFile);
|
||||
|
||||
beforeEach(() => {
|
||||
canUseNativeHostCapabilityMock.mockReturnValue(false);
|
||||
importHostImageFileMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
@@ -308,6 +325,121 @@ test('square hole image dialog reuses platform media frame', async () => {
|
||||
expect(mediaFrame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
|
||||
});
|
||||
|
||||
test('square hole image dialog imports slot images from native host capability', async () => {
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
const { squareHoleAssetClient, updateSquareHoleWork } = await import(
|
||||
'../../services/square-hole-works'
|
||||
);
|
||||
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
|
||||
vi.mocked(updateSquareHoleWork).mockResolvedValue({
|
||||
item: createProfile(),
|
||||
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
|
||||
canUseNativeHostCapabilityMock.mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
importHostImageFileMock.mockResolvedValue({
|
||||
action: 'selected',
|
||||
fileName: '方洞封面.png',
|
||||
mimeType: 'image/png',
|
||||
base64Data: 'bmF0aXZlLWNvdmVy',
|
||||
bytes: 12,
|
||||
});
|
||||
|
||||
try {
|
||||
render(
|
||||
<SquareHoleResultView
|
||||
profile={createProfile()}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看封面图' }));
|
||||
expect(screen.getByRole('dialog', { name: '封面图查看' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传图片' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSquareHoleWork).toHaveBeenCalledWith(
|
||||
'profile-1',
|
||||
expect.objectContaining({
|
||||
coverImageSrc: 'data:image/png;base64,bmF0aXZlLWNvdmVy',
|
||||
}),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('square hole image dialog keeps browser file input fallback when native import is unavailable', async () => {
|
||||
const user = userEvent.setup();
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
const { squareHoleAssetClient } = await import(
|
||||
'../../services/square-hole-works'
|
||||
);
|
||||
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
|
||||
|
||||
try {
|
||||
render(
|
||||
<SquareHoleResultView
|
||||
profile={createProfile()}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '查看封面图' }));
|
||||
await screen.findByRole('dialog', { name: '封面图查看' });
|
||||
await user.click(screen.getByRole('button', { name: '上传图片' }));
|
||||
|
||||
expect(importHostImageFileMock).not.toHaveBeenCalled();
|
||||
expect(inputClickSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('square hole image dialog leaves slot image unchanged when native import is cancelled', async () => {
|
||||
const { squareHoleAssetClient, updateSquareHoleWork } = await import(
|
||||
'../../services/square-hole-works'
|
||||
);
|
||||
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
|
||||
vi.mocked(updateSquareHoleWork).mockResolvedValue({
|
||||
item: createProfile(),
|
||||
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
|
||||
canUseNativeHostCapabilityMock.mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
importHostImageFileMock.mockResolvedValue(false);
|
||||
|
||||
render(
|
||||
<SquareHoleResultView
|
||||
profile={createProfile()}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看封面图' }));
|
||||
expect(screen.getByRole('dialog', { name: '封面图查看' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传图片' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(updateSquareHoleWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('square hole cover and background buttons use platform media frames', () => {
|
||||
render(
|
||||
<SquareHoleResultView
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
Send,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type {
|
||||
@@ -26,6 +32,11 @@ import {
|
||||
type SquareHoleImageAssetKind,
|
||||
updateSquareHoleWork,
|
||||
} from '../../services/square-hole-works';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
type HostFileImportImageResult,
|
||||
importHostImageFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
|
||||
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
|
||||
@@ -305,6 +316,10 @@ function readImageAsDataUrl(file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
function hostImageImportResultToDataUrl(result: HostFileImportImageResult) {
|
||||
return `data:${result.mimeType};base64,${result.base64Data}`;
|
||||
}
|
||||
|
||||
function createShapeOption(
|
||||
index: number,
|
||||
holeOptions: SquareHoleHoleOption[],
|
||||
@@ -484,6 +499,7 @@ function SquareHoleImageSlotDialog({
|
||||
onClose,
|
||||
onRegenerateImages,
|
||||
onSelectHistory,
|
||||
onHostUpload,
|
||||
onUpload,
|
||||
}: {
|
||||
currentImageSrc: string;
|
||||
@@ -494,14 +510,17 @@ function SquareHoleImageSlotDialog({
|
||||
onClose: () => void;
|
||||
onRegenerateImages: () => void;
|
||||
onSelectHistory: (asset: SquareHoleHistoryAsset) => void;
|
||||
onHostUpload: (slot: SquareHoleImageSlot) => void;
|
||||
onUpload: (
|
||||
slot: SquareHoleImageSlot,
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => void;
|
||||
}) {
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [assets, setAssets] = useState<SquareHoleHistoryAsset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canImportHostImage = canUseNativeHostCapability('file.importImage');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -534,6 +553,17 @@ function SquareHoleImageSlotDialog({
|
||||
};
|
||||
}, [slot.assetKind]);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
if (canImportHostImage) {
|
||||
onHostUpload(slot);
|
||||
return;
|
||||
}
|
||||
uploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<PlatformToolModalShell
|
||||
open
|
||||
@@ -559,22 +589,23 @@ function SquareHoleImageSlotDialog({
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<PlatformActionButton
|
||||
asChild="label"
|
||||
onClick={handleUploadClick}
|
||||
disabled={isBusy}
|
||||
tone="ghost"
|
||||
size="md"
|
||||
aria-disabled={isBusy}
|
||||
className={`min-h-11 cursor-pointer gap-2 px-4 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className="min-h-11 gap-2 px-4"
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
上传图片
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => onUpload(slot, event)}
|
||||
/>
|
||||
</PlatformActionButton>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => onUpload(slot, event)}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
onClick={onRegenerateImages}
|
||||
disabled={!canRegenerateImages || isBusy}
|
||||
@@ -767,6 +798,37 @@ export function SquareHoleResultView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSlotHostUpload = async (slot: SquareHoleImageSlot) => {
|
||||
if (
|
||||
isBusy ||
|
||||
isApplyingHistoryImage ||
|
||||
isPublishing ||
|
||||
isRegeneratingImages ||
|
||||
isStartingTestRun
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const importedImage = await importHostImageFile();
|
||||
if (!importedImage) {
|
||||
return;
|
||||
}
|
||||
setEditState((current) =>
|
||||
applyImageSlotSrc(
|
||||
current,
|
||||
slot,
|
||||
hostImageImportResultToDataUrl(importedImage),
|
||||
),
|
||||
);
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '图片读取失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTestRun = async () => {
|
||||
if (!canSubmit || isStartingTestRun) {
|
||||
setLocalError(blockers[0] ?? null);
|
||||
@@ -1420,6 +1482,9 @@ export function SquareHoleResultView({
|
||||
onSelectHistory={(asset) => {
|
||||
void handleSelectHistoryImage(activeImageSlot, asset);
|
||||
}}
|
||||
onHostUpload={(slot) => {
|
||||
void handleImageSlotHostUpload(slot);
|
||||
}}
|
||||
onUpload={(slot, event) => {
|
||||
void handleImageSlotUpload(slot, event);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user