@cwygoda/service-catalog-ui 1.0.1

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.
Files changed (55) hide show
  1. package/dist/__mocks__/app-environment.d.ts +4 -0
  2. package/dist/__mocks__/app-environment.js +4 -0
  3. package/dist/__mocks__/app-state.d.ts +12 -0
  4. package/dist/__mocks__/app-state.js +10 -0
  5. package/dist/adapters/index.d.ts +1 -0
  6. package/dist/adapters/index.js +1 -0
  7. package/dist/adapters/static-json.adapter.d.ts +2 -0
  8. package/dist/adapters/static-json.adapter.js +34 -0
  9. package/dist/components/BpmnDiagram.svelte +360 -0
  10. package/dist/components/BpmnDiagram.svelte.d.ts +7 -0
  11. package/dist/components/Breadcrumbs.svelte +32 -0
  12. package/dist/components/Breadcrumbs.svelte.d.ts +11 -0
  13. package/dist/components/DomainCard.svelte +29 -0
  14. package/dist/components/DomainCard.svelte.d.ts +9 -0
  15. package/dist/components/DomainCard.test.d.ts +1 -0
  16. package/dist/components/DomainCard.test.js +45 -0
  17. package/dist/components/Header.svelte +110 -0
  18. package/dist/components/Header.svelte.d.ts +3 -0
  19. package/dist/components/NavModeToggle.svelte +28 -0
  20. package/dist/components/NavModeToggle.svelte.d.ts +18 -0
  21. package/dist/components/NavTree.svelte +182 -0
  22. package/dist/components/NavTree.svelte.d.ts +9 -0
  23. package/dist/components/ServiceCard.svelte +26 -0
  24. package/dist/components/ServiceCard.svelte.d.ts +7 -0
  25. package/dist/components/ServiceCard.test.d.ts +1 -0
  26. package/dist/components/ServiceCard.test.js +36 -0
  27. package/dist/components/ServiceGraph.svelte +348 -0
  28. package/dist/components/ServiceGraph.svelte.d.ts +10 -0
  29. package/dist/components/ThemeToggle.svelte +29 -0
  30. package/dist/components/ThemeToggle.svelte.d.ts +18 -0
  31. package/dist/components/ThemeToggle.test.d.ts +1 -0
  32. package/dist/components/ThemeToggle.test.js +52 -0
  33. package/dist/components/UseCaseCard.svelte +57 -0
  34. package/dist/components/UseCaseCard.svelte.d.ts +7 -0
  35. package/dist/components/UseCaseCard.test.d.ts +1 -0
  36. package/dist/components/UseCaseCard.test.js +87 -0
  37. package/dist/components/index.d.ts +10 -0
  38. package/dist/components/index.js +10 -0
  39. package/dist/index.d.ts +5 -0
  40. package/dist/index.js +5 -0
  41. package/dist/ports/catalog.port.d.ts +5 -0
  42. package/dist/ports/catalog.port.js +1 -0
  43. package/dist/ports/index.d.ts +1 -0
  44. package/dist/ports/index.js +1 -0
  45. package/dist/stores/index.d.ts +3 -0
  46. package/dist/stores/index.js +2 -0
  47. package/dist/stores/nav-mode.svelte.d.ts +7 -0
  48. package/dist/stores/nav-mode.svelte.js +32 -0
  49. package/dist/stores/theme.svelte.d.ts +8 -0
  50. package/dist/stores/theme.svelte.js +61 -0
  51. package/dist/utils/fetch-catalog.d.ts +6 -0
  52. package/dist/utils/fetch-catalog.js +26 -0
  53. package/dist/utils/index.d.ts +1 -0
  54. package/dist/utils/index.js +1 -0
  55. package/package.json +54 -0
@@ -0,0 +1,4 @@
1
+ export declare const browser = true;
2
+ export declare const dev = true;
3
+ export declare const building = false;
4
+ export declare const version = "test";
@@ -0,0 +1,4 @@
1
+ export const browser = true;
2
+ export const dev = true;
3
+ export const building = false;
4
+ export const version = 'test';
@@ -0,0 +1,12 @@
1
+ export declare const page: {
2
+ url: URL;
3
+ params: {};
4
+ route: {
5
+ id: string;
6
+ };
7
+ status: number;
8
+ error: null;
9
+ data: {};
10
+ state: {};
11
+ form: null;
12
+ };
@@ -0,0 +1,10 @@
1
+ export const page = {
2
+ url: new URL('http://localhost:5173/'),
3
+ params: {},
4
+ route: { id: '/' },
5
+ status: 200,
6
+ error: null,
7
+ data: {},
8
+ state: {},
9
+ form: null,
10
+ };
@@ -0,0 +1 @@
1
+ export { createStaticJsonAdapter } from './static-json.adapter.js';
@@ -0,0 +1 @@
1
+ export { createStaticJsonAdapter } from './static-json.adapter.js';
@@ -0,0 +1,2 @@
1
+ import type { CatalogPort } from '../ports/catalog.port.js';
2
+ export declare function createStaticJsonAdapter(baseUrl?: string): CatalogPort;
@@ -0,0 +1,34 @@
1
+ import { Value } from '@sinclair/typebox/value';
2
+ import { findService } from '@cwygoda/service-catalog-core/domain';
3
+ import { CatalogSchema } from '@cwygoda/service-catalog-core/schemas';
4
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
5
+ export function createStaticJsonAdapter(baseUrl = '') {
6
+ let cache = null;
7
+ async function fetchCatalog() {
8
+ const now = Date.now();
9
+ if (cache && now - cache.timestamp < CACHE_TTL_MS) {
10
+ return cache.data;
11
+ }
12
+ const response = await fetch(`${baseUrl}/catalog.json`);
13
+ if (!response.ok) {
14
+ throw new Error(`Failed to fetch catalog: ${String(response.status)}`);
15
+ }
16
+ const data = await response.json();
17
+ if (!Value.Check(CatalogSchema, data)) {
18
+ const errors = [...Value.Errors(CatalogSchema, data)];
19
+ const message = errors.map((e) => `${e.path}: ${e.message}`).join('; ');
20
+ throw new Error(`Invalid catalog data: ${message}`);
21
+ }
22
+ cache = { data: data, timestamp: now };
23
+ return cache.data;
24
+ }
25
+ return {
26
+ async getCatalog() {
27
+ return fetchCatalog();
28
+ },
29
+ async getService(id) {
30
+ const catalog = await fetchCatalog();
31
+ return findService(catalog, id);
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,360 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { browser } from '$app/environment';
4
+
5
+ interface Props {
6
+ xml: string;
7
+ interactive?: boolean;
8
+ }
9
+
10
+ interface Bounds {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ interface BpmnCanvas {
18
+ zoom(level: 'fit-viewport' | number): void;
19
+ zoom(): number; // get current zoom
20
+ viewbox(): Bounds & { scale: number; inner: Bounds; outer: Bounds };
21
+ viewbox(bounds: Partial<Bounds>): void;
22
+ }
23
+
24
+ const FIT_PADDING = 40; // pixels of padding around diagram when fitting
25
+
26
+ interface BpmnViewer {
27
+ importXML: (xml: string) => Promise<unknown>;
28
+ get: (name: string) => unknown;
29
+ destroy?: () => void;
30
+ }
31
+
32
+ let { xml, interactive = false }: Props = $props();
33
+
34
+ let wrapper: HTMLDivElement;
35
+ let container: HTMLDivElement;
36
+ let viewer: BpmnViewer | null = $state(null);
37
+ let error: string | null = $state(null);
38
+ let ready = $state(false);
39
+
40
+ // Fullscreen state
41
+ let isFullscreen = $state(false);
42
+
43
+ function toggleFullscreen() {
44
+ if (!browser) return;
45
+ if (!document.fullscreenElement) {
46
+ void wrapper.requestFullscreen();
47
+ } else {
48
+ void document.exitFullscreen();
49
+ }
50
+ }
51
+
52
+ function handleFullscreenChange() {
53
+ isFullscreen = !!document.fullscreenElement;
54
+ // Re-fit diagram when entering/exiting fullscreen
55
+ if (ready) {
56
+ setTimeout(fitWithPadding, 100);
57
+ }
58
+ }
59
+
60
+ function getCanvas(): BpmnCanvas | null {
61
+ if (!viewer) return null;
62
+ return viewer.get('canvas') as BpmnCanvas;
63
+ }
64
+
65
+ function fitWithPadding() {
66
+ const canvas = getCanvas();
67
+ if (!canvas) return;
68
+
69
+ // First fit to viewport to get the content bounds
70
+ canvas.zoom('fit-viewport');
71
+
72
+ // Get current viewbox info - inner is the diagram bounds, outer is container
73
+ const vb = canvas.viewbox();
74
+ const inner = vb.inner;
75
+ const outer = vb.outer;
76
+
77
+ // Calculate scale to fit content with padding
78
+ const availableWidth = outer.width - FIT_PADDING * 2;
79
+ const availableHeight = outer.height - FIT_PADDING * 2;
80
+ const scaleX = availableWidth / inner.width;
81
+ const scaleY = availableHeight / inner.height;
82
+ const scale = Math.min(scaleX, scaleY);
83
+
84
+ // Calculate centered viewbox dimensions
85
+ const viewWidth = outer.width / scale;
86
+ const viewHeight = outer.height / scale;
87
+
88
+ // Center the content
89
+ const centerX = inner.x + inner.width / 2;
90
+ const centerY = inner.y + inner.height / 2;
91
+
92
+ canvas.viewbox({
93
+ x: centerX - viewWidth / 2,
94
+ y: centerY - viewHeight / 2,
95
+ width: viewWidth,
96
+ height: viewHeight,
97
+ });
98
+ }
99
+
100
+ function zoomIn() {
101
+ const canvas = getCanvas();
102
+ if (!canvas) return;
103
+ const current = canvas.zoom();
104
+ canvas.zoom(current * 1.25);
105
+ }
106
+
107
+ function zoomOut() {
108
+ const canvas = getCanvas();
109
+ if (!canvas) return;
110
+ const current = canvas.zoom();
111
+ canvas.zoom(current * 0.8);
112
+ }
113
+
114
+ function resetZoom() {
115
+ fitWithPadding();
116
+ }
117
+
118
+ onMount(async () => {
119
+ if (!browser || !xml) return;
120
+
121
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
122
+
123
+ try {
124
+ // Dynamic import to avoid SSR issues
125
+ const { default: BpmnViewer } = await import('bpmn-js/lib/NavigatedViewer');
126
+
127
+ viewer = new BpmnViewer({
128
+ container,
129
+ keyboard: { bindTo: interactive ? document : undefined },
130
+ }) as BpmnViewer;
131
+
132
+ await viewer.importXML(xml);
133
+
134
+ // Fit diagram to container with padding
135
+ fitWithPadding();
136
+ ready = true;
137
+ } catch (e) {
138
+ error = e instanceof Error ? e.message : 'Failed to render BPMN diagram';
139
+ console.error('BPMN render error:', e);
140
+ }
141
+ });
142
+
143
+ onDestroy(() => {
144
+ if (browser) {
145
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
146
+ }
147
+ if (viewer?.destroy) {
148
+ viewer.destroy();
149
+ }
150
+ });
151
+
152
+ $effect(() => {
153
+ // Re-import when xml changes
154
+ if (browser && viewer && xml) {
155
+ viewer
156
+ .importXML(xml)
157
+ .then(() => {
158
+ fitWithPadding();
159
+ })
160
+ .catch((e: unknown) => {
161
+ error = e instanceof Error ? e.message : 'Failed to update diagram';
162
+ });
163
+ }
164
+ });
165
+ </script>
166
+
167
+ {#if error}
168
+ <div
169
+ class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
170
+ >
171
+ <p class="font-medium">Failed to render diagram</p>
172
+ <p class="mt-1 text-sm">{error}</p>
173
+ </div>
174
+ {:else}
175
+ <div
176
+ bind:this={wrapper}
177
+ class="bpmn-wrapper relative {isFullscreen ? 'bg-white dark:bg-gray-900' : ''}"
178
+ >
179
+ <div
180
+ bind:this={container}
181
+ class="bpmn-container w-full rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
182
+ class:cursor-grab={interactive}
183
+ class:h-96={!isFullscreen}
184
+ class:h-screen={isFullscreen}
185
+ ></div>
186
+
187
+ <!-- Zoom controls -->
188
+ {#if ready}
189
+ <div
190
+ class="absolute right-3 top-3 flex flex-col gap-1 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-gray-600 dark:bg-gray-800/90"
191
+ >
192
+ <button
193
+ onclick={zoomIn}
194
+ class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
195
+ aria-label="Zoom in"
196
+ title="Zoom in"
197
+ >
198
+ <svg
199
+ class="h-4 w-4"
200
+ fill="none"
201
+ viewBox="0 0 24 24"
202
+ stroke="currentColor"
203
+ stroke-width="2"
204
+ >
205
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
206
+ </svg>
207
+ </button>
208
+ <button
209
+ onclick={zoomOut}
210
+ class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
211
+ aria-label="Zoom out"
212
+ title="Zoom out"
213
+ >
214
+ <svg
215
+ class="h-4 w-4"
216
+ fill="none"
217
+ viewBox="0 0 24 24"
218
+ stroke="currentColor"
219
+ stroke-width="2"
220
+ >
221
+ <path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />
222
+ </svg>
223
+ </button>
224
+ <div class="my-0.5 border-t border-gray-200 dark:border-gray-600"></div>
225
+ <button
226
+ onclick={resetZoom}
227
+ class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
228
+ aria-label="Fit to view"
229
+ title="Fit to view"
230
+ >
231
+ <!-- Viewfinder/target icon for fit-to-view -->
232
+ <svg
233
+ class="h-4 w-4"
234
+ fill="none"
235
+ viewBox="0 0 24 24"
236
+ stroke="currentColor"
237
+ stroke-width="2"
238
+ >
239
+ <path
240
+ stroke-linecap="round"
241
+ stroke-linejoin="round"
242
+ d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5m0 9V18A2.25 2.25 0 0118 20.25h-1.5m-9 0H6A2.25 2.25 0 013.75 18v-1.5M15 12a3 3 0 11-6 0 3 3 0 016 0z"
243
+ />
244
+ </svg>
245
+ </button>
246
+ <div class="my-0.5 border-t border-gray-200 dark:border-gray-600"></div>
247
+ <button
248
+ onclick={toggleFullscreen}
249
+ class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
250
+ aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
251
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
252
+ >
253
+ {#if isFullscreen}
254
+ <svg
255
+ class="h-4 w-4"
256
+ fill="none"
257
+ viewBox="0 0 24 24"
258
+ stroke="currentColor"
259
+ stroke-width="2"
260
+ >
261
+ <path
262
+ stroke-linecap="round"
263
+ stroke-linejoin="round"
264
+ d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
265
+ />
266
+ </svg>
267
+ {:else}
268
+ <svg
269
+ class="h-4 w-4"
270
+ fill="none"
271
+ viewBox="0 0 24 24"
272
+ stroke="currentColor"
273
+ stroke-width="2"
274
+ >
275
+ <path
276
+ stroke-linecap="round"
277
+ stroke-linejoin="round"
278
+ d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
279
+ />
280
+ </svg>
281
+ {/if}
282
+ </button>
283
+ </div>
284
+ {/if}
285
+ </div>
286
+ {/if}
287
+
288
+ <style>
289
+ .bpmn-container :global(.djs-container) {
290
+ height: 100% !important;
291
+ }
292
+
293
+ /* Dark mode styles for BPMN diagram */
294
+ :global(.dark) .bpmn-container :global(.djs-visual) {
295
+ /* Invert strokes: black -> light gray */
296
+ --bpmn-stroke: #d1d5db;
297
+ --bpmn-fill: #374151;
298
+ }
299
+
300
+ /* Participant/Pool borders and labels */
301
+ :global(.dark) .bpmn-container :global(.djs-group .djs-visual > rect) {
302
+ stroke: #9ca3af !important;
303
+ }
304
+
305
+ :global(.dark) .bpmn-container :global(.djs-group .djs-visual > path) {
306
+ stroke: #9ca3af !important;
307
+ }
308
+
309
+ /* Task boxes */
310
+ :global(.dark) .bpmn-container :global(.djs-shape .djs-visual > rect) {
311
+ stroke: #9ca3af !important;
312
+ fill: #374151 !important;
313
+ }
314
+
315
+ /* Events (circles) */
316
+ :global(.dark) .bpmn-container :global(.djs-shape .djs-visual > circle) {
317
+ stroke: #9ca3af !important;
318
+ fill: #374151 !important;
319
+ }
320
+
321
+ /* Gateways (diamonds) */
322
+ :global(.dark) .bpmn-container :global(.djs-shape .djs-visual > polygon) {
323
+ stroke: #9ca3af !important;
324
+ fill: #374151 !important;
325
+ }
326
+
327
+ /* Sequence flows (solid lines) */
328
+ :global(.dark) .bpmn-container :global(.djs-connection .djs-visual > path) {
329
+ stroke: #9ca3af !important;
330
+ }
331
+
332
+ /* Message flows (dashed lines) */
333
+ :global(.dark) .bpmn-container :global(.djs-connection .djs-visual > polyline) {
334
+ stroke: #9ca3af !important;
335
+ }
336
+
337
+ /* Arrow markers */
338
+ :global(.dark) .bpmn-container :global(marker path) {
339
+ fill: #9ca3af !important;
340
+ stroke: #9ca3af !important;
341
+ }
342
+
343
+ /* Text labels */
344
+ :global(.dark) .bpmn-container :global(.djs-label text),
345
+ :global(.dark) .bpmn-container :global(.djs-visual text),
346
+ :global(.dark) .bpmn-container :global(text) {
347
+ fill: #e5e7eb !important;
348
+ }
349
+
350
+ /* Participant header background (the title band) */
351
+ :global(.dark) .bpmn-container :global(.djs-visual > rect:first-child) {
352
+ fill: #1f2937 !important;
353
+ }
354
+
355
+ /* bpmn.io logo/watermark in corner */
356
+ :global(.dark) .bpmn-container :global(.bjs-powered-by) {
357
+ filter: invert(1) hue-rotate(180deg);
358
+ opacity: 0.7;
359
+ }
360
+ </style>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ xml: string;
3
+ interactive?: boolean;
4
+ }
5
+ declare const BpmnDiagram: import("svelte").Component<Props, {}, "">;
6
+ type BpmnDiagram = ReturnType<typeof BpmnDiagram>;
7
+ export default BpmnDiagram;
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ interface BreadcrumbItem {
3
+ label: string;
4
+ href: string;
5
+ }
6
+
7
+ interface Props {
8
+ items: BreadcrumbItem[];
9
+ current: string;
10
+ }
11
+
12
+ let { items, current }: Props = $props();
13
+ </script>
14
+
15
+ <nav aria-label="Breadcrumb" class="mb-6">
16
+ <ol class="flex flex-wrap items-center gap-1 text-sm">
17
+ {#each items as item (item.href)}
18
+ <li class="flex items-center">
19
+ <a
20
+ href={item.href}
21
+ class="text-primary-600 hover:text-primary-800 hover:underline dark:text-primary-400 dark:hover:text-primary-200"
22
+ >
23
+ {item.label}
24
+ </a>
25
+ <span class="mx-2 text-gray-400" aria-hidden="true">/</span>
26
+ </li>
27
+ {/each}
28
+ <li class="text-gray-600 dark:text-gray-400" aria-current="page">
29
+ {current}
30
+ </li>
31
+ </ol>
32
+ </nav>
@@ -0,0 +1,11 @@
1
+ interface BreadcrumbItem {
2
+ label: string;
3
+ href: string;
4
+ }
5
+ interface Props {
6
+ items: BreadcrumbItem[];
7
+ current: string;
8
+ }
9
+ declare const Breadcrumbs: import("svelte").Component<Props, {}, "">;
10
+ type Breadcrumbs = ReturnType<typeof Breadcrumbs>;
11
+ export default Breadcrumbs;
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ import type { Domain } from '@cwygoda/service-catalog-core/domain';
3
+
4
+ interface Props {
5
+ domain: Domain;
6
+ useCaseCount: number;
7
+ serviceCount: number;
8
+ }
9
+
10
+ let { domain, useCaseCount, serviceCount }: Props = $props();
11
+ </script>
12
+
13
+ <a
14
+ href="/domains/{domain.id}"
15
+ class="block rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
16
+ >
17
+ <div class="mb-2">
18
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
19
+ {domain.name}
20
+ </h3>
21
+ </div>
22
+ <p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
23
+ {domain.description}
24
+ </p>
25
+ <div class="flex gap-4 text-xs text-gray-500 dark:text-gray-400">
26
+ <span>{useCaseCount} use case{useCaseCount !== 1 ? 's' : ''}</span>
27
+ <span>{serviceCount} service{serviceCount !== 1 ? 's' : ''}</span>
28
+ </div>
29
+ </a>
@@ -0,0 +1,9 @@
1
+ import type { Domain } from '@cwygoda/service-catalog-core/domain';
2
+ interface Props {
3
+ domain: Domain;
4
+ useCaseCount: number;
5
+ serviceCount: number;
6
+ }
7
+ declare const DomainCard: import("svelte").Component<Props, {}, "">;
8
+ type DomainCard = ReturnType<typeof DomainCard>;
9
+ export default DomainCard;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import { describe, expect, it } from 'vitest';
4
+ import DomainCard from './DomainCard.svelte';
5
+ describe('DomainCard', () => {
6
+ const baseDomain = {
7
+ id: 'commerce',
8
+ name: 'Commerce',
9
+ description: 'E-commerce domain',
10
+ };
11
+ it('renders domain name', () => {
12
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 2, serviceCount: 3 } });
13
+ expect(screen.getByText('Commerce')).toBeInTheDocument();
14
+ });
15
+ it('renders domain description', () => {
16
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 2, serviceCount: 3 } });
17
+ expect(screen.getByText('E-commerce domain')).toBeInTheDocument();
18
+ });
19
+ it('links to domain detail page', () => {
20
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 2, serviceCount: 3 } });
21
+ const link = screen.getByRole('link');
22
+ expect(link).toHaveAttribute('href', '/domains/commerce');
23
+ });
24
+ it('shows use case count', () => {
25
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 2, serviceCount: 3 } });
26
+ expect(screen.getByText('2 use cases')).toBeInTheDocument();
27
+ });
28
+ it('shows singular use case for count of 1', () => {
29
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 1, serviceCount: 3 } });
30
+ expect(screen.getByText('1 use case')).toBeInTheDocument();
31
+ });
32
+ it('shows service count', () => {
33
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 2, serviceCount: 3 } });
34
+ expect(screen.getByText('3 services')).toBeInTheDocument();
35
+ });
36
+ it('shows singular service for count of 1', () => {
37
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 2, serviceCount: 1 } });
38
+ expect(screen.getByText('1 service')).toBeInTheDocument();
39
+ });
40
+ it('shows zero counts correctly', () => {
41
+ render(DomainCard, { props: { domain: baseDomain, useCaseCount: 0, serviceCount: 0 } });
42
+ expect(screen.getByText('0 use cases')).toBeInTheDocument();
43
+ expect(screen.getByText('0 services')).toBeInTheDocument();
44
+ });
45
+ });