Files
reader/web/test/media-parent-editor.test.tsx

69 lines
5.0 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 {MusicParentEditor} from '../src/features/media/components/parent-editor.tsx';
import {ApiError} from '../src/api/errors.ts';
import type {Detail,MediaApi} from '../src/features/media/api/media-api.ts';
const root=document.createElement('div');document.body.append(root);
afterEach(()=>act(()=>render(null,root)));
const button=(name:string)=>[...root.querySelectorAll('button')].find(value=>value.textContent===name)!;
it('previews and cancels creation without writing, then creates with the expected parent',async()=>{
const item:Detail={id:'track',libraryId:'lib',kind:'track',parentId:'old',title:'曲目',metadata:{},overrides:{},editions:[],children:[]};
const request=vi.fn().mockResolvedValue({...item,parentId:'created'}),updated=vi.fn();
const api={request,items:vi.fn(async()=>({items:[],total:0}))} as unknown as MediaApi;
await act(async()=>render(<MusicParentEditor api={api} item={item} onUpdated={updated}/>,root));
await act(async()=>button('调整所属专辑').click());
const input=root.querySelector<HTMLInputElement>('input[aria-label="新建专辑名称"]')!;
await vi.waitFor(()=>expect(input.disabled).toBe(false));
await act(async()=>{input.value=' 新专辑 ';input.dispatchEvent(new Event('input',{bubbles:true}));});
await act(async()=>button('预览新建专辑').click());
expect(request).not.toHaveBeenCalled();expect(root.textContent).toContain('新专辑沿用原专辑的歌手');
await act(async()=>button('取消新建').click());expect(request).not.toHaveBeenCalled();
await act(async()=>button('预览新建专辑').click());
await act(async()=>button('确认新建并调整归属').click());
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith('items/track/parent','POST',{title:'新专辑',expectedParentId:'old'},expect.any(AbortSignal));
expect(updated).toHaveBeenCalledWith(expect.objectContaining({parentId:'created'}));
});
it('locks creation after a stale-parent conflict instead of replaying it',async()=>{
const item:Detail={id:'album',libraryId:'lib',kind:'album',parentId:'old',title:'专辑',metadata:{},overrides:{},editions:[],children:[]};
const request=vi.fn().mockRejectedValue(new ApiError('conflict','归属变化','MEDIA_PARENT_CHANGED',409));
const api={request,items:vi.fn(async()=>({items:[],total:0}))} as unknown as MediaApi;
await act(async()=>render(<MusicParentEditor api={api} item={item} onUpdated={()=>{}}/>,root));
await act(async()=>button('调整所属歌手').click());
const input=root.querySelector<HTMLInputElement>('input[aria-label="新建歌手名称"]')!;
await vi.waitFor(()=>expect(input.disabled).toBe(false));
await act(async()=>{input.value='新歌手';input.dispatchEvent(new Event('input',{bubbles:true}));});
await act(async()=>button('预览新建歌手').click());
await act(async()=>button('确认新建并调整归属').click());
expect(button('预览新建歌手').disabled).toBe(true);
expect(button('确认新建并调整归属')).toBeUndefined();expect(request).toHaveBeenCalledTimes(1);
});
it('requires a fresh parent after conflict and retries reads without replaying the write',async()=>{
let item:Detail={id:'track',libraryId:'lib',kind:'track',parentId:'old',title:'曲目',metadata:{},overrides:{},editions:[],children:[]};
const target={...item,id:'target',kind:'album',title:'目标专辑',parentId:null};
const request=vi.fn().mockRejectedValueOnce(new ApiError('conflict','归属变化','MEDIA_PARENT_CHANGED',409)).mockResolvedValue({...item,parentId:'target'});
const detail=vi.fn().mockRejectedValueOnce(new Error('刷新失败')).mockResolvedValue({...item,parentId:'new-parent'});
const items=vi.fn(async()=>({items:[target],total:1}));
const api={request,detail,items} as unknown as MediaApi;
const draw=()=>render(<MusicParentEditor api={api} item={item} onUpdated={value=>{item=value;draw();}}/>,root);
await act(async()=>draw());
await act(async()=>button('调整所属专辑').click());
await vi.waitFor(()=>expect(button('选择')?.disabled).toBe(false));
await act(async()=>button('选择').click());
await act(async()=>button('确认调整归属').click());
await vi.waitFor(()=>expect(button('刷新当前归属')).toBeDefined());
expect(button('选择').disabled).toBe(true);
await act(async()=>button('刷新当前归属').click());
await vi.waitFor(()=>expect(root.textContent).toContain('刷新失败'));
expect(request).toHaveBeenCalledTimes(1);expect(button('选择').disabled).toBe(true);
await act(async()=>button('刷新当前归属').click());
await vi.waitFor(()=>expect(button('选择')?.disabled).toBe(false));
expect(request).toHaveBeenCalledTimes(1);
await act(async()=>button('选择').click());
await act(async()=>button('确认调整归属').click());
expect(request).toHaveBeenLastCalledWith('items/track/parent','PUT',{targetParentId:'target',expectedParentId:'new-parent'},expect.any(AbortSignal));
});