接入汪汪声浪结果页原生图片导入

汪汪声浪结果页三图槽位上传优先复用file.importImage宿主能力

补充原生导入取消和浏览器回退测试

将汪汪声浪结果页纳入原生壳H5调用链门禁

更新宿主壳协议、方案文档和共享记忆
This commit is contained in:
2026-06-19 10:13:26 +08:00
parent bda650e598
commit 4a16263729
7 changed files with 244 additions and 10 deletions
@@ -2,12 +2,16 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { BarkBattleResultView } from './BarkBattleResultView';
vi.mock('../../services/bark-battle-creation', () => ({
@@ -15,6 +19,11 @@ vi.mock('../../services/bark-battle-creation', () => ({
uploadBarkBattleAsset: vi.fn(),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(),
importHostImageFile: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
@@ -41,6 +50,13 @@ const draft = {
};
describe('BarkBattleResultView', () => {
beforeEach(() => {
vi.mocked(uploadBarkBattleAsset).mockReset();
vi.mocked(regenerateBarkBattleImageAsset).mockReset();
vi.mocked(canUseNativeHostCapability).mockReset();
vi.mocked(importHostImageFile).mockReset();
});
it('exposes draft preview actions before publish', async () => {
const user = userEvent.setup();
const onStartTestRun = vi.fn();
@@ -168,6 +184,153 @@ describe('BarkBattleResultView', () => {
);
});
it('imports replacement image assets through native HostBridge', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const onDraftChange = vi.fn();
vi.mocked(canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(importHostImageFile).mockResolvedValue({
action: 'selected',
fileName: '玩家形象.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
});
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
assetObjectId: 'asset-player-1',
assetKind: 'bark_battle_player_character_image',
objectKey: 'generated-bark-battle-assets/player.png',
assetSrc: '/generated-bark-battle-assets/player.png',
});
try {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', {
name: '上传',
}),
);
await waitFor(() => {
expect(uploadBarkBattleAsset).toHaveBeenCalledWith(
expect.objectContaining({
slot: 'player-character',
draftId: 'bark-battle-draft-1',
file: expect.any(File),
}),
);
});
const uploadedFile = vi.mocked(uploadBarkBattleAsset).mock.calls[0]?.[0]
.file;
expect(importHostImageFile).toHaveBeenCalledTimes(1);
expect(uploadedFile?.name).toBe('玩家形象.png');
expect(uploadedFile?.type).toBe('image/png');
expect(uploadedFile?.size).toBe(5);
expect(inputClickSpy).not.toHaveBeenCalled();
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
} finally {
inputClickSpy.mockRestore();
}
});
it('keeps native image cancellation inside shell flow', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.mocked(canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(importHostImageFile).mockResolvedValue(false);
try {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', {
name: '上传',
}),
);
await waitFor(() => {
expect(importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(uploadBarkBattleAsset).not.toHaveBeenCalled();
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
it('falls back to browser picker without native image capability', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
try {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', {
name: '上传',
}),
);
expect(inputClickSpy).toHaveBeenCalledTimes(1);
expect(importHostImageFile).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
it('does not render the raw object key or asset path in the slot summary', () => {
render(
<BarkBattleResultView
@@ -24,6 +24,10 @@ import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -103,6 +107,20 @@ function getSlotAssetSrc(
return '';
}
function base64ImageToFile(
base64Data: string,
fileName: string,
mimeType: string,
) {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mimeType });
}
function ResultActionButton({
children,
disabled,
@@ -140,15 +158,14 @@ function BarkBattleAssetSlotControl({
onError: (message: string | null) => void;
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isImportingImage, setIsImportingImage] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot);
const assetStatus = assetSrc ? '已替换' : '未替换';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
const uploadFile = async (file: File) => {
if (isUploading || isRegenerating) {
return;
}
@@ -169,6 +186,49 @@ function BarkBattleAssetSlotControl({
}
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
await uploadFile(file);
};
const openUploadPicker = () => {
if (disabled || isImportingImage || isUploading || isRegenerating) {
return;
}
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
setIsImportingImage(true);
try {
const importedImage = await importHostImageFile();
if (!importedImage) {
return;
}
await uploadFile(
base64ImageToFile(
importedImage.base64Data,
importedImage.fileName,
importedImage.mimeType,
),
);
} finally {
setIsImportingImage(false);
}
})().catch((error) => {
onError(error instanceof Error ? error.message : '上传素材失败。');
});
return;
}
fileInputRef.current?.click();
};
const handleRegenerate = async () => {
setIsRegenerating(true);
onError(null);
@@ -187,7 +247,7 @@ function BarkBattleAssetSlotControl({
}
};
const isSlotBusy = isUploading || isRegenerating;
const isSlotBusy = isImportingImage || isUploading || isRegenerating;
return (
<PlatformSubpanel
@@ -218,12 +278,13 @@ function BarkBattleAssetSlotControl({
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
disabled={disabled || isSlotBusy}
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
/>
<PlatformActionButton
disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()}
onClick={openUploadPicker}
tone="secondary"
size="xs"
shape="pill"