|
| 1 | +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' |
| 2 | + |
| 3 | +import { |
| 4 | + getCopyIconText, |
| 5 | + copyButtonHandlers, |
| 6 | + COPIED_RESET_DELAY_MS, |
| 7 | + COPY_ICON_COLLAPSED, |
| 8 | + COPY_ICON_EXPANDED, |
| 9 | + COPY_ICON_COPIED, |
| 10 | +} from '../../components/copy-button' |
| 11 | +import { initializeThemeStore } from '../../hooks/use-theme' |
| 12 | + |
| 13 | +// Initialize theme before tests |
| 14 | +initializeThemeStore() |
| 15 | + |
| 16 | +/** |
| 17 | + * Tests for CopyButton component logic. |
| 18 | + * |
| 19 | + * These tests use the exported utilities from copy-button.tsx: |
| 20 | + * - getCopyIconText: determines what text to display |
| 21 | + * - copyButtonHandlers: pure functions for state transitions |
| 22 | + * - COPIED_RESET_DELAY_MS: the timeout constant |
| 23 | + */ |
| 24 | + |
| 25 | +describe('CopyButton - CopyIcon text rendering', () => { |
| 26 | + describe('with leadingSpace=true (default)', () => { |
| 27 | + test('renders collapsed icon when not hovered or copied', () => { |
| 28 | + const text = getCopyIconText(false, false, true) |
| 29 | + expect(text).toBe(' ⎘') |
| 30 | + }) |
| 31 | + |
| 32 | + test('renders expanded text when hovered', () => { |
| 33 | + const text = getCopyIconText(false, true, true) |
| 34 | + expect(text).toBe(' [⎘ copy]') |
| 35 | + }) |
| 36 | + |
| 37 | + test('renders copied text when copied', () => { |
| 38 | + const text = getCopyIconText(true, false, true) |
| 39 | + expect(text).toBe(' [✔ copied]') |
| 40 | + }) |
| 41 | + |
| 42 | + test('renders copied text even when hovered (copied takes priority)', () => { |
| 43 | + const text = getCopyIconText(true, true, true) |
| 44 | + expect(text).toBe(' [✔ copied]') |
| 45 | + }) |
| 46 | + }) |
| 47 | + |
| 48 | + describe('with leadingSpace=false', () => { |
| 49 | + test('renders collapsed icon without leading space', () => { |
| 50 | + const text = getCopyIconText(false, false, false) |
| 51 | + expect(text).toBe('⎘') |
| 52 | + }) |
| 53 | + |
| 54 | + test('renders expanded text without leading space', () => { |
| 55 | + const text = getCopyIconText(false, true, false) |
| 56 | + expect(text).toBe('[⎘ copy]') |
| 57 | + }) |
| 58 | + |
| 59 | + test('renders copied text without leading space', () => { |
| 60 | + const text = getCopyIconText(true, false, false) |
| 61 | + expect(text).toBe('[✔ copied]') |
| 62 | + }) |
| 63 | + }) |
| 64 | +}) |
| 65 | + |
| 66 | +describe('CopyButton - copyButtonHandlers (from component)', () => { |
| 67 | + describe('handleMouseOver', () => { |
| 68 | + test('returns true (should hover) when not copied', () => { |
| 69 | + expect(copyButtonHandlers.handleMouseOver(false)).toBe(true) |
| 70 | + }) |
| 71 | + |
| 72 | + test('returns false (block hover) when copied', () => { |
| 73 | + expect(copyButtonHandlers.handleMouseOver(true)).toBe(false) |
| 74 | + }) |
| 75 | + }) |
| 76 | + |
| 77 | + describe('handleMouseOut', () => { |
| 78 | + test('always returns false to clear hover', () => { |
| 79 | + expect(copyButtonHandlers.handleMouseOut()).toBe(false) |
| 80 | + }) |
| 81 | + }) |
| 82 | + |
| 83 | + describe('handleCopy', () => { |
| 84 | + test('returns copied=true and clears hover', () => { |
| 85 | + const result = copyButtonHandlers.handleCopy() |
| 86 | + expect(result).toEqual({ isCopied: true, isHovered: false }) |
| 87 | + }) |
| 88 | + }) |
| 89 | +}) |
| 90 | + |
| 91 | +describe('CopyButton - exported constants', () => { |
| 92 | + test('COPIED_RESET_DELAY_MS is 2000ms', () => { |
| 93 | + expect(COPIED_RESET_DELAY_MS).toBe(2000) |
| 94 | + }) |
| 95 | + |
| 96 | + test('icon constants are defined', () => { |
| 97 | + expect(COPY_ICON_COLLAPSED).toBe('⎘') |
| 98 | + expect(COPY_ICON_EXPANDED).toBe('[⎘ copy]') |
| 99 | + expect(COPY_ICON_COPIED).toBe('[✔ copied]') |
| 100 | + }) |
| 101 | +}) |
| 102 | + |
| 103 | +describe('CopyButton - copied state reset timing', () => { |
| 104 | + let originalSetTimeout: typeof setTimeout |
| 105 | + let originalClearTimeout: typeof clearTimeout |
| 106 | + let timers: { id: number; ms: number; fn: Function; active: boolean }[] |
| 107 | + let nextId: number |
| 108 | + |
| 109 | + const runTimers = () => { |
| 110 | + for (const t of timers) { |
| 111 | + if (t.active) t.fn() |
| 112 | + } |
| 113 | + timers = [] |
| 114 | + } |
| 115 | + |
| 116 | + beforeEach(() => { |
| 117 | + timers = [] |
| 118 | + nextId = 1 |
| 119 | + originalSetTimeout = globalThis.setTimeout |
| 120 | + originalClearTimeout = globalThis.clearTimeout |
| 121 | + |
| 122 | + globalThis.setTimeout = ((fn: Function, ms?: number) => { |
| 123 | + const id = nextId++ |
| 124 | + timers.push({ id, ms: Number(ms ?? 0), fn, active: true }) |
| 125 | + return id as any |
| 126 | + }) as any |
| 127 | + |
| 128 | + globalThis.clearTimeout = ((id?: any) => { |
| 129 | + const rec = timers.find((t) => t.id === id) |
| 130 | + if (rec) rec.active = false |
| 131 | + }) as any |
| 132 | + }) |
| 133 | + |
| 134 | + afterEach(() => { |
| 135 | + globalThis.setTimeout = originalSetTimeout |
| 136 | + globalThis.clearTimeout = originalClearTimeout |
| 137 | + }) |
| 138 | + |
| 139 | + test('uses the exported COPIED_RESET_DELAY_MS constant (2000ms)', () => { |
| 140 | + let isCopied = false |
| 141 | + |
| 142 | + // Simulate handleCopy using the exported constant |
| 143 | + const handleCopy = () => { |
| 144 | + const newState = copyButtonHandlers.handleCopy() |
| 145 | + isCopied = newState.isCopied |
| 146 | + setTimeout(() => { |
| 147 | + isCopied = false |
| 148 | + }, COPIED_RESET_DELAY_MS) |
| 149 | + } |
| 150 | + |
| 151 | + handleCopy() |
| 152 | + expect(isCopied).toBe(true) |
| 153 | + expect(timers.length).toBe(1) |
| 154 | + expect(timers[0].ms).toBe(COPIED_RESET_DELAY_MS) |
| 155 | + |
| 156 | + runTimers() |
| 157 | + expect(isCopied).toBe(false) |
| 158 | + }) |
| 159 | + |
| 160 | + test('multiple rapid clicks only create one active timer', () => { |
| 161 | + let isCopied = false |
| 162 | + let currentTimerId: number | null = null |
| 163 | + |
| 164 | + const handleCopy = () => { |
| 165 | + if (currentTimerId !== null) { |
| 166 | + clearTimeout(currentTimerId) |
| 167 | + } |
| 168 | + const newState = copyButtonHandlers.handleCopy() |
| 169 | + isCopied = newState.isCopied |
| 170 | + currentTimerId = setTimeout(() => { |
| 171 | + isCopied = false |
| 172 | + }, COPIED_RESET_DELAY_MS) as unknown as number |
| 173 | + } |
| 174 | + |
| 175 | + handleCopy() |
| 176 | + handleCopy() |
| 177 | + handleCopy() |
| 178 | + |
| 179 | + const activeTimers = timers.filter((t) => t.active) |
| 180 | + expect(activeTimers.length).toBe(1) |
| 181 | + }) |
| 182 | +}) |
| 183 | + |
0 commit comments