import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { EditableValue, TagPillList, UrlValue } from './EditableValue' import { isUrlValue, normalizeUrl } from '../utils/url' vi.mock('../utils/url', async () => { const actual = await vi.importActual('../utils/url') return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) } }) const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & { openExternalUrl: ReturnType } describe('EditableValue', () => { const onSave = vi.fn() const onCancel = vi.fn() const onStartEdit = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('displays value text in view mode', () => { render() expect(screen.getByText('Active')).toBeInTheDocument() }) it('displays em-dash when value is empty', () => { render() expect(screen.getByText('\u2014')).toBeInTheDocument() }) it('calls onStartEdit when clicked in view mode', () => { render() fireEvent.click(screen.getByText('Active')) expect(onStartEdit).toHaveBeenCalled() }) it('uses a full-width truncating layout in view mode', () => { render() const value = screen.getByText('A very long property value') expect(value.parentElement).toHaveClass('w-full', 'overflow-hidden') expect(value).toHaveClass('truncate') }) it('left-aligns plain text values in view mode', () => { render() expect(screen.getByText('Active').parentElement).toHaveClass('justify-start', 'text-left') }) it('shows input in editing mode', () => { render() const input = screen.getByDisplayValue('Active') expect(input).toBeInTheDocument() expect(input.tagName).toBe('INPUT') }) it('calls onSave when Enter is pressed', () => { render() const input = screen.getByDisplayValue('Active') fireEvent.change(input, { target: { value: 'Done' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onSave).toHaveBeenCalledWith('Done') }) it('calls onCancel and resets when Escape is pressed', () => { render() const input = screen.getByDisplayValue('Active') fireEvent.change(input, { target: { value: 'Changed' } }) fireEvent.keyDown(input, { key: 'Escape' }) expect(onCancel).toHaveBeenCalled() }) it('calls onSave on blur', () => { render() const input = screen.getByDisplayValue('Active') fireEvent.change(input, { target: { value: 'Paused' } }) fireEvent.blur(input) expect(onSave).toHaveBeenCalledWith('Paused') }) }) describe('TagPillList', () => { const onSave = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('renders all items as pills', () => { render() expect(screen.getByText('Tag1')).toBeInTheDocument() expect(screen.getByText('Tag2')).toBeInTheDocument() expect(screen.getByText('Tag3')).toBeInTheDocument() }) it('shows add button with + text', () => { render() expect(screen.getByTitle('Add tags')).toBeInTheDocument() }) it('shows add input when + button is clicked', () => { render() fireEvent.click(screen.getByTitle('Add tags')) expect(screen.getByPlaceholderText('Tags...')).toBeInTheDocument() }) it('adds new item on Enter in add input', () => { render() fireEvent.click(screen.getByTitle('Add tags')) const input = screen.getByPlaceholderText('Tags...') fireEvent.change(input, { target: { value: 'NewTag' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onSave).toHaveBeenCalledWith(['A', 'NewTag']) }) it('cancels add on Escape', () => { render() fireEvent.click(screen.getByTitle('Add tags')) const input = screen.getByPlaceholderText('Tags...') fireEvent.keyDown(input, { key: 'Escape' }) expect(onSave).not.toHaveBeenCalled() // Add button should reappear expect(screen.getByTitle('Add tags')).toBeInTheDocument() }) it('deletes item when X button is clicked', () => { render() const removeButtons = screen.getAllByTitle('Remove') fireEvent.click(removeButtons[1]) // Remove B expect(onSave).toHaveBeenCalledWith(['A', 'C']) }) it('enters edit mode when clicking a pill', () => { render() fireEvent.click(screen.getByText('Hello')) expect(screen.getByDisplayValue('Hello')).toBeInTheDocument() }) it('saves edit on Enter', () => { render() fireEvent.click(screen.getByText('Old')) const input = screen.getByDisplayValue('Old') fireEvent.change(input, { target: { value: 'New' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onSave).toHaveBeenCalledWith(['New']) }) it('removes item when edit value is cleared', () => { render() fireEvent.click(screen.getByText('Remove')) const input = screen.getByDisplayValue('Remove') fireEvent.change(input, { target: { value: '' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onSave).toHaveBeenCalledWith(['Keep']) }) it('cancels edit on Escape', () => { render() fireEvent.click(screen.getByText('Stay')) const input = screen.getByDisplayValue('Stay') fireEvent.keyDown(input, { key: 'Escape' }) expect(onSave).not.toHaveBeenCalled() // Pill should reappear expect(screen.getByText('Stay')).toBeInTheDocument() }) it('adds item on blur when input has value', () => { render() fireEvent.click(screen.getByTitle('Add tags')) const input = screen.getByPlaceholderText('Tags...') fireEvent.change(input, { target: { value: 'BlurAdd' } }) fireEvent.blur(input) expect(onSave).toHaveBeenCalledWith(['BlurAdd']) }) it('cancels add on blur when input is empty', () => { render() fireEvent.click(screen.getByTitle('Add tags')) const input = screen.getByPlaceholderText('Tags...') fireEvent.blur(input) expect(onSave).not.toHaveBeenCalled() }) it('truncates long pill labels independently', () => { render() expect(screen.getByText('A very long property list value')).toHaveClass('truncate') }) }) describe('isUrlValue', () => { it('detects http URLs', () => { expect(isUrlValue('http://example.com')).toBe(true) }) it('detects https URLs', () => { expect(isUrlValue('https://example.com/path?q=1')).toBe(true) }) it('detects bare domain names', () => { expect(isUrlValue('example.com')).toBe(true) expect(isUrlValue('docs.google.com/spreadsheets')).toBe(true) }) it('rejects plain text', () => { expect(isUrlValue('just some text')).toBe(false) expect(isUrlValue('Weekly')).toBe(false) expect(isUrlValue('Luca Rossi')).toBe(false) }) it('rejects empty strings', () => { expect(isUrlValue('')).toBe(false) }) it('rejects single words without TLD', () => { expect(isUrlValue('localhost')).toBe(false) expect(isUrlValue('active')).toBe(false) }) }) describe('normalizeUrl', () => { it('returns http/https URLs unchanged', () => { expect(normalizeUrl('https://example.com')).toBe('https://example.com') expect(normalizeUrl('http://example.com')).toBe('http://example.com') }) it('prepends https:// to bare domains', () => { expect(normalizeUrl('example.com')).toBe('https://example.com') expect(normalizeUrl('docs.google.com/sheet')).toBe('https://docs.google.com/sheet') }) }) describe('UrlValue', () => { const onSave = vi.fn() const onCancel = vi.fn() const onStartEdit = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('displays URL text in view mode', () => { render() expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com') }) it('left-aligns URL values in view mode', () => { render() expect(screen.getByTestId('url-link')).toHaveClass('justify-start', 'text-left') }) it('opens URL via openExternalUrl on click', () => { render() fireEvent.click(screen.getByTestId('url-link')) expect(openExternalUrl).toHaveBeenCalledWith('https://example.com') }) it('normalizes bare domain before opening', () => { render() fireEvent.click(screen.getByTestId('url-link')) expect(openExternalUrl).toHaveBeenCalledWith('https://example.com') }) it('uses a flexible truncating layout for long URL values', () => { render() const link = screen.getByTestId('url-link') const value = screen.getByText('https://example.com/very/long/path/that/needs/truncation') expect(link).toHaveClass('flex-1', 'overflow-hidden') expect(value).toHaveClass('truncate') }) it('does not open malformed URL', () => { render() fireEvent.click(screen.getByTestId('url-link')) expect(openExternalUrl).not.toHaveBeenCalled() }) it('does not trigger edit mode when URL link is clicked', () => { render() fireEvent.click(screen.getByTestId('url-link')) expect(onStartEdit).not.toHaveBeenCalled() }) it('shows edit button that calls onStartEdit', () => { render() fireEvent.click(screen.getByTestId('url-edit-btn')) expect(onStartEdit).toHaveBeenCalled() }) it('shows input in editing mode', () => { render() const input = screen.getByDisplayValue('https://example.com') expect(input).toBeInTheDocument() expect(input.tagName).toBe('INPUT') }) it('calls onSave when Enter is pressed in edit mode', () => { render() const input = screen.getByDisplayValue('https://example.com') fireEvent.change(input, { target: { value: 'https://new.com' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onSave).toHaveBeenCalledWith('https://new.com') }) it('calls onCancel when Escape is pressed in edit mode', () => { render() const input = screen.getByDisplayValue('https://example.com') fireEvent.keyDown(input, { key: 'Escape' }) expect(onCancel).toHaveBeenCalled() }) it('calls onSave on blur in edit mode', () => { render() const input = screen.getByDisplayValue('https://example.com') fireEvent.change(input, { target: { value: 'https://updated.com' } }) fireEvent.blur(input) expect(onSave).toHaveBeenCalledWith('https://updated.com') }) })