Skip to content

Commit bf6e630

Browse files
committed
test(files): extract pure modules and add 122-test suite for file viewer logic
Extract TextEditorContentState machine and file category resolution into plain .ts modules (text-editor-state.ts, file-category.ts) so they can be unit-tested without React or Next.js overhead. Update component files to import from the extracted modules, eliminating code duplication. Add two test files: - text-editor-state.test.ts: 32 tests covering resolveStreamingEditorContent, the reducer (edit / save-success), and syncTextEditorContentState across all phases (uninitialized, ready, streaming, reconciling) including reference-equality short-circuit checks for zero-allocation paths - file-category.test.ts: 90 tests covering MIME-type routing for all 8 categories, extension fallback, MIME-priority-over-extension, and case-insensitive extension handling
1 parent f1d837d commit bf6e630

6 files changed

Lines changed: 914 additions & 331 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/uploads/utils/validation', () => ({
7+
SUPPORTED_CODE_EXTENSIONS: ['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'],
8+
}))
9+
10+
vi.mock('@/lib/uploads/utils/file-utils', () => ({
11+
getFileExtension: (filename: string): string => {
12+
const lastDot = filename.lastIndexOf('.')
13+
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
14+
},
15+
}))
16+
17+
import { resolveFileCategory } from './file-category'
18+
19+
describe('resolveFileCategory — MIME type routing', () => {
20+
describe('text-editable', () => {
21+
it.each([
22+
'text/plain',
23+
'text/markdown',
24+
'application/json',
25+
'application/x-yaml',
26+
'text/csv',
27+
'text/html',
28+
'text/xml',
29+
'application/xml',
30+
'text/css',
31+
'text/javascript',
32+
'application/javascript',
33+
'application/typescript',
34+
'application/toml',
35+
'text/x-python',
36+
'text/x-sh',
37+
'text/x-sql',
38+
'image/svg+xml',
39+
'text/x-mermaid',
40+
])('%s → text-editable', (mime) => {
41+
expect(resolveFileCategory(mime, 'file.txt')).toBe('text-editable')
42+
})
43+
})
44+
45+
describe('iframe-previewable (PDF)', () => {
46+
it('application/pdf → iframe-previewable', () => {
47+
expect(resolveFileCategory('application/pdf', 'doc.pdf')).toBe('iframe-previewable')
48+
})
49+
50+
it('text/x-pdflibjs → iframe-previewable', () => {
51+
expect(resolveFileCategory('text/x-pdflibjs', 'generated.pdf')).toBe('iframe-previewable')
52+
})
53+
})
54+
55+
describe('image-previewable', () => {
56+
it.each(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])(
57+
'%s → image-previewable',
58+
(mime) => {
59+
expect(resolveFileCategory(mime, 'img.png')).toBe('image-previewable')
60+
}
61+
)
62+
})
63+
64+
describe('audio-previewable', () => {
65+
it.each([
66+
'audio/mpeg',
67+
'audio/mp4',
68+
'audio/wav',
69+
'audio/webm',
70+
'audio/ogg',
71+
'audio/flac',
72+
'audio/aac',
73+
'audio/opus',
74+
'audio/x-m4a',
75+
])('%s → audio-previewable', (mime) => {
76+
expect(resolveFileCategory(mime, 'audio.mp3')).toBe('audio-previewable')
77+
})
78+
})
79+
80+
describe('video-previewable', () => {
81+
it.each(['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm'])(
82+
'%s → video-previewable',
83+
(mime) => {
84+
expect(resolveFileCategory(mime, 'video.mp4')).toBe('video-previewable')
85+
}
86+
)
87+
})
88+
89+
describe('docx-previewable', () => {
90+
it('application/vnd.openxmlformats-officedocument.wordprocessingml.document → docx-previewable', () => {
91+
expect(
92+
resolveFileCategory(
93+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
94+
'doc.docx'
95+
)
96+
).toBe('docx-previewable')
97+
})
98+
99+
it('text/x-docxjs → docx-previewable', () => {
100+
expect(resolveFileCategory('text/x-docxjs', 'doc.docx')).toBe('docx-previewable')
101+
})
102+
})
103+
104+
describe('pptx-previewable', () => {
105+
it('application/vnd.openxmlformats-officedocument.presentationml.presentation → pptx-previewable', () => {
106+
expect(
107+
resolveFileCategory(
108+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
109+
'deck.pptx'
110+
)
111+
).toBe('pptx-previewable')
112+
})
113+
114+
it('text/x-pptxgenjs → pptx-previewable', () => {
115+
expect(resolveFileCategory('text/x-pptxgenjs', 'deck.pptx')).toBe('pptx-previewable')
116+
})
117+
})
118+
119+
describe('xlsx-previewable', () => {
120+
it('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet → xlsx-previewable', () => {
121+
expect(
122+
resolveFileCategory(
123+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
124+
'data.xlsx'
125+
)
126+
).toBe('xlsx-previewable')
127+
})
128+
})
129+
})
130+
131+
describe('resolveFileCategory — extension fallback', () => {
132+
describe('text-editable extensions', () => {
133+
it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])(
134+
'.%s → text-editable',
135+
(ext) => {
136+
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
137+
}
138+
)
139+
})
140+
141+
describe('code extensions from SUPPORTED_CODE_EXTENSIONS', () => {
142+
it.each(['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'])('.%s → text-editable', (ext) => {
143+
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
144+
})
145+
})
146+
147+
describe('pdf extension', () => {
148+
it('.pdf → iframe-previewable', () => {
149+
expect(resolveFileCategory(null, 'document.pdf')).toBe('iframe-previewable')
150+
})
151+
})
152+
153+
describe('image extensions', () => {
154+
it.each(['png', 'jpg', 'jpeg', 'gif', 'webp'])('.%s → image-previewable', (ext) => {
155+
expect(resolveFileCategory(null, `image.${ext}`)).toBe('image-previewable')
156+
})
157+
})
158+
159+
describe('audio extensions', () => {
160+
it.each(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])(
161+
'.%s → audio-previewable',
162+
(ext) => {
163+
expect(resolveFileCategory(null, `audio.${ext}`)).toBe('audio-previewable')
164+
}
165+
)
166+
})
167+
168+
describe('video extensions', () => {
169+
it.each(['mp4', 'mov', 'avi', 'mkv', 'webm'])('.%s → video-previewable', (ext) => {
170+
expect(resolveFileCategory(null, `video.${ext}`)).toBe('video-previewable')
171+
})
172+
})
173+
174+
describe('docx extension', () => {
175+
it('.docx → docx-previewable', () => {
176+
expect(resolveFileCategory(null, 'doc.docx')).toBe('docx-previewable')
177+
})
178+
})
179+
180+
describe('pptx extension', () => {
181+
it('.pptx → pptx-previewable', () => {
182+
expect(resolveFileCategory(null, 'deck.pptx')).toBe('pptx-previewable')
183+
})
184+
})
185+
186+
describe('xlsx extension', () => {
187+
it('.xlsx → xlsx-previewable', () => {
188+
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
189+
})
190+
})
191+
192+
describe('unsupported', () => {
193+
it('unknown extension → unsupported', () => {
194+
expect(resolveFileCategory(null, 'file.xyz')).toBe('unsupported')
195+
})
196+
197+
it('unknown mime with unknown extension → unsupported', () => {
198+
expect(resolveFileCategory('application/octet-stream', 'file.bin')).toBe('unsupported')
199+
})
200+
201+
it('no extension, no mime → unsupported', () => {
202+
expect(resolveFileCategory(null, 'LICENSE')).toBe('unsupported')
203+
})
204+
})
205+
})
206+
207+
describe('resolveFileCategory — MIME priority', () => {
208+
it('text/plain MIME + .pdf extension → text-editable (MIME wins)', () => {
209+
expect(resolveFileCategory('text/plain', 'notes.pdf')).toBe('text-editable')
210+
})
211+
212+
it('application/pdf MIME + .txt extension → iframe-previewable (MIME wins)', () => {
213+
expect(resolveFileCategory('application/pdf', 'disguised.txt')).toBe('iframe-previewable')
214+
})
215+
216+
it('null MIME falls through to extension routing', () => {
217+
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
218+
})
219+
220+
it('unknown MIME falls through to extension routing', () => {
221+
expect(resolveFileCategory('application/octet-stream', 'data.xlsx')).toBe('xlsx-previewable')
222+
})
223+
})
224+
225+
describe('resolveFileCategory — extension case', () => {
226+
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
227+
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')
228+
})
229+
230+
it('handles mixed-case correctly for json', () => {
231+
expect(resolveFileCategory(null, 'config.JSON')).toBe('text-editable')
232+
})
233+
})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
2+
import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'
3+
4+
const TEXT_EDITABLE_MIME_TYPES = new Set([
5+
'text/markdown',
6+
'text/plain',
7+
'application/json',
8+
'application/x-yaml',
9+
'text/csv',
10+
'text/html',
11+
'text/xml',
12+
'application/xml',
13+
'text/css',
14+
'text/javascript',
15+
'application/javascript',
16+
'application/typescript',
17+
'application/toml',
18+
'text/x-python',
19+
'text/x-sh',
20+
'text/x-sql',
21+
'image/svg+xml',
22+
'text/x-mermaid',
23+
])
24+
25+
const TEXT_EDITABLE_EXTENSIONS = new Set([
26+
'md',
27+
'txt',
28+
'json',
29+
'yaml',
30+
'yml',
31+
'csv',
32+
'html',
33+
'htm',
34+
'svg',
35+
'mmd',
36+
...SUPPORTED_CODE_EXTENSIONS,
37+
])
38+
39+
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf', 'text/x-pdflibjs'])
40+
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
41+
42+
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
43+
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
44+
45+
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
46+
'audio/mpeg',
47+
'audio/mp4',
48+
'audio/wav',
49+
'audio/webm',
50+
'audio/ogg',
51+
'audio/flac',
52+
'audio/aac',
53+
'audio/opus',
54+
'audio/x-m4a',
55+
])
56+
const AUDIO_PREVIEWABLE_EXTENSIONS = new Set(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])
57+
58+
const VIDEO_PREVIEWABLE_MIME_TYPES = new Set([
59+
'video/mp4',
60+
'video/quicktime',
61+
'video/x-msvideo',
62+
'video/x-matroska',
63+
'video/webm',
64+
])
65+
const VIDEO_PREVIEWABLE_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm'])
66+
67+
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
68+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
69+
'text/x-pptxgenjs',
70+
])
71+
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
72+
73+
const DOCX_PREVIEWABLE_MIME_TYPES = new Set([
74+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
75+
'text/x-docxjs',
76+
])
77+
const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx'])
78+
79+
const XLSX_PREVIEWABLE_MIME_TYPES = new Set([
80+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
81+
])
82+
const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx'])
83+
84+
export type FileCategory =
85+
| 'text-editable'
86+
| 'iframe-previewable'
87+
| 'image-previewable'
88+
| 'audio-previewable'
89+
| 'video-previewable'
90+
| 'pptx-previewable'
91+
| 'docx-previewable'
92+
| 'xlsx-previewable'
93+
| 'unsupported'
94+
95+
export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
96+
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
97+
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
98+
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
99+
if (mimeType && AUDIO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'audio-previewable'
100+
if (mimeType && VIDEO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'video-previewable'
101+
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
102+
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
103+
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
104+
105+
const ext = getFileExtension(filename)
106+
const nameKey = ext || filename.toLowerCase()
107+
if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable'
108+
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
109+
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
110+
if (AUDIO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'audio-previewable'
111+
if (VIDEO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'video-previewable'
112+
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
113+
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
114+
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
115+
116+
return 'unsupported'
117+
}

0 commit comments

Comments
 (0)