@moontra/moonui 6.16.0 → 6.18.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.16.0",
3
+ "version": "6.18.0",
4
4
  "description": "Premium React component library with modern design and customization",
5
5
  "author": "MoonUI",
6
6
  "license": "MIT",
@@ -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
+ })
@@ -0,0 +1,95 @@
1
+ /**
2
+ * #354 — `--primary-subtle` token çifti ve kontrast değişmezi.
3
+ *
4
+ * BULGU: pro Sidebar'ın aktif öğesi `bg-primary/10 text-primary` kullanıyordu.
5
+ * Light temada bu bileşim **4.28:1** veriyor (WCAG AA metin için 4.5 ister) ve
6
+ * docs kabuğu bu Sidebar'ı kullandığı için ihlal HER sayfada basılıyordu.
7
+ * Dark tarafta `--primary` neredeyse beyaz olduğu için sorun yoktu (15.73:1).
8
+ *
9
+ * KÖK NEDEN: #313/#314/#323'te kurulan `--*-subtle` ailesi (yumuşak tint + koyu
10
+ * metin) `destructive/success/warning/info` için vardı ama **primary EKSİKTİ** —
11
+ * bu yüzden Sidebar ham `primary/10` kompozisyonuna düşmüştü.
12
+ *
13
+ * Bu test hem token'ların VARLIĞINI hem de KONTRAST oranlarını sabitler: değer
14
+ * değiştirilirse ve oran 4.5'in altına düşerse kırmızı olur.
15
+ */
16
+ import * as fs from 'fs'
17
+ import * as path from 'path'
18
+
19
+ const KOK = path.resolve(__dirname, '../../..')
20
+ const tokens = fs.readFileSync(path.join(KOK, 'styles/tokens.css'), 'utf8')
21
+ const preset = fs.readFileSync(path.resolve(KOK, '../tailwind-preset.js'), 'utf8')
22
+
23
+ /** "H S% L%" biçimindeki token değerini oku (light = :root, dark = .dark bloğu) */
24
+ function tokenOku(ad: string, blok: 'light' | 'dark'): string | null {
25
+ const kaynak =
26
+ blok === 'dark'
27
+ ? tokens.slice(tokens.indexOf('.dark'))
28
+ : tokens.slice(0, tokens.indexOf('.dark'))
29
+ return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
30
+ }
31
+
32
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
33
+ h /= 360
34
+ s /= 100
35
+ l /= 100
36
+ const f = (n: number) => {
37
+ const k = (n + h * 12) % 12
38
+ const a = s * Math.min(l, 1 - l)
39
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
40
+ }
41
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
42
+ }
43
+
44
+ function bagilParlaklik([r, g, b]: [number, number, number]): number {
45
+ const c = (v: number) => {
46
+ const x = v / 255
47
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
48
+ }
49
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
50
+ }
51
+
52
+ function kontrast(a: string, b: string): number {
53
+ const ayrıştır = (v: string): [number, number, number] => {
54
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
55
+ return hslToRgb(h, s, l)
56
+ }
57
+ const la = bagilParlaklik(ayrıştır(a))
58
+ const lb = bagilParlaklik(ayrıştır(b))
59
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
60
+ return (hi + 0.05) / (lo + 0.05)
61
+ }
62
+
63
+ describe('#354 --primary-subtle token çifti', () => {
64
+ it('hesaplayıcı DOĞRU — bilinen bir çift beklenen oranı veriyor', () => {
65
+ // Sessiz-yeşil koruması: kontrast fonksiyonu bozulursa aşağıdaki testler
66
+ // anlamsız sayılarla yeşil geçebilir. `--info-subtle` çifti tokens.css'te
67
+ // 9.52:1 diye belgelenmiş; hesaplayıcı onu yeniden üretmeli.
68
+ const oran = kontrast(tokenOku('info-subtle', 'light')!, tokenOku('info-subtle-foreground', 'light')!)
69
+ expect(oran).toBeGreaterThan(9.3)
70
+ expect(oran).toBeLessThan(9.7)
71
+ })
72
+
73
+ it.each(['light', 'dark'] as const)('%s temada token çifti TANIMLI', (blok) => {
74
+ expect(tokenOku('primary-subtle', blok)).toBeTruthy()
75
+ expect(tokenOku('primary-subtle-foreground', blok)).toBeTruthy()
76
+ })
77
+
78
+ it.each(['light', 'dark'] as const)('%s temada kontrast WCAG AA (4.5:1) geçiyor', (blok) => {
79
+ const oran = kontrast(tokenOku('primary-subtle', blok)!, tokenOku('primary-subtle-foreground', blok)!)
80
+ expect(oran).toBeGreaterThanOrEqual(4.5)
81
+ })
82
+
83
+ it('light zemin, bugünkü `primary/10` görünümünü KORUYOR', () => {
84
+ // Zemin değişirse aktif öğenin görünümü değişir; bu bilinçli bir karar olmalı.
85
+ // #e7f0fe = beyaz üstüne %10 primary.
86
+ expect(tokenOku('primary-subtle', 'light')).toBe('217 92% 95%')
87
+ })
88
+
89
+ it('preset sınıfları ÜRETİYOR (token tanımlamak yetmez)', () => {
90
+ // Bu oturumda `theme.extend.borderColor`ın nested renkleri GÖLGELEDİĞİ ve
91
+ // sınıfın sessizce hiç CSS üretmediği görülmüştü (#313/#314/#323).
92
+ expect(preset).toContain('hsl(var(--primary-subtle) / <alpha-value>)')
93
+ expect(preset).toContain('hsl(var(--primary-subtle-foreground) / <alpha-value>)')
94
+ })
95
+ })