import { act, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import { buildPostImportRow } from '@/test/postImportFactories' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' const api = vi.hoisted (() => ({ apiGet: vi.fn (), })) vi.mock ('@/lib/api', () => api) describe ('PostImportRowForm', () => { beforeEach (() => { vi.clearAllMocks () globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') globalThis.URL.revokeObjectURL = vi.fn () api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' })) }) it ('resets only the draft, then saves with resetRequested', async () => { const row = buildPostImportRow () row.attributes.title = 'manual title' row.provenance.title = 'manual' const actions: DialogueFormAction[][] = [] const controls: DialogueFormControls = { close: vi.fn (), confirm: vi.fn ().mockResolvedValue (true), setActions: next => actions.push (next) } const invalidRow = buildPostImportRow ({ validationErrors: { title: ['タイトルを確認してください.'] } }) const onSave = vi.fn ().mockResolvedValue ({ saved: false, row: invalidRow }) render () const titleInput = screen.getByDisplayValue ('manual title') await waitFor (() => expect (actions.at (-1)?.length).toBe (2)) const reset = actions.at (-1)?.find (action => action.label === '変更をリセット') expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false }) await act (async () => { await reset?.onSelect () }) expect (controls.confirm).toHaveBeenCalled () expect (titleInput).toHaveValue ('') expect (onSave).not.toHaveBeenCalled () const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存') await act (async () => { await save?.onSelect () }) expect (onSave).toHaveBeenCalledWith ({ draft: expect.objectContaining ({ title: '' }), resetRequested: true }) expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument () }) it ('does not reset the draft when confirmation is cancelled', async () => { const row = buildPostImportRow ({ attributes: { title: 'manual title' } }) let actions: DialogueFormAction[] = [] const controls: DialogueFormControls = { close: vi.fn (), confirm: vi.fn ().mockResolvedValue (false), setActions: next => { actions = next } } render ( ) await waitFor (() => expect (actions.length).toBe (2)) await act (async () => { await actions.find (action => action.label === '変更をリセット')?.onSelect () }) expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument () }) it ('marks edited fields and areas invalid from field errors', () => { const row = buildPostImportRow ({ validationErrors: { url: ['URL error'], tags: ['tag error'] }, importErrors: { title: ['title error'] }, fieldWarnings: { title: ['title warning'] } }) render ( ) expect (screen.getByText ('URL error')).toBeInTheDocument () expect (screen.getByText ('tag error')).toBeInTheDocument () expect (screen.getByText ('title error')).toBeInTheDocument () expect (screen.getByText ('title warning')).toBeInTheDocument () expect (screen.getAllByRole ('textbox').filter ( textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3) }) it ('keeps untouched original created values unchanged in the save payload', async () => { let actions: DialogueFormAction[] = [] const row = buildPostImportRow ({ attributes: { originalCreatedFrom: '2024-01-01T12:34+09:00', originalCreatedBefore: '2024-01-01T12:35+09:00' } }) const controls: DialogueFormControls = { close: vi.fn (), confirm: vi.fn (), setActions: next => { actions = next } } const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null }) render ( ) await waitFor (() => expect (actions.length).toBe (2)) await act (async () => { void actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) expect (onSave).toHaveBeenCalledWith ({ draft: expect.objectContaining ({ originalCreatedFrom: '2024-01-01T12:34+09:00', originalCreatedBefore: '2024-01-01T12:35+09:00' }), resetRequested: false }) }) it ( 'shows upload input only when the thumbnail URL is blank', async () => { let actions: DialogueFormAction[] = [] const controls: DialogueFormControls = { close: vi.fn (), confirm: vi.fn (), setActions: next => { actions = next } } const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null }) const { container } = render ( ) await waitFor (() => expect (actions.length).toBe (2)) const labels = Array.from (container.querySelectorAll ('label')) .map (node => node.textContent?.trim ()) expect (labels.slice (0, 6)).toEqual ([ 'URL', 'サムネール', 'タイトル', 'オリジナルの作成日時', 'タグ', '親投稿']) expect (container.querySelector ('input[type="file"]')).toHaveAttribute ( 'accept', 'image/*') expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument () expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () await act (async () => { await actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) expect (onSave).toHaveBeenCalledWith ({ draft: expect.objectContaining ({ tags: 'tag1' }), resetRequested: false }) }) it ('keeps reset enabled when the value matches but provenance still differs', async () => { const row = buildPostImportRow ({ attributes: { title: 'same title' }, provenance: { title: 'manual' }, resetSnapshot: { url: 'https://example.com/post', attributes: { title: 'same title', thumbnailBase: '', originalCreatedFrom: '', originalCreatedBefore: '', tags: '', parentPostIds: '' }, provenance: { url: 'manual', title: 'automatic', thumbnailBase: 'automatic', originalCreatedFrom: 'automatic', originalCreatedBefore: 'automatic', tags: 'automatic', parentPostIds: 'automatic' }, tagSources: { automatic: '', manual: '' }, fieldWarnings: { }, baseWarnings: [] } }) let actions: DialogueFormAction[] = [] render ( { actions = next } }} onSave={vi.fn ()}/>) await waitFor (() => expect (actions.length).toBe (2)) expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false) }) it ( 'disables every field while save validation is pending and re-enables them afterwards', async () => { let actions: DialogueFormAction[] = [] let resolveSave: ((value: { saved: boolean row: ReturnType | null }) => void) | null = null const onSave = vi.fn (() => new Promise<{ saved: boolean row: ReturnType | null }> (resolve => { resolveSave = resolve })) render ( { actions = next } }} onSave={onSave}/>) await waitFor (() => expect (actions.length).toBe (2)) let savePromise: Promise | undefined await act (async () => { savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) await waitFor (() => { screen.getAllByRole ('textbox').forEach (textbox => { expect (textbox).toBeDisabled () }) }) resolveSave?.({ saved: false, row: buildPostImportRow ({ attributes: { title: 'draft title' }, validationErrors: { title: ['タイトルを確認してください.'] } }) }) await act (async () => { await savePromise }) await waitFor (() => { screen.getAllByRole ('textbox').forEach (textbox => { expect (textbox).not.toBeDisabled () }) }) expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument () expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument () }) })