接入汪汪声浪结果页原生图片导入
汪汪声浪结果页三图槽位上传优先复用file.importImage宿主能力 补充原生导入取消和浏览器回退测试 将汪汪声浪结果页纳入原生壳H5调用链门禁 更新宿主壳协议、方案文档和共享记忆
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user