@moontra/moonui 6.17.0 → 6.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moontra/moonui",
3
- "version": "6.17.0",
3
+ "version": "6.19.0",
4
4
  "description": "Premium React component library with modern design and customization",
5
5
  "author": "MoonUI",
6
6
  "license": "MIT",
@@ -15,8 +15,6 @@
15
15
  "main": "dist/index.js",
16
16
  "module": "dist/index.mjs",
17
17
  "types": "dist/index.d.ts",
18
- "unpkg": "dist/index.global.js",
19
- "jsdelivr": "dist/index.global.js",
20
18
  "sideEffects": [
21
19
  "**/*.css"
22
20
  ],
@@ -60,8 +58,8 @@
60
58
  "scripts": {
61
59
  "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --sourcemap",
62
60
  "build": "npm run build:js && npm run build:dts && node scripts/postbuild.js",
63
- "build:js": "tsup src/index.tsx src/lib/theme.tsx src/hooks/index.ts --format esm,cjs",
64
- "build:dts": "tsup src/index.tsx src/lib/theme.tsx src/hooks/index.ts --format esm,cjs --dts-only",
61
+ "build:js": "tsup",
62
+ "build:dts": "tsup --dts-only",
65
63
  "lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
66
64
  "clean": "rm -rf dist",
67
65
  "prepublishOnly": "npm run clean && npm run build",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * CDN (IIFE / `.global.js`) build'i KALDIRILDI — karar kilidi. Issue #399.
3
+ *
4
+ * Ne olmuştu: `tsup.config.ts` ikinci bir tanımda `format: ["iife"]` +
5
+ * `globalName: "MoonUI"` ilan ediyordu, ama `package.json`'daki `build:js`
6
+ * tsup'ı CLI argümanıyla çağırıyordu (`tsup src/... --format esm,cjs`).
7
+ * tsup'ta CLI argümanı config'i EZER → IIFE hiç üretilmedi; `.global.js`
8
+ * ADIYLA bir ES modülü yayımlandı. Dosya adı IIFE vaat ettiği için hata
9
+ * sessizdi: `window.MoonUI` hiçbir sürümde oluşmadı, buna rağmen her
10
+ * tarball 4.7 MB ölü `.global.*` artefaktı taşıdı.
11
+ *
12
+ * Neden "IIFE'yi gerçekten üretelim" seçilmedi (ölçüm, #399): CLI argümanı
13
+ * kaldırılıp gerçek IIFE üretildiğinde `window.MoonUI` oluşuyor, ama React
14
+ * bundle'ın içine giriyor (config her şeyi noExternal ile bundle'lıyordu) ve
15
+ * dışarı React API'si sunulmuyor → sayfa kendi React'ini yüklemek zorunda,
16
+ * yani çift React → hook
17
+ * kullanan her component çöküyor (Switch/Tooltip/Accordion: "Cannot read
18
+ * properties of null"). Ayrıca CDN için stil artefaktı yok. Gerçek bir CDN
19
+ * yolu ayrı bir ürün işidir; o gelene kadar yüzey tamamen kaldırıldı.
20
+ *
21
+ * Bu test üç katmanı birden kilitler: build config, paket metadata'sı ve
22
+ * (varsa) üretilmiş dist. Config + metadata katmanları BUILD GEREKTİRMEZ —
23
+ * `.global.*` ancak config onu ilan ederse üretilebileceği için asıl kapı
24
+ * onlardır; dist taraması build yapılmışsa çalışan EK doğrulamadır.
25
+ */
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+
29
+ const PACKAGE_ROOT = path.resolve(__dirname, '../..');
30
+ const TSUP_CONFIG = path.join(PACKAGE_ROOT, 'tsup.config.ts');
31
+ const PACKAGE_JSON = path.join(PACKAGE_ROOT, 'package.json');
32
+ const DIST = path.join(PACKAGE_ROOT, 'dist');
33
+
34
+ /** dist ağacındaki tüm dosyaları (alt dizinler dahil) paket köküne göreli döndürür. */
35
+ function walk(dir: string, base = dir): string[] {
36
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
37
+ const full = path.join(dir, entry.name);
38
+ return entry.isDirectory() ? walk(full, base) : [path.relative(base, full)];
39
+ });
40
+ }
41
+
42
+ describe('CDN/IIFE build yüzeyi kaldırıldı (#399)', () => {
43
+ describe('tsup.config.ts — IIFE tanımı geri gelmemeli', () => {
44
+ let config = '';
45
+ beforeAll(() => {
46
+ config = fs.readFileSync(TSUP_CONFIG, 'utf8');
47
+ });
48
+
49
+ it('hiçbir tanımda `format: ["iife"]` yok', () => {
50
+ // Yorum satırlarını ayıkla: karar gerekçesi metninde "iife" kelimesi geçiyor.
51
+ const code = config.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
52
+ expect(code).not.toMatch(/format\s*:\s*\[[^\]]*['"]iife['"]/);
53
+ });
54
+
55
+ it('`globalName` (window.X ataması) ilan edilmiyor', () => {
56
+ const code = config.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
57
+ expect(code).not.toMatch(/globalName\s*:/);
58
+ });
59
+
60
+ it('`.global.js` uzantısı üreten outExtension yok', () => {
61
+ const code = config.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
62
+ expect(code).not.toMatch(/\.global\.js/);
63
+ });
64
+ });
65
+
66
+ describe('package.json — CDN metadata ve config-ezen build argümanları yok', () => {
67
+ let pkg: Record<string, unknown> = {};
68
+ beforeAll(() => {
69
+ pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8'));
70
+ });
71
+
72
+ it.each(['unpkg', 'jsdelivr'])('%s alanı yok (CDN çözümleyicileri ölü dosyaya bakmasın)', (field) => {
73
+ expect(pkg).not.toHaveProperty(field);
74
+ });
75
+
76
+ it('exports/files alanlarında `.global` atfı yok', () => {
77
+ expect(JSON.stringify({ exports: pkg.exports, files: pkg.files })).not.toContain('.global');
78
+ });
79
+
80
+ /**
81
+ * Kök nedenin kendisi: CLI argümanı tsup config'ini ezer. build script'leri
82
+ * entry/format'ı CLI'dan geçmediği sürece config tek kaynak kalır.
83
+ */
84
+ it.each(['build:js', 'build:dts'])('%s tsup config\'ini CLI argümanıyla ezmiyor', (script) => {
85
+ const scripts = pkg.scripts as Record<string, string>;
86
+ const command = scripts[script];
87
+ expect(command).toBeDefined();
88
+ expect(command).not.toMatch(/--format/);
89
+ expect(command).not.toMatch(/\bsrc\//);
90
+ });
91
+ });
92
+
93
+ describe('dist — üretilmiş çıktıda `.global.*` kalmamalı', () => {
94
+ const distExists = fs.existsSync(DIST);
95
+
96
+ // Build yapılmamışsa bu katman atlanır; asıl kapı yukarıdaki config +
97
+ // metadata testleridir (build olmadan da koşar ve regresyonu yakalar).
98
+ (distExists ? it : it.skip)('dist ağacında hiçbir `.global.*` dosyası yok', () => {
99
+ const globals = walk(DIST).filter((file) => path.basename(file).includes('.global.'));
100
+ expect(globals).toEqual([]);
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * #368 — koyu tema token kontrast değişmezleri.
3
+ *
4
+ * BULGU: `--success` için yapılmış çift-rol denetimi (zemin mi metin mi?) üç
5
+ * token için atlanmıştı; üçü de koyu temada ölçülebilir biçimde kırıktı:
6
+ *
7
+ * 1. `--destructive` (0 63% 31%) metin rolünde gerçek kart zemini #111827
8
+ * üstünde 1.79:1 veriyordu (AA 4.5 ister) → hata metinleri okunmuyordu.
9
+ * Düzeltme: 0 84% 60% (= `:root --error`) → 4.69:1.
10
+ * 2. `--muted` (223 47% 11%) kart zemininden 1.01:1 ile ayrışmıyordu →
11
+ * Skeleton barları görünmüyordu. Düzeltme: L 11→22 → 1.31:1 (aynı hue/sat).
12
+ * Zincir etkisi: `--muted-foreground` 56.9% yeni muted üstünde 4.06'ya
13
+ * düşecekti → 62% ile 4.80.
14
+ * 3. `--primary` vs `--foreground` 1.19:1 — BİLİNÇLİ bırakıldı: token'ın esas
15
+ * rolü zemin (buton 19.81 taşır); vurgu ayrımı renk-dışı sinyallerle ve
16
+ * `--primary-subtle(-foreground)` (#354) ile verilir. Buradaki testler o
17
+ * kararın DAYANDIĞI değişmezleri kilitler.
18
+ *
19
+ * Kabul kriteri ayrıca: AÇIK tema değerleri DEĞİŞMEMELİ — ayrı blokta sabitlenir.
20
+ *
21
+ * Hesap yöntemi primary-subtle-token.test.ts ile birebir aynıdır (WCAG 2.x
22
+ * bağıl parlaklık); oradaki "hesaplayıcı doğru mu?" çapası burada da var.
23
+ */
24
+ import * as fs from 'fs'
25
+ import * as path from 'path'
26
+
27
+ const KOK = path.resolve(__dirname, '../../..')
28
+ const tokens = fs.readFileSync(path.join(KOK, 'styles/tokens.css'), 'utf8')
29
+
30
+ /**
31
+ * Docs kabuğunda ölçülen GERÇEK kart zemini (#111827 = rgb(17,24,39)).
32
+ * #284 konvansiyonu: kenarlık/metin pratikte `--background` (4%) üstünde değil
33
+ * bu yüzeyde duruyor; kontrast gerçek zemine göre ölçülür (tokens.css --input notu).
34
+ */
35
+ const KART_GERCEK: [number, number, number] = [17, 24, 39]
36
+
37
+ /** "H S% L%" biçimindeki token değerini oku (light = :root, dark = .dark bloğu) */
38
+ function tokenOku(ad: string, blok: 'light' | 'dark'): string | null {
39
+ const kaynak =
40
+ blok === 'dark'
41
+ ? tokens.slice(tokens.indexOf('.dark'))
42
+ : tokens.slice(0, tokens.indexOf('.dark'))
43
+ return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
44
+ }
45
+
46
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
47
+ h /= 360
48
+ s /= 100
49
+ l /= 100
50
+ const f = (n: number) => {
51
+ const k = (n + h * 12) % 12
52
+ const a = s * Math.min(l, 1 - l)
53
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
54
+ }
55
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
56
+ }
57
+
58
+ function bagilParlaklik([r, g, b]: [number, number, number]): number {
59
+ const c = (v: number) => {
60
+ const x = v / 255
61
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
62
+ }
63
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
64
+ }
65
+
66
+ function rgbKontrast(a: [number, number, number], b: [number, number, number]): number {
67
+ const la = bagilParlaklik(a)
68
+ const lb = bagilParlaklik(b)
69
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
70
+ return (hi + 0.05) / (lo + 0.05)
71
+ }
72
+
73
+ const ayrıştır = (v: string): [number, number, number] => {
74
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
75
+ return hslToRgb(h, s, l)
76
+ }
77
+
78
+ /** İki token (veya token + sabit RGB yüzey) arasındaki kontrast oranı */
79
+ function kontrast(a: string, b: string | [number, number, number]): number {
80
+ return rgbKontrast(ayrıştır(a), Array.isArray(b) ? b : ayrıştır(b))
81
+ }
82
+
83
+ const dark = (ad: string) => tokenOku(ad, 'dark')!
84
+
85
+ describe('#368 koyu tema token kontrastları', () => {
86
+ it('hesaplayıcı DOĞRU — bilinen bir çift belgelenen oranı veriyor', () => {
87
+ // Sessiz-yeşil koruması: `--info-subtle` çifti tokens.css'te 9.52:1 diye
88
+ // belgelenmiş; hesaplayıcı bozulursa bu çapa kırmızı olur.
89
+ const oran = kontrast(tokenOku('info-subtle', 'light')!, tokenOku('info-subtle-foreground', 'light')!)
90
+ expect(oran).toBeGreaterThan(9.3)
91
+ expect(oran).toBeLessThan(9.7)
92
+ })
93
+
94
+ describe('--destructive: koyu temada METİN rolü (asıl bulgu, eski değer 1.79)', () => {
95
+ it('gerçek kart zemini (#111827) üstünde AA geçiyor (>=4.5)', () => {
96
+ expect(kontrast(dark('destructive'), KART_GERCEK)).toBeGreaterThanOrEqual(4.5)
97
+ })
98
+
99
+ it('--background üstünde AA geçiyor (>=4.5)', () => {
100
+ expect(kontrast(dark('destructive'), dark('background'))).toBeGreaterThanOrEqual(4.5)
101
+ })
102
+
103
+ it('hata kenarlığı rolünde 1.4.11 eşiğini geçiyor (>=3.0, eski değer 1.80)', () => {
104
+ expect(kontrast(dark('destructive'), KART_GERCEK)).toBeGreaterThanOrEqual(3.0)
105
+ })
106
+
107
+ it('dolgu rolü belgelenen trade-off tabanının altına DÜŞMÜYOR (fg ile >=3.0)', () => {
108
+ // Çift-rol uzlaşması: metin rolü AA alınırken dolgu rolü (bg-destructive +
109
+ // beyaz foreground) 3.61'e iner. 3.0 büyük-metin/UI tabanıdır; biri
110
+ // destructive'i daha da açarsa buton metni tamamen çökemesin diye kilitli.
111
+ expect(kontrast(dark('destructive'), dark('destructive-foreground'))).toBeGreaterThanOrEqual(3.0)
112
+ })
113
+ })
114
+
115
+ describe('--muted: koyu temada dekoratif YÜZEY rolü (eski değer kartla 1.01)', () => {
116
+ it('gerçek kart zemininden ayrışıyor (>=1.3 — Skeleton görünürlüğü)', () => {
117
+ expect(kontrast(dark('muted'), KART_GERCEK)).toBeGreaterThanOrEqual(1.3)
118
+ })
119
+
120
+ it('--background üstünde ayrışıyor (>=1.3)', () => {
121
+ expect(kontrast(dark('muted'), dark('background'))).toBeGreaterThanOrEqual(1.3)
122
+ })
123
+
124
+ it('AŞIRI açılmamış — subtle doğası korunuyor (kartla <=2.0)', () => {
125
+ // Muted "belli belirsiz" bir yüzeydir; 2.0 üstü artık vurgulu bir bloktur.
126
+ expect(kontrast(dark('muted'), KART_GERCEK)).toBeLessThanOrEqual(2.0)
127
+ })
128
+
129
+ it('--muted-foreground yeni muted zemini üstünde AA geçiyor (>=4.5, #284 deseni)', () => {
130
+ expect(kontrast(dark('muted-foreground'), dark('muted'))).toBeGreaterThanOrEqual(4.5)
131
+ })
132
+
133
+ it('--muted-foreground kart ve --background üstünde AA korunuyor (>=4.5)', () => {
134
+ expect(kontrast(dark('muted-foreground'), KART_GERCEK)).toBeGreaterThanOrEqual(4.5)
135
+ expect(kontrast(dark('muted-foreground'), dark('background'))).toBeGreaterThanOrEqual(4.5)
136
+ })
137
+ })
138
+
139
+ describe('--primary: koyu temada ZEMİN rolü esas (bilinçli near-white, #368 kararı)', () => {
140
+ it('buton bileşimi (bg-primary + primary-foreground) AA geçiyor (>=4.5)', () => {
141
+ // Bu değişmez, "--primary'yi maviye çekme" önerilerine karşı kararın
142
+ // dayanağıdır: zemin rolü 19.81 taşıyor; değer değişecekse bu test bilinçli
143
+ // olarak güncellenmelidir.
144
+ expect(kontrast(dark('primary'), dark('primary-foreground'))).toBeGreaterThanOrEqual(4.5)
145
+ })
146
+
147
+ it('vurgu metni alternatifi canlı: --primary-subtle-foreground kart üstünde AA (>=4.5)', () => {
148
+ expect(kontrast(dark('primary-subtle-foreground'), KART_GERCEK)).toBeGreaterThanOrEqual(4.5)
149
+ })
150
+ })
151
+
152
+ describe('kabul kriteri: AÇIK tema değerleri DEĞİŞMEDİ', () => {
153
+ it.each([
154
+ ['destructive', '0 84.2% 48%'],
155
+ ['muted', '217.2 32.6% 96%'],
156
+ ['muted-foreground', '215.4 16.3% 45%'],
157
+ ['primary', '217.2 91.2% 51%'],
158
+ ])('--%s açık temada sabit: %s', (ad, beklenen) => {
159
+ expect(tokenOku(ad, 'light')).toBe(beklenen)
160
+ })
161
+ })
162
+ })
@@ -1,4 +1,5 @@
1
1
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import { DataTableBasic } from '../data-table-basic';
3
4
  import { ColumnDef } from '@tanstack/react-table';
4
5
  import '@testing-library/jest-dom';
@@ -269,3 +270,111 @@ describe('DataTableBasic', () => {
269
270
  expect(container.querySelector('.custom-class')).toBeInTheDocument();
270
271
  });
271
272
  });
273
+
274
+ /**
275
+ * #366 — sıralanabilir başlığın erişilebilirliği (#321'in `table.tsx` testinin ikizi).
276
+ *
277
+ * ÖNCESİ: sıralanabilir başlık yalnız `<div onClick>` idi — dosyada `aria-sort`,
278
+ * `tabIndex` ve `onKeyDown` grep'i 0. Klavye kullanıcısı sıralayamıyor, durum
279
+ * yardımcı teknolojiye hiç ulaşmıyordu. Aynı desen pro DataTable'da #291/#345,
280
+ * free `table.tsx`'te #321 ile kapatılmıştı; bu üçüncü dosyaydı.
281
+ *
282
+ * Çözüm table.tsx'in div-role taklidi DEĞİL, pro DataTable'ın #345 deseni:
283
+ * başlık içeriği flexRender ile TÜKETİCİDEN gelir (`createSortableHeader` zaten
284
+ * Button render eder) — sarmalayıcıya role="button" vermek buton-içinde-buton
285
+ * (nested-interactive) üretirdi. Bu yüzden <th aria-sort> + GERÇEK ikon
286
+ * <button> (native Tab + Enter/Space + focus-visible ring).
287
+ */
288
+ describe('#366 — sıralama erişilebilirliği', () => {
289
+ it('sıralanabilir başlık aria-sort taşır ve none → ascending → descending → none döngüsünü izler', () => {
290
+ render(<DataTableBasic columns={mockColumns} data={mockData} />);
291
+
292
+ const nameHeader = screen.getByRole('columnheader', { name: /name/i });
293
+ expect(nameHeader).toHaveAttribute('aria-sort', 'none');
294
+
295
+ const sortButton = screen.getByRole('button', { name: 'Sort by Name' });
296
+
297
+ fireEvent.click(sortButton);
298
+ expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
299
+
300
+ fireEvent.click(sortButton);
301
+ expect(nameHeader).toHaveAttribute('aria-sort', 'descending');
302
+
303
+ fireEvent.click(sortButton);
304
+ expect(nameHeader).toHaveAttribute('aria-sort', 'none');
305
+ });
306
+
307
+ it('KLAVYEYLE sıralanabilir — gerçek <button>, Enter ve Space ile satır sırası değişir', async () => {
308
+ const user = userEvent.setup();
309
+ render(<DataTableBasic columns={mockColumns} data={mockData} />);
310
+
311
+ const sortButton = screen.getByRole('button', { name: 'Sort by Name' });
312
+
313
+ // Native button: Tab ile odaklanabilir (tabIndex taklidine gerek yok)
314
+ sortButton.focus();
315
+ expect(sortButton).toHaveFocus();
316
+
317
+ // Enter → ascending: alfabetik ilk kayıt Bob Johnson öne gelir
318
+ await user.keyboard('{Enter}');
319
+ let rows = screen.getAllByRole('row');
320
+ expect(rows[1]).toHaveTextContent('Bob Johnson');
321
+ expect(
322
+ screen.getByRole('columnheader', { name: /name/i })
323
+ ).toHaveAttribute('aria-sort', 'ascending');
324
+
325
+ // Space → descending: alfabetik son kayıt John Doe öne gelir
326
+ await user.keyboard(' ');
327
+ rows = screen.getAllByRole('row');
328
+ expect(rows[1]).toHaveTextContent('John Doe');
329
+ expect(
330
+ screen.getByRole('columnheader', { name: /name/i })
331
+ ).toHaveAttribute('aria-sort', 'descending');
332
+ });
333
+
334
+ it('enableSorting=false iken aria-sort HİÇ basılmaz ve sıralama butonu yoktur', () => {
335
+ render(
336
+ <DataTableBasic columns={mockColumns} data={mockData} enableSorting={false} />
337
+ );
338
+
339
+ for (const header of screen.getAllByRole('columnheader')) {
340
+ expect(header).not.toHaveAttribute('aria-sort');
341
+ }
342
+ expect(
343
+ screen.queryByRole('button', { name: /^Sort by/ })
344
+ ).not.toBeInTheDocument();
345
+ });
346
+
347
+ it('kolon düzeyinde enableSorting: false olan sütunda buton/aria-sort verilmez', () => {
348
+ const columns: ColumnDef<TestData>[] = [
349
+ { accessorKey: 'name', header: 'Name' },
350
+ { accessorKey: 'email', header: 'Email', enableSorting: false },
351
+ ];
352
+ render(<DataTableBasic columns={columns} data={mockData} />);
353
+
354
+ expect(
355
+ screen.getByRole('columnheader', { name: /name/i })
356
+ ).toHaveAttribute('aria-sort', 'none');
357
+ expect(
358
+ screen.getByRole('columnheader', { name: /email/i })
359
+ ).not.toHaveAttribute('aria-sort');
360
+ expect(
361
+ screen.getByRole('button', { name: 'Sort by Name' })
362
+ ).toBeInTheDocument();
363
+ expect(
364
+ screen.queryByRole('button', { name: 'Sort by Email' })
365
+ ).not.toBeInTheDocument();
366
+ });
367
+
368
+ it('başlığa tıklamak (fare kolaylığı) sıralamayı hâlâ değiştirir', () => {
369
+ render(<DataTableBasic columns={mockColumns} data={mockData} />);
370
+
371
+ // Butona değil, başlık METNİNE tıklanır — sarmalayıcı div onClick'i
372
+ fireEvent.click(screen.getByText('Name'));
373
+
374
+ const rows = screen.getAllByRole('row');
375
+ expect(rows[1]).toHaveTextContent('Bob Johnson');
376
+ expect(
377
+ screen.getByRole('columnheader', { name: /name/i })
378
+ ).toHaveAttribute('aria-sort', 'ascending');
379
+ });
380
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * FileUpload (free) testleri — issue #378.
3
+ *
4
+ * Kapsam:
5
+ * 1. accept matrisi — HTML `input[accept]` semantiği:
6
+ * - uzantı token'ı (`.pdf`) → dosya ADI son ekiyle eşleşir (harf duyarsız)
7
+ * - MIME token'ı (`application/pdf`) → `File.type` ile birebir eşleşir
8
+ * - wildcard (`image/*`) → MIME prefix'iyle eşleşir
9
+ * - karışık liste / boşluklu liste / `*` ve `*∕*` tam-geçirgen token'lar
10
+ * Eski mantık uzantı token'ını da MIME ile karşılaştırdığı için
11
+ * `accept=".pdf"` altında batch komple reddediliyordu (docs snippet'i kırıktı).
12
+ * 2. Simüle progress / error yarışı: onUpload reddedince simülasyon interval'i
13
+ * temizlenmeli ve 'error' state'i sonraki tick'lerce 'success'/"Done" ile
14
+ * EZİLMEMELİ (0/300 ms reddetme gecikmeleriyle ölçülen K1 bulgusu).
15
+ */
16
+ import * as React from "react"
17
+ import { render, screen, fireEvent, act } from "@testing-library/react"
18
+ import { FileUpload } from "../file-upload"
19
+
20
+ // jsdom URL.createObjectURL/revokeObjectURL sağlamaz — image önizlemesi için stub
21
+ beforeAll(() => {
22
+ Object.defineProperty(global.URL, "createObjectURL", {
23
+ value: jest.fn(() => "blob:mock"),
24
+ writable: true,
25
+ })
26
+ Object.defineProperty(global.URL, "revokeObjectURL", {
27
+ value: jest.fn(),
28
+ writable: true,
29
+ })
30
+ })
31
+
32
+ const makeFile = (name: string, type: string, content = "moonui"): File =>
33
+ new File([content], name, { type })
34
+
35
+ const getInput = () => screen.getByLabelText(/choose files to upload/i)
36
+
37
+ const uploadFiles = (...files: File[]) => {
38
+ fireEvent.change(getInput(), { target: { files } })
39
+ }
40
+
41
+ /** Kabul edilen dosya listede görünür, tür hatası görünmez. */
42
+ const expectAccepted = (fileName: string) => {
43
+ expect(screen.getByText(fileName)).toBeInTheDocument()
44
+ expect(screen.queryByText(/File type not supported/)).not.toBeInTheDocument()
45
+ }
46
+
47
+ /** Reddedilen dosya listeye girmez, dosya-adıyla tür hatası görünür. */
48
+ const expectRejected = (fileName: string) => {
49
+ expect(
50
+ screen.getByText(
51
+ new RegExp(`${fileName.replace(/\./g, "\\.")}: File type not supported`)
52
+ )
53
+ ).toBeInTheDocument()
54
+ expect(screen.queryByText(/^Uploaded Files/)).not.toBeInTheDocument()
55
+ }
56
+
57
+ describe("FileUpload accept matrisi (#378)", () => {
58
+ beforeEach(() => {
59
+ jest.useFakeTimers()
60
+ })
61
+
62
+ afterEach(() => {
63
+ jest.clearAllTimers()
64
+ jest.useRealTimers()
65
+ })
66
+
67
+ it('uzantı token\'ı: accept=".pdf" PDF dosyasını kabul eder (docs snippet senaryosu)', () => {
68
+ render(<FileUpload accept=".pdf" />)
69
+ uploadFiles(makeFile("report.pdf", "application/pdf"))
70
+ expectAccepted("report.pdf")
71
+ })
72
+
73
+ it('uzantı eşleşmesi harf duyarsızdır: ".pdf" + REPORT.PDF', () => {
74
+ render(<FileUpload accept=".pdf" />)
75
+ uploadFiles(makeFile("REPORT.PDF", "application/pdf"))
76
+ expectAccepted("REPORT.PDF")
77
+ })
78
+
79
+ it("uzantı token'ı MIME'den bağımsız çalışır (boş File.type)", () => {
80
+ // OS/tarayıcı kombinasyonları drag-drop'ta type'ı boş bırakabilir
81
+ render(<FileUpload accept=".pdf" />)
82
+ uploadFiles(makeFile("contract.pdf", ""))
83
+ expectAccepted("contract.pdf")
84
+ })
85
+
86
+ it('uzantı token\'ı eşleşmeyen dosyayı reddeder: ".pdf" + notes.txt', () => {
87
+ render(<FileUpload accept=".pdf" />)
88
+ uploadFiles(makeFile("notes.txt", "text/plain"))
89
+ expectRejected("notes.txt")
90
+ })
91
+
92
+ it('MIME token\'ı: "application/pdf" type ile birebir eşleşir', () => {
93
+ render(<FileUpload accept="application/pdf" />)
94
+ uploadFiles(makeFile("report.pdf", "application/pdf"))
95
+ expectAccepted("report.pdf")
96
+ })
97
+
98
+ it('MIME token\'ı eşleşmeyeni reddeder: "application/pdf" + text/plain', () => {
99
+ render(<FileUpload accept="application/pdf" />)
100
+ uploadFiles(makeFile("notes.txt", "text/plain"))
101
+ expectRejected("notes.txt")
102
+ })
103
+
104
+ it('wildcard: "image/*" image/png\'yi kabul eder', () => {
105
+ render(<FileUpload accept="image/*" />)
106
+ uploadFiles(makeFile("photo.png", "image/png"))
107
+ expectAccepted("photo.png")
108
+ })
109
+
110
+ it('wildcard: "image/*" video/mp4\'ü reddeder', () => {
111
+ render(<FileUpload accept="image/*" />)
112
+ uploadFiles(makeFile("clip.mp4", "video/mp4"))
113
+ expectRejected("clip.mp4")
114
+ })
115
+
116
+ it('karışık liste: "image/*,.pdf,.docx" — png, pdf ve docx kabul; exe red', () => {
117
+ render(<FileUpload accept="image/*,.pdf,.docx" maxFiles={5} />)
118
+ uploadFiles(
119
+ makeFile("photo.png", "image/png"),
120
+ makeFile("report.pdf", "application/pdf"),
121
+ makeFile(
122
+ "spec.docx",
123
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
124
+ )
125
+ )
126
+ expectAccepted("photo.png")
127
+ expect(screen.getByText("report.pdf")).toBeInTheDocument()
128
+ expect(screen.getByText("spec.docx")).toBeInTheDocument()
129
+
130
+ uploadFiles(makeFile("app.exe", "application/x-msdownload"))
131
+ expect(
132
+ screen.getByText(/app\.exe: File type not supported/)
133
+ ).toBeInTheDocument()
134
+ // exe listeye girmedi (hata satırındaki metin liste satırı değil)
135
+ expect(screen.queryByText("app.exe", { exact: true })).not.toBeInTheDocument()
136
+ })
137
+
138
+ it('varsayılan "*" her dosyayı kabul eder', () => {
139
+ render(<FileUpload />)
140
+ uploadFiles(makeFile("anything.bin", "application/octet-stream"))
141
+ expectAccepted("anything.bin")
142
+ })
143
+
144
+ it('"*/*" tam-geçirgen token olarak kabul eder', () => {
145
+ render(<FileUpload accept="*/*" />)
146
+ uploadFiles(makeFile("anything.bin", "application/octet-stream"))
147
+ expectAccepted("anything.bin")
148
+ })
149
+
150
+ it('boşluklu liste tolere edilir: " image/* , .pdf "', () => {
151
+ render(<FileUpload accept=" image/* , .pdf " />)
152
+ uploadFiles(makeFile("report.pdf", "application/pdf"))
153
+ expectAccepted("report.pdf")
154
+ })
155
+ })
156
+
157
+ describe("FileUpload simüle progress / error yarışı (#378)", () => {
158
+ let consoleErrorSpy: jest.SpyInstance
159
+
160
+ beforeEach(() => {
161
+ jest.useFakeTimers()
162
+ // Component catch'te console.error('Upload failed:', ...) loglar — çıktıyı sustur
163
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
164
+ })
165
+
166
+ afterEach(() => {
167
+ jest.clearAllTimers()
168
+ jest.useRealTimers()
169
+ consoleErrorSpy.mockRestore()
170
+ })
171
+
172
+ it("anında reddeden onUpload'da error korunur — son tick 'Done' yazamaz", async () => {
173
+ const onUpload = jest.fn().mockRejectedValue(new Error("backend rejected"))
174
+ render(<FileUpload onUpload={onUpload} />)
175
+
176
+ uploadFiles(makeFile("data.bin", "application/octet-stream"))
177
+ // Reddin microtask'ı işlensin → catch 'error' state'ini yazar
178
+ await act(async () => {})
179
+ expect(screen.getByText("Error")).toBeInTheDocument()
180
+
181
+ // Simülasyonun fazlasıyla tamamlanacağı süreyi geç — 'error' EZİLMEMELİ
182
+ await act(async () => {
183
+ jest.advanceTimersByTime(5000)
184
+ })
185
+ expect(screen.getByText("Error")).toBeInTheDocument()
186
+ expect(screen.queryByText("Done")).not.toBeInTheDocument()
187
+ expect(onUpload).toHaveBeenCalledTimes(1)
188
+ })
189
+
190
+ it("geç (300ms) reddeden onUpload'da da error korunur", async () => {
191
+ const onUpload = jest.fn(
192
+ () =>
193
+ new Promise<void>((_, reject) => {
194
+ setTimeout(() => reject(new Error("late fail")), 300)
195
+ })
196
+ )
197
+ render(<FileUpload onUpload={onUpload} />)
198
+
199
+ uploadFiles(makeFile("data.bin", "application/octet-stream"))
200
+ // 300 ms: red tetiklenir (arada 200 ms'lik ilk simülasyon tick'i de işler)
201
+ await act(async () => {
202
+ jest.advanceTimersByTime(300)
203
+ })
204
+ await act(async () => {})
205
+ expect(screen.getByText("Error")).toBeInTheDocument()
206
+
207
+ await act(async () => {
208
+ jest.advanceTimersByTime(5000)
209
+ })
210
+ expect(screen.getByText("Error")).toBeInTheDocument()
211
+ expect(screen.queryByText("Done")).not.toBeInTheDocument()
212
+ })
213
+
214
+ it("başarılı onUpload'da simülasyon 'Done'a ulaşır (regresyon koruması)", async () => {
215
+ const onUpload = jest.fn().mockResolvedValue(undefined)
216
+ render(<FileUpload onUpload={onUpload} />)
217
+
218
+ uploadFiles(makeFile("data.bin", "application/octet-stream"))
219
+ await act(async () => {})
220
+ await act(async () => {
221
+ jest.advanceTimersByTime(5000)
222
+ })
223
+ expect(screen.getByText("Done")).toBeInTheDocument()
224
+ expect(screen.queryByText("Error")).not.toBeInTheDocument()
225
+ })
226
+ })