@cosmicdrift/kumiko-renderer-web 0.1.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 +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- package/src/tokens.ts +63 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { createStaticLocaleResolver, LocaleProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { render as _render, screen, waitFor } from "@testing-library/react";
|
|
5
|
+
import userEvent from "@testing-library/user-event";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { describe, expect, test, vi } from "vitest";
|
|
8
|
+
import { LanguageSwitcher } from "../layout/language-switcher";
|
|
9
|
+
|
|
10
|
+
// Tests greifen den LanguageSwitcher mit einem stateful Stub-Resolver
|
|
11
|
+
// (setLocale + subscribe) UND einem stateless Resolver, um die zwei
|
|
12
|
+
// Verzweigungen abzudecken: Switcher rendert nur wenn setLocale da ist.
|
|
13
|
+
// Radix-DropdownMenu öffnet auf pointerdown, daher userEvent statt
|
|
14
|
+
// fireEvent.click.
|
|
15
|
+
|
|
16
|
+
function makeStatefulResolver(initial: string): LocaleResolver {
|
|
17
|
+
let current = initial;
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
return {
|
|
20
|
+
translate: (key: string) => key,
|
|
21
|
+
locale: () => current,
|
|
22
|
+
timeZone: () => "UTC",
|
|
23
|
+
subscribe: (l: () => void) => {
|
|
24
|
+
listeners.add(l);
|
|
25
|
+
return () => {
|
|
26
|
+
listeners.delete(l);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
setLocale: vi.fn((next: string) => {
|
|
30
|
+
current = next;
|
|
31
|
+
for (const l of listeners) l();
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderWithResolver(resolver: LocaleResolver, ui: ReactNode) {
|
|
37
|
+
return _render(<LocaleProvider resolver={resolver}>{ui}</LocaleProvider>);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const locales = [
|
|
41
|
+
{ code: "de", label: "Deutsch" },
|
|
42
|
+
{ code: "en", label: "English" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
describe("LanguageSwitcher", () => {
|
|
46
|
+
test("renders nothing when resolver has no setLocale", () => {
|
|
47
|
+
const resolver = createStaticLocaleResolver();
|
|
48
|
+
const { container } = renderWithResolver(
|
|
49
|
+
resolver,
|
|
50
|
+
<LanguageSwitcher locales={locales} testId="lang" />,
|
|
51
|
+
);
|
|
52
|
+
expect(container.firstChild).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("active locale shown via shorthand", () => {
|
|
56
|
+
const resolver = makeStatefulResolver("de");
|
|
57
|
+
renderWithResolver(resolver, <LanguageSwitcher locales={locales} testId="lang" />);
|
|
58
|
+
// Trigger zeigt den Locale-Code im DOM (Tailwind uppercased ihn nur
|
|
59
|
+
// visuell via CSS — der Text-Knoten bleibt lowercase). Das passt:
|
|
60
|
+
// getByText sieht den DOM-Text.
|
|
61
|
+
expect(screen.getByText("de")).toBeTruthy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("opens dropdown and lists all locales with active marker", async () => {
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
const resolver = makeStatefulResolver("de");
|
|
67
|
+
renderWithResolver(resolver, <LanguageSwitcher locales={locales} testId="lang" />);
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "Sprache" }));
|
|
69
|
+
expect(screen.getByText("Deutsch")).toBeTruthy();
|
|
70
|
+
expect(screen.getByText("English")).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("clicking a locale calls resolver.setLocale", async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
const resolver = makeStatefulResolver("de");
|
|
76
|
+
renderWithResolver(resolver, <LanguageSwitcher locales={locales} testId="lang" />);
|
|
77
|
+
await user.click(screen.getByRole("button", { name: "Sprache" }));
|
|
78
|
+
await user.click(screen.getByText("English"));
|
|
79
|
+
expect(resolver.setLocale).toHaveBeenCalledWith("en");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("matches active locale via language-root (de-AT → de)", async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
const resolver = makeStatefulResolver("de-AT");
|
|
85
|
+
renderWithResolver(resolver, <LanguageSwitcher locales={locales} testId="lang" />);
|
|
86
|
+
// Trigger zeigt "DE" (aus de-AT abgeleitet) — der active marker im
|
|
87
|
+
// Dropdown muss bei "Deutsch" sitzen, nicht bei "English".
|
|
88
|
+
await user.click(screen.getByRole("button", { name: "Sprache" }));
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
// Radix-CheckboxItem markiert active via aria-checked="true". Der
|
|
91
|
+
// Check-Icon (lucide) sitzt im ItemIndicator und ist nur sichtbar
|
|
92
|
+
// wenn checked — die ARIA-Variante ist robust gegen Rendering-
|
|
93
|
+
// Tricks.
|
|
94
|
+
const deItem = screen.getByText("Deutsch").closest('[role="menuitemcheckbox"]');
|
|
95
|
+
expect(deItem?.getAttribute("aria-checked")).toBe("true");
|
|
96
|
+
const enItem = screen.getByText("English").closest('[role="menuitemcheckbox"]');
|
|
97
|
+
expect(enItem?.getAttribute("aria-checked")).toBe("false");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// MoneyInput hat genug Custom-Logik (focus-aware Format, +/- Buttons,
|
|
4
|
+
// Locale-Parse), dass der Switch-Case-Test in primitives.test nicht
|
|
5
|
+
// reicht. Hier pinnen wir die Verträge:
|
|
6
|
+
// - Blur-View formatiert mit Currency-Symbol + Tausender-Trenner.
|
|
7
|
+
// - Focus-View liefert raw editable string, ohne Grouping.
|
|
8
|
+
// - +/- Buttons mutieren Canonical-Cents direkt (nicht major-units).
|
|
9
|
+
// - Blur mit Müll-Input verwirft den Wert (kein corrupt-set).
|
|
10
|
+
// - Verschiedene Currencies → korrekte Decimal-Stellen (JPY=0).
|
|
11
|
+
|
|
12
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
13
|
+
import { describe, expect, test, vi } from "vitest";
|
|
14
|
+
import { MoneyInput, parseLocaleNumber } from "../primitives/money-input";
|
|
15
|
+
|
|
16
|
+
describe("MoneyInput", () => {
|
|
17
|
+
test("blur-view: de-DE EUR zeigt €-Symbol + Punkt-Tausender + Komma-Decimal", () => {
|
|
18
|
+
render(
|
|
19
|
+
<MoneyInput
|
|
20
|
+
id="eur"
|
|
21
|
+
name="eur"
|
|
22
|
+
value={123456}
|
|
23
|
+
onChange={() => undefined}
|
|
24
|
+
currency="EUR"
|
|
25
|
+
locale="de-DE"
|
|
26
|
+
/>,
|
|
27
|
+
);
|
|
28
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
29
|
+
expect(input.value).toBe("1.234,56 €");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("blur-view: en-US USD zeigt $-Prefix + Komma-Tausender + Punkt-Decimal", () => {
|
|
33
|
+
render(
|
|
34
|
+
<MoneyInput
|
|
35
|
+
id="usd"
|
|
36
|
+
name="usd"
|
|
37
|
+
value={2599}
|
|
38
|
+
onChange={() => undefined}
|
|
39
|
+
currency="USD"
|
|
40
|
+
locale="en-US"
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
44
|
+
expect(input.value).toBe("$25.99");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("blur-view: ja-JP JPY zeigt Yen-Symbol ohne Decimals", () => {
|
|
48
|
+
render(
|
|
49
|
+
<MoneyInput
|
|
50
|
+
id="jpy"
|
|
51
|
+
name="jpy"
|
|
52
|
+
value={150000}
|
|
53
|
+
onChange={() => undefined}
|
|
54
|
+
currency="JPY"
|
|
55
|
+
locale="ja-JP"
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
59
|
+
// ja-JP rendert "¥150,000" (Fullwidth-Yen). 0 Decimals weil JPY
|
|
60
|
+
// keine Subunits hat — 150.000 cents → 150.000 Yen direkt.
|
|
61
|
+
expect(input.value).toContain("150,000");
|
|
62
|
+
expect(input.value).not.toContain(".00");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("focus-view: zeigt raw editable string (kein Grouping, kein Symbol)", () => {
|
|
66
|
+
render(
|
|
67
|
+
<MoneyInput
|
|
68
|
+
id="eur"
|
|
69
|
+
name="eur"
|
|
70
|
+
value={123456}
|
|
71
|
+
onChange={() => undefined}
|
|
72
|
+
currency="EUR"
|
|
73
|
+
locale="de-DE"
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
77
|
+
fireEvent.focus(input);
|
|
78
|
+
// Locale ist de-DE → Komma als Decimal-Separator, kein Punkt-Trenner
|
|
79
|
+
expect(input.value).toBe("1234,56");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("blur mit gültigem Edit-String: onChange feuert Cents", () => {
|
|
83
|
+
const onChange = vi.fn();
|
|
84
|
+
render(
|
|
85
|
+
<MoneyInput
|
|
86
|
+
id="eur"
|
|
87
|
+
name="eur"
|
|
88
|
+
value={1000}
|
|
89
|
+
onChange={onChange}
|
|
90
|
+
currency="EUR"
|
|
91
|
+
locale="de-DE"
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
95
|
+
fireEvent.focus(input);
|
|
96
|
+
fireEvent.change(input, { target: { value: "12,34" } });
|
|
97
|
+
fireEvent.blur(input);
|
|
98
|
+
expect(onChange).toHaveBeenCalledWith(1234);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("blur mit leerem String: onChange(undefined) räumt den Wert", () => {
|
|
102
|
+
const onChange = vi.fn();
|
|
103
|
+
render(
|
|
104
|
+
<MoneyInput
|
|
105
|
+
id="eur"
|
|
106
|
+
name="eur"
|
|
107
|
+
value={1000}
|
|
108
|
+
onChange={onChange}
|
|
109
|
+
currency="EUR"
|
|
110
|
+
locale="de-DE"
|
|
111
|
+
/>,
|
|
112
|
+
);
|
|
113
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
114
|
+
fireEvent.focus(input);
|
|
115
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
116
|
+
fireEvent.blur(input);
|
|
117
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("blur mit korruptem Input (Buchstaben): onChange wird NICHT gerufen", () => {
|
|
121
|
+
const onChange = vi.fn();
|
|
122
|
+
render(
|
|
123
|
+
<MoneyInput
|
|
124
|
+
id="eur"
|
|
125
|
+
name="eur"
|
|
126
|
+
value={1000}
|
|
127
|
+
onChange={onChange}
|
|
128
|
+
currency="EUR"
|
|
129
|
+
locale="de-DE"
|
|
130
|
+
/>,
|
|
131
|
+
);
|
|
132
|
+
const input = screen.getByRole("textbox") as HTMLInputElement;
|
|
133
|
+
fireEvent.focus(input);
|
|
134
|
+
fireEvent.change(input, { target: { value: "abc" } });
|
|
135
|
+
fireEvent.blur(input);
|
|
136
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("+ Button: addiert 1 Major-Unit (=100 cents bei EUR) zum Canonical-Wert", () => {
|
|
140
|
+
const onChange = vi.fn();
|
|
141
|
+
render(
|
|
142
|
+
<MoneyInput
|
|
143
|
+
id="eur"
|
|
144
|
+
name="eur"
|
|
145
|
+
value={1000}
|
|
146
|
+
onChange={onChange}
|
|
147
|
+
currency="EUR"
|
|
148
|
+
locale="de-DE"
|
|
149
|
+
/>,
|
|
150
|
+
);
|
|
151
|
+
fireEvent.click(screen.getByRole("button", { name: "+" }));
|
|
152
|
+
expect(onChange).toHaveBeenCalledWith(1100);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("− Button: subtrahiert 1 Major-Unit", () => {
|
|
156
|
+
const onChange = vi.fn();
|
|
157
|
+
render(
|
|
158
|
+
<MoneyInput
|
|
159
|
+
id="eur"
|
|
160
|
+
name="eur"
|
|
161
|
+
value={1000}
|
|
162
|
+
onChange={onChange}
|
|
163
|
+
currency="EUR"
|
|
164
|
+
locale="de-DE"
|
|
165
|
+
/>,
|
|
166
|
+
);
|
|
167
|
+
fireEvent.click(screen.getByRole("button", { name: "−" }));
|
|
168
|
+
expect(onChange).toHaveBeenCalledWith(900);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("+ Button bei leerem Wert: startet bei 0 + 1 Major-Unit", () => {
|
|
172
|
+
const onChange = vi.fn();
|
|
173
|
+
render(
|
|
174
|
+
<MoneyInput id="eur" name="eur" value="" onChange={onChange} currency="EUR" locale="de-DE" />,
|
|
175
|
+
);
|
|
176
|
+
fireEvent.click(screen.getByRole("button", { name: "+" }));
|
|
177
|
+
expect(onChange).toHaveBeenCalledWith(100);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("+ Button bei JPY: addiert 1 Yen (1 cent, weil JPY 0 decimals hat)", () => {
|
|
181
|
+
const onChange = vi.fn();
|
|
182
|
+
render(
|
|
183
|
+
<MoneyInput
|
|
184
|
+
id="jpy"
|
|
185
|
+
name="jpy"
|
|
186
|
+
value={500}
|
|
187
|
+
onChange={onChange}
|
|
188
|
+
currency="JPY"
|
|
189
|
+
locale="ja-JP"
|
|
190
|
+
/>,
|
|
191
|
+
);
|
|
192
|
+
fireEvent.click(screen.getByRole("button", { name: "+" }));
|
|
193
|
+
// factor=1 (10^0), bump(1) → +1 statt +100
|
|
194
|
+
expect(onChange).toHaveBeenCalledWith(501);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("parseLocaleNumber (strict-Negative)", () => {
|
|
199
|
+
test("de-DE: erkennt Komma als Decimal", () => {
|
|
200
|
+
expect(parseLocaleNumber("12,34", "de-DE")).toBe(12.34);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("en-US: erkennt Punkt als Decimal", () => {
|
|
204
|
+
expect(parseLocaleNumber("12.34", "en-US")).toBe(12.34);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("Tausender-Trenner werden entfernt", () => {
|
|
208
|
+
expect(parseLocaleNumber("1.234,56", "de-DE")).toBe(1234.56);
|
|
209
|
+
expect(parseLocaleNumber("1,234.56", "en-US")).toBe(1234.56);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("negativ am Anfang: wird als negative Zahl geparst", () => {
|
|
213
|
+
expect(parseLocaleNumber("-12,34", "de-DE")).toBe(-12.34);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("Minus mitten in der Zahl: NaN (strict — kein Silent-Fallback)", () => {
|
|
217
|
+
// "1-23" könnte naiv als -123 interpretiert werden; das war der
|
|
218
|
+
// Bug der vor dem Strict-Check existierte und vertippte Inputs zu
|
|
219
|
+
// falschen Beträgen gemacht hat.
|
|
220
|
+
expect(parseLocaleNumber("1-23", "de-DE")).toBeNaN();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("Buchstaben oder Sonderzeichen: NaN", () => {
|
|
224
|
+
expect(parseLocaleNumber("abc", "de-DE")).toBeNaN();
|
|
225
|
+
expect(parseLocaleNumber("12,3a", "de-DE")).toBeNaN();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("Leerstring oder reines Decimal: NaN", () => {
|
|
229
|
+
expect(parseLocaleNumber("", "de-DE")).toBeNaN();
|
|
230
|
+
expect(parseLocaleNumber(",", "de-DE")).toBeNaN();
|
|
231
|
+
});
|
|
232
|
+
});
|