Files
reader/web/test/media-edition-assignment.test.tsx

73 lines
5.4 KiB
TypeScript

import { render } from '../src/shared/ui/render-root.ts';
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest';
import { act } from 'react';
import { EditionAssignment } from '../src/features/media/components/edition-assignment.tsx';
import type { Detail, Edition, MediaApi } from '../src/features/media/api/media-api.ts';
import {ApiError} from '../src/api/errors.ts';
const root=document.createElement('div');document.body.append(root);
const edition:Edition={id:'edition',label:'原版',parts:[{id:'part',assetId:'asset',title:'第一章',start:0,end:20,available:true}]};
const item:Detail={id:'source',libraryId:'library',kind:'audiobook',title:'原作品',parentId:null,metadata:{},overrides:{},children:[],editions:[edition]};
const target:Detail={...item,id:'target',title:'目标作品',editions:[]};
afterEach(()=>act(()=>render(null,root)));
it('retries failed list and target reads without writing ownership',async()=>{
const items=vi.fn().mockRejectedValueOnce(new ApiError('offline','Failed to fetch')).mockResolvedValue({items:[target],total:1});
const detail=vi.fn().mockRejectedValueOnce(new Error('目标读取失败')).mockResolvedValue(target),request=vi.fn();
await act(async()=>render(<EditionAssignment api={{items,detail,request} as unknown as MediaApi} item={item} edition={edition} onAssigned={vi.fn()}/>,root));
await vi.waitFor(()=>expect(root.textContent).toContain('无法连接服务器'));expect(root.textContent).not.toContain('Failed to fetch');
await click('重新读取作品');await click('选择此作品');
await vi.waitFor(()=>expect(root.textContent).toContain('目标读取失败'));
await click('重新读取作品');await vi.waitFor(()=>expect(root.querySelector('[aria-label="确认版本归属"]')).not.toBeNull());
expect(items).toHaveBeenCalledTimes(2);expect(detail).toHaveBeenCalledTimes(2);expect(request).not.toHaveBeenCalled();
});
it('creates a new work only after preview and confirmation',async()=>{
const assigned=vi.fn(),request=vi.fn().mockResolvedValue({...target,editions:[edition]});
const api={items:vi.fn().mockResolvedValue({items:[],total:0}),request} as unknown as MediaApi;
await act(async()=>render(<EditionAssignment api={api} item={item} edition={edition} onAssigned={assigned}/>,root));
await act(async()=>{const input=root.querySelector('input')!;input.value='新作品';input.dispatchEvent(new Event('input',{bubbles:true}));});
await click('预览新建作品');expect(request).not.toHaveBeenCalled();
expect(root.querySelector('[aria-label="确认新建作品"]')?.textContent).toContain('新作品');
await click('取消新建');expect(request).not.toHaveBeenCalled();
await click('预览新建作品');await click('确认新建并关联');
expect(request).toHaveBeenCalledWith('editions/edition/new-item','POST',{title:'新作品',expectedItemId:'source'},expect.any(AbortSignal));
expect(assigned).toHaveBeenCalledWith(expect.objectContaining({id:'target'}));
});
async function click(label:string){
await vi.waitFor(()=>expect([...root.querySelectorAll('button')].find(button=>button.textContent===label)?.disabled).toBe(false));
await act(async()=>{[...root.querySelectorAll('button')].find(button=>button.textContent===label)!.click();});
}
it('previews an existing work and changes ownership only after explicit confirmation, retaining failures for retry',async()=>{
const assigned=vi.fn();
const request=vi.fn().mockRejectedValueOnce(new Error('归属已改变,请刷新')).mockResolvedValue({...target,editions:[edition]});
const api={items:vi.fn().mockResolvedValue({items:[item,target],total:2}),detail:vi.fn().mockResolvedValue(target),request} as unknown as MediaApi;
await act(async()=>render(<EditionAssignment api={api} item={item} edition={edition} onAssigned={assigned}/>,root));
await click('选择此作品');
expect(root.querySelector('[aria-label="确认版本归属"]')?.textContent).toContain('原作品 → 目标作品');
expect(request).not.toHaveBeenCalled();
await click('确认修改归属');
expect(root.querySelector('[role="alert"]')?.textContent).toContain('归属已改变');
expect(assigned).not.toHaveBeenCalled();
await click('确认修改归属');
expect(request).toHaveBeenLastCalledWith('editions/edition/item','PUT',{targetItemId:'target',expectedItemId:'source'},expect.any(AbortSignal));
expect(assigned).toHaveBeenCalledWith(expect.objectContaining({id:'target',editions:[edition]}));
});
it('allows retrying an unchanged search and ignores a detail response after leaving',async()=>{
let release!:(value:Detail)=>void;
const items=vi.fn().mockRejectedValueOnce(new Error('连接中断')).mockResolvedValue({items:[target],total:1});
const detail=vi.fn((_id:string,_signal:AbortSignal)=>new Promise<Detail>(resolve=>{release=resolve;}));
const assigned=vi.fn(),request=vi.fn();
const api={items,detail,request} as unknown as MediaApi;
await act(async()=>render(<EditionAssignment api={api} item={item} edition={edition} onAssigned={assigned}/>,root));
await vi.waitFor(()=>expect(root.textContent).toContain('连接中断'));
await act(async()=>{root.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});
await click('选择此作品');
const signal=detail.mock.calls[0]![1];
act(()=>render(null,root));expect(signal.aborted).toBe(true);
await act(async()=>release(target));
expect(request).not.toHaveBeenCalled();expect(assigned).not.toHaveBeenCalled();
});