2026-02-23 20:29:34 +01:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'
|
2026-03-11 18:15:40 +01:00
|
|
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
2026-02-23 20:29:34 +01:00
|
|
|
import { uploadImageFile, useImageDrop } from './useImageDrop'
|
|
|
|
|
import { createRef } from 'react'
|
|
|
|
|
|
2026-03-11 18:15:40 +01:00
|
|
|
let tauriMode = false
|
|
|
|
|
|
2026-02-23 20:29:34 +01:00
|
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
|
|
|
invoke: vi.fn(),
|
|
|
|
|
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('../mock-tauri', () => ({
|
2026-03-11 18:15:40 +01:00
|
|
|
isTauri: () => tauriMode,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } }
|
|
|
|
|
type DragDropCallback = (event: DragDropEvent) => void
|
|
|
|
|
let capturedDragDropHandler: DragDropCallback | null = null
|
|
|
|
|
|
|
|
|
|
vi.mock('@tauri-apps/api/webview', () => ({
|
|
|
|
|
getCurrentWebview: () => ({
|
|
|
|
|
onDragDropEvent: vi.fn((cb: DragDropCallback) => {
|
|
|
|
|
capturedDragDropHandler = cb
|
|
|
|
|
return Promise.resolve(() => { capturedDragDropHandler = null })
|
|
|
|
|
}),
|
|
|
|
|
}),
|
2026-02-23 20:29:34 +01:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// JSDOM lacks DragEvent and File.arrayBuffer — polyfill for tests
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
if (typeof globalThis.DragEvent === 'undefined') {
|
2026-02-23 22:00:31 +01:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
(globalThis as any).DragEvent = class DragEvent extends MouseEvent {
|
2026-02-23 20:29:34 +01:00
|
|
|
dataTransfer: DataTransfer | null
|
|
|
|
|
constructor(type: string, init?: DragEventInit) {
|
|
|
|
|
super(type, init)
|
|
|
|
|
this.dataTransfer = init?.dataTransfer ?? null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// File.prototype.arrayBuffer may be missing in older JSDOM
|
|
|
|
|
if (!File.prototype.arrayBuffer) {
|
|
|
|
|
File.prototype.arrayBuffer = function () {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
|
|
|
|
reader.readAsArrayBuffer(this)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mock DataTransfer (JSDOM doesn't implement it)
|
|
|
|
|
function createMockDataTransfer(files: File[]) {
|
|
|
|
|
const items = files.map(f => ({ kind: 'file' as const, type: f.type, getAsFile: () => f }))
|
|
|
|
|
return {
|
2026-02-23 22:00:31 +01:00
|
|
|
items: { ...items, length: items.length },
|
2026-02-23 20:29:34 +01:00
|
|
|
files: Object.assign(files, { item: (i: number) => files[i] }),
|
|
|
|
|
dropEffect: 'none',
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
} as any as DataTransfer
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 22:00:31 +01:00
|
|
|
function createDragEvent(type: string, files: File[], opts?: { relatedTarget?: EventTarget | null }) {
|
2026-02-23 20:29:34 +01:00
|
|
|
const dt = createMockDataTransfer(files)
|
|
|
|
|
return new DragEvent(type, {
|
|
|
|
|
dataTransfer: dt,
|
|
|
|
|
bubbles: true,
|
|
|
|
|
cancelable: true,
|
|
|
|
|
relatedTarget: opts?.relatedTarget ?? null,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('uploadImageFile', () => {
|
|
|
|
|
it('returns a data URL in browser mode', async () => {
|
|
|
|
|
const blob = new Blob(['fake-image-data'], { type: 'image/png' })
|
|
|
|
|
const file = new File([blob], 'test.png', { type: 'image/png' })
|
|
|
|
|
|
|
|
|
|
const url = await uploadImageFile(file)
|
|
|
|
|
expect(url).toMatch(/^data:image\/png;base64,/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('passes file to Tauri save_image in Tauri mode', async () => {
|
2026-03-11 18:15:40 +01:00
|
|
|
tauriMode = true
|
2026-02-23 20:29:34 +01:00
|
|
|
|
|
|
|
|
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
|
|
|
|
|
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-test.png')
|
|
|
|
|
vi.mocked(convertFileSrc).mockReturnValue('asset://localhost/vault/attachments/123-test.png')
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([new Uint8Array([0x89, 0x50])], { type: 'image/png' })
|
|
|
|
|
const file = new File([blob], 'test.png', { type: 'image/png' })
|
|
|
|
|
|
|
|
|
|
const url = await uploadImageFile(file, '/vault')
|
|
|
|
|
expect(invoke).toHaveBeenCalledWith('save_image', {
|
|
|
|
|
vaultPath: '/vault',
|
|
|
|
|
filename: 'test.png',
|
|
|
|
|
data: expect.any(String),
|
|
|
|
|
})
|
|
|
|
|
expect(url).toBe('asset://localhost/vault/attachments/123-test.png')
|
|
|
|
|
|
2026-03-11 18:15:40 +01:00
|
|
|
tauriMode = false
|
2026-02-23 20:29:34 +01:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('useImageDrop', () => {
|
|
|
|
|
let container: HTMLDivElement
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
container = document.createElement('div')
|
|
|
|
|
document.body.appendChild(container)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
container.remove()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-28 21:31:47 +01:00
|
|
|
function renderImageDrop(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
|
2026-02-23 20:29:34 +01:00
|
|
|
const ref = createRef<HTMLDivElement>()
|
|
|
|
|
Object.defineProperty(ref, 'current', { value: container, writable: true })
|
2026-02-28 21:31:47 +01:00
|
|
|
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
|
2026-02-23 20:29:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('sets isDragOver to true on dragover with image files', () => {
|
|
|
|
|
const { result } = renderImageDrop()
|
|
|
|
|
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
|
|
|
|
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('ignores dragover with non-image files', () => {
|
|
|
|
|
const { result } = renderImageDrop()
|
|
|
|
|
const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' })
|
|
|
|
|
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('resets isDragOver on dragleave when leaving container', () => {
|
|
|
|
|
const { result } = renderImageDrop()
|
|
|
|
|
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
|
|
|
|
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(true)
|
|
|
|
|
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragleave', [], { relatedTarget: document.body })) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-23 22:00:31 +01:00
|
|
|
it('resets isDragOver on drop (upload handled by BlockNote natively)', () => {
|
2026-02-23 20:29:34 +01:00
|
|
|
const { result } = renderImageDrop()
|
2026-02-23 22:00:31 +01:00
|
|
|
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
2026-02-23 20:29:34 +01:00
|
|
|
|
2026-02-23 22:00:31 +01:00
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(true)
|
2026-02-23 20:29:34 +01:00
|
|
|
|
2026-02-23 22:00:31 +01:00
|
|
|
act(() => { container.dispatchEvent(createDragEvent('drop', [file])) })
|
2026-02-23 20:29:34 +01:00
|
|
|
expect(result.current.isDragOver).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts jpeg, gif, and webp types', () => {
|
|
|
|
|
const { result } = renderImageDrop()
|
|
|
|
|
|
|
|
|
|
for (const type of ['image/jpeg', 'image/gif', 'image/webp']) {
|
|
|
|
|
const file = new File(['data'], `img.${type.split('/')[1]}`, { type })
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(true)
|
|
|
|
|
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragleave', [], { relatedTarget: document.body })) })
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-02-28 21:31:47 +01:00
|
|
|
|
|
|
|
|
it('passes onImageUrl and vaultPath without error', () => {
|
|
|
|
|
const onImageUrl = vi.fn()
|
|
|
|
|
const { result } = renderImageDrop({ onImageUrl, vaultPath: '/vault' })
|
|
|
|
|
// Should render without error; Tauri event listener is skipped in browser mode
|
|
|
|
|
expect(result.current.isDragOver).toBe(false)
|
|
|
|
|
})
|
2026-02-23 20:29:34 +01:00
|
|
|
})
|
2026-03-11 18:15:40 +01:00
|
|
|
|
|
|
|
|
describe('useImageDrop — Tauri native drag-drop', () => {
|
|
|
|
|
let container: HTMLDivElement
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
tauriMode = true
|
|
|
|
|
capturedDragDropHandler = null
|
|
|
|
|
container = document.createElement('div')
|
|
|
|
|
document.body.appendChild(container)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
tauriMode = false
|
|
|
|
|
capturedDragDropHandler = null
|
|
|
|
|
container.remove()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function renderImageDropTauri(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
|
|
|
|
|
const ref = createRef<HTMLDivElement>()
|
|
|
|
|
Object.defineProperty(ref, 'current', { value: container, writable: true })
|
|
|
|
|
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('does not set isDragOver on Tauri over event (internal drags are indistinguishable)', async () => {
|
|
|
|
|
const { result } = renderImageDropTauri()
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedDragDropHandler!({ payload: { type: 'over', paths: [], position: { x: 100, y: 100 } } })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current.isDragOver).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('resets isDragOver on Tauri drop event', async () => {
|
|
|
|
|
const onImageUrl = vi.fn()
|
|
|
|
|
const { result } = renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
|
|
|
|
|
|
|
|
|
// Set isDragOver via HTML5 dragover (simulates real OS file drag)
|
|
|
|
|
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(true)
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedDragDropHandler!({
|
|
|
|
|
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current.isDragOver).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('resets isDragOver on Tauri cancel event', async () => {
|
|
|
|
|
const { result } = renderImageDropTauri()
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
|
|
|
|
|
|
|
|
|
// Set isDragOver via HTML5 dragover first
|
|
|
|
|
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
|
|
|
|
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
|
|
|
|
expect(result.current.isDragOver).toBe(true)
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
capturedDragDropHandler!({ payload: { type: 'cancel', paths: [], position: { x: 0, y: 0 } } })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current.isDragOver).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|