@praxisjs/composables 0.1.5 → 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.
@@ -1,282 +1,316 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
-
4
3
  import { signal } from "@praxisjs/core/internal";
5
4
 
6
- import { usePagination, useClipboard, useGeolocation, useTimeAgo } from "../utilities";
5
+ import { Clipboard, Geolocation, TimeAgo, Pagination } from "../utilities";
7
6
 
8
- describe("usePagination", () => {
9
- it("starts on page 1 by default", () => {
10
- const p = usePagination({ total: 100, pageSize: 10 });
11
- expect(p.page()).toBe(1);
12
- });
7
+ // ── Clipboard ─────────────────────────────────────────────────────────────────
13
8
 
14
- it("respects initial page option", () => {
15
- const p = usePagination({ total: 100, pageSize: 10, initial: 3 });
16
- expect(p.page()).toBe(3);
9
+ describe("Clipboard", () => {
10
+ beforeEach(() => {
11
+ Object.defineProperty(navigator, "clipboard", {
12
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
13
+ configurable: true,
14
+ });
17
15
  });
18
16
 
19
- it("computes total pages correctly", () => {
20
- expect(usePagination({ total: 100, pageSize: 10 }).totalPages()).toBe(10);
21
- expect(usePagination({ total: 101, pageSize: 10 }).totalPages()).toBe(11);
22
- expect(usePagination({ total: 5, pageSize: 10 }).totalPages()).toBe(1);
17
+ it("starts with copied=false and empty content", () => {
18
+ const cb = new Clipboard();
19
+ const { copied, content } = cb.setup() as { copied: () => boolean; content: () => string };
20
+ expect(copied()).toBe(false);
21
+ expect(content()).toBe("");
23
22
  });
24
23
 
25
- it("computes offset", () => {
26
- const p = usePagination({ total: 100, pageSize: 10 });
27
- expect(p.offset()).toBe(0);
28
- p.goTo(3);
29
- expect(p.offset()).toBe(20);
24
+ it("sets copied=true and content after copy", async () => {
25
+ const cb = new Clipboard();
26
+ const { copy, copied, content } = cb.setup() as {
27
+ copy: (t: string) => Promise<void>;
28
+ copied: () => boolean;
29
+ content: () => string;
30
+ };
31
+ await copy("hello");
32
+ expect(copied()).toBe(true);
33
+ expect(content()).toBe("hello");
30
34
  });
31
35
 
32
- it("next() advances the page", () => {
33
- const p = usePagination({ total: 30, pageSize: 10 });
34
- p.next();
35
- expect(p.page()).toBe(2);
36
+ it("resets copied after resetDelay", async () => {
37
+ vi.useFakeTimers();
38
+ const cb = new Clipboard(1000);
39
+ const { copy, copied } = cb.setup() as {
40
+ copy: (t: string) => Promise<void>;
41
+ copied: () => boolean;
42
+ };
43
+ await copy("test");
44
+ expect(copied()).toBe(true);
45
+ vi.advanceTimersByTime(1000);
46
+ expect(copied()).toBe(false);
47
+ vi.useRealTimers();
36
48
  });
37
49
 
38
- it("next() does nothing on last page", () => {
39
- const p = usePagination({ total: 10, pageSize: 10 });
40
- p.next();
41
- expect(p.page()).toBe(1);
50
+ it("warns on clipboard failure", async () => {
51
+ (navigator.clipboard.writeText as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("denied"));
52
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
53
+ const cb = new Clipboard();
54
+ const { copy } = cb.setup() as { copy: (t: string) => Promise<void> };
55
+ await copy("fail");
56
+ expect(warn).toHaveBeenCalled();
57
+ warn.mockRestore();
42
58
  });
43
59
 
44
- it("prev() goes back", () => {
45
- const p = usePagination({ total: 30, pageSize: 10, initial: 3 });
46
- p.prev();
47
- expect(p.page()).toBe(2);
60
+ it("setTimeout is cleared on onUnmount", async () => {
61
+ vi.useFakeTimers();
62
+ const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
63
+ const cb = new Clipboard(1000);
64
+ const { copy } = cb.setup() as { copy: (t: string) => Promise<void> };
65
+ await copy("hello");
66
+ cb.onUnmount();
67
+ expect(clearTimeoutSpy).toHaveBeenCalled();
68
+ vi.useRealTimers();
48
69
  });
70
+ });
49
71
 
50
- it("prev() does nothing on first page", () => {
51
- const p = usePagination({ total: 30, pageSize: 10 });
52
- p.prev();
53
- expect(p.page()).toBe(1);
54
- });
72
+ // ── Geolocation ───────────────────────────────────────────────────────────────
55
73
 
56
- it("goTo() clamps to valid range", () => {
57
- const p = usePagination({ total: 30, pageSize: 10 });
58
- p.goTo(0);
59
- expect(p.page()).toBe(1);
60
- p.goTo(99);
61
- expect(p.page()).toBe(3);
74
+ describe("Geolocation", () => {
75
+ function mockGeolocation(impl: (success: PositionCallback, error?: PositionErrorCallback | null) => void) {
76
+ vi.stubGlobal("navigator", {
77
+ ...navigator,
78
+ geolocation: { getCurrentPosition: impl },
79
+ });
80
+ }
81
+
82
+ afterEach(() => { vi.unstubAllGlobals(); });
83
+
84
+ it("starts in loading state", () => {
85
+ mockGeolocation(() => {});
86
+ const geo = new Geolocation();
87
+ const { loading, lat, lng, error } = geo.setup() as {
88
+ loading: () => boolean;
89
+ lat: () => number | null;
90
+ lng: () => number | null;
91
+ error: () => GeolocationPositionError | null;
92
+ };
93
+ expect(loading()).toBe(true);
94
+ expect(lat()).toBeNull();
95
+ expect(lng()).toBeNull();
96
+ expect(error()).toBeNull();
97
+ });
98
+
99
+ it("sets coordinates on success", () => {
100
+ mockGeolocation((success) => {
101
+ success({ coords: { latitude: 51.5, longitude: -0.1 } } as GeolocationPosition);
102
+ });
103
+ const geo = new Geolocation();
104
+ const { loading, lat, lng } = geo.setup() as {
105
+ loading: () => boolean;
106
+ lat: () => number | null;
107
+ lng: () => number | null;
108
+ };
109
+ expect(loading()).toBe(false);
110
+ expect(lat()).toBe(51.5);
111
+ expect(lng()).toBe(-0.1);
112
+ });
113
+
114
+ it("sets error on failure", () => {
115
+ const mockError = { code: 1, message: "denied" } as GeolocationPositionError;
116
+ mockGeolocation((_s, error) => { error!(mockError); });
117
+ const geo = new Geolocation();
118
+ const { loading, error } = geo.setup() as {
119
+ loading: () => boolean;
120
+ error: () => GeolocationPositionError | null;
121
+ };
122
+ expect(loading()).toBe(false);
123
+ expect(error()).toBe(mockError);
124
+ });
125
+
126
+ it("success callback after unmount does not update state", () => {
127
+ let capturedSuccess: PositionCallback | undefined;
128
+ mockGeolocation((success) => { capturedSuccess = success; });
129
+
130
+ const geo = new Geolocation();
131
+ const { lat, lng, loading } = geo.setup() as {
132
+ lat: () => number | null;
133
+ lng: () => number | null;
134
+ loading: () => boolean;
135
+ };
136
+
137
+ geo.onUnmount();
138
+ capturedSuccess!({ coords: { latitude: 99, longitude: 99 } } as GeolocationPosition);
139
+
140
+ expect(lat()).toBeNull();
141
+ expect(lng()).toBeNull();
142
+ expect(loading()).toBe(true);
143
+ });
144
+
145
+ it("error callback after unmount does not update state", () => {
146
+ let capturedError: PositionErrorCallback | undefined;
147
+ mockGeolocation((_success, error) => { capturedError = error ?? undefined; });
148
+
149
+ const geo = new Geolocation();
150
+ const { error, loading } = geo.setup() as {
151
+ error: () => GeolocationPositionError | null;
152
+ loading: () => boolean;
153
+ };
154
+
155
+ geo.onUnmount();
156
+ capturedError!({ code: 1, message: "denied" } as GeolocationPositionError);
157
+
158
+ expect(error()).toBeNull();
159
+ expect(loading()).toBe(true);
62
160
  });
161
+ });
63
162
 
64
- it("first() jumps to page 1", () => {
65
- const p = usePagination({ total: 50, pageSize: 10, initial: 5 });
66
- p.first();
67
- expect(p.page()).toBe(1);
68
- });
163
+ // ── TimeAgo ───────────────────────────────────────────────────────────────────
69
164
 
70
- it("last() jumps to last page", () => {
71
- const p = usePagination({ total: 50, pageSize: 10 });
72
- p.last();
73
- expect(p.page()).toBe(5);
74
- });
165
+ describe("TimeAgo", () => {
166
+ beforeEach(() => { vi.useFakeTimers(); });
167
+ afterEach(() => { vi.useRealTimers(); });
75
168
 
76
- it("hasNext is false on last page", () => {
77
- const p = usePagination({ total: 10, pageSize: 10 });
78
- expect(p.hasNext()).toBe(false);
79
- });
169
+ const now = Date.now();
80
170
 
81
- it("hasPrev is false on first page", () => {
82
- const p = usePagination({ total: 30, pageSize: 10 });
83
- expect(p.hasPrev()).toBe(false);
171
+ it("formats seconds ago", () => {
172
+ const ta = new TimeAgo(() => now - 30_000, "en");
173
+ const { value } = ta.setup() as { value: () => string };
174
+ expect(value()).toContain("30 seconds ago");
84
175
  });
85
176
 
86
- it("pages() returns array of page numbers", () => {
87
- const p = usePagination({ total: 30, pageSize: 10 });
88
- expect(p.pages()).toEqual([1, 2, 3]);
177
+ it("formats minutes ago", () => {
178
+ const ta = new TimeAgo(() => now - 5 * 60_000, "en");
179
+ const { value } = ta.setup() as { value: () => string };
180
+ expect(value()).toContain("5 minutes ago");
89
181
  });
90
182
 
91
- it("pageSize() reflects the configured size", () => {
92
- const p = usePagination({ total: 100, pageSize: 25 });
93
- expect(p.pageSize()).toBe(25);
183
+ it("formats hours ago", () => {
184
+ const ta = new TimeAgo(() => now - 2 * 3_600_000, "en");
185
+ const { value } = ta.setup() as { value: () => string };
186
+ expect(value()).toContain("2 hours ago");
94
187
  });
95
188
 
96
- it("accepts a factory function for options", () => {
97
- const p = usePagination(() => ({ total: 50, pageSize: 5 }));
98
- expect(p.totalPages()).toBe(10);
189
+ it("formats days ago", () => {
190
+ const ta = new TimeAgo(() => now - 3 * 86_400_000, "en");
191
+ const { value } = ta.setup() as { value: () => string };
192
+ expect(value()).toContain("3 days ago");
99
193
  });
100
- });
101
194
 
102
- // ── useClipboard ──────────────────────────────────────────────────────────────
103
-
104
- describe("useClipboard", () => {
105
- beforeEach(() => {
106
- vi.useFakeTimers();
107
- Object.defineProperty(navigator, "clipboard", {
108
- value: { writeText: vi.fn().mockResolvedValue(undefined) },
109
- configurable: true,
110
- writable: true,
111
- });
195
+ it("formats months ago", () => {
196
+ const ta = new TimeAgo(() => now - 60 * 86_400_000, "en");
197
+ const { value } = ta.setup() as { value: () => string };
198
+ expect(value()).toContain("2 months ago");
112
199
  });
113
200
 
114
- afterEach(() => {
115
- vi.clearAllTimers();
116
- vi.useRealTimers();
201
+ it("accepts a signal as source", () => {
202
+ const src = signal(now - 30_000);
203
+ const ta = new TimeAgo(src, "en");
204
+ const { value } = ta.setup() as { value: () => string };
205
+ expect(value()).toContain("30 seconds ago");
117
206
  });
118
207
 
119
- it("copied starts as false", () => {
120
- const { copied } = useClipboard();
121
- expect(copied()).toBe(false);
208
+ it("clears interval on unmount", () => {
209
+ const clearInterval = vi.spyOn(globalThis, "clearInterval");
210
+ const ta = new TimeAgo(() => now, "en");
211
+ ta.setup();
212
+ ta.onUnmount();
213
+ expect(clearInterval).toHaveBeenCalled();
122
214
  });
123
215
 
124
- it("content starts as empty string", () => {
125
- const { content } = useClipboard();
126
- expect(content()).toBe("");
216
+ it("future date produces a positive relative time string (e.g. 'in 30 seconds')", () => {
217
+ // A future timestamp produces a positive diff; Intl.RelativeTimeFormat formats it as "in X"
218
+ const ta = new TimeAgo(() => now + 30_000, "en");
219
+ const { value } = ta.setup() as { value: () => string };
220
+ expect(value()).toContain("in 30 seconds");
127
221
  });
222
+ });
128
223
 
129
- it("sets copied to true and content to the text after copy()", async () => {
130
- const { copy, copied, content } = useClipboard();
131
- await copy("hello");
132
- expect(copied()).toBe(true);
133
- expect(content()).toBe("hello");
134
- });
224
+ // ── Pagination ────────────────────────────────────────────────────────────────
135
225
 
136
- it("resets copied to false after resetDelay", async () => {
137
- const { copy, copied } = useClipboard(1000);
138
- await copy("test");
139
- expect(copied()).toBe(true);
140
- vi.advanceTimersByTime(1000);
141
- expect(copied()).toBe(false);
226
+ describe("Pagination", () => {
227
+ it("initialises with defaults", () => {
228
+ const p = new Pagination({ total: 100, pageSize: 10 });
229
+ const s = p.setup() as {
230
+ page: () => number; totalPages: () => number; offset: () => number;
231
+ hasNext: () => boolean; hasPrev: () => boolean; pageSize: () => number;
232
+ };
233
+ expect(s.page()).toBe(1);
234
+ expect(s.totalPages()).toBe(10);
235
+ expect(s.offset()).toBe(0);
236
+ expect(s.hasNext()).toBe(true);
237
+ expect(s.hasPrev()).toBe(false);
238
+ expect(s.pageSize()).toBe(10);
142
239
  });
143
240
 
144
- it("warns and does not update state on clipboard failure", async () => {
145
- (navigator.clipboard as unknown as { writeText: ReturnType<typeof vi.fn> }).writeText =
146
- vi.fn().mockRejectedValue(new Error("denied"));
147
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
148
- const { copy, copied } = useClipboard();
149
- await copy("fail");
150
- expect(copied()).toBe(false);
151
- expect(warn).toHaveBeenCalled();
152
- warn.mockRestore();
241
+ it("respects initial page", () => {
242
+ const p = new Pagination({ total: 100, pageSize: 10, initial: 3 });
243
+ const { page, offset } = p.setup() as { page: () => number; offset: () => number };
244
+ expect(page()).toBe(3);
245
+ expect(offset()).toBe(20);
153
246
  });
154
- });
155
-
156
- // ── useGeolocation ────────────────────────────────────────────────────────────
157
-
158
- describe("useGeolocation", () => {
159
- it("starts with loading=true and lat/lng null", () => {
160
- const successCb: PositionCallback[] = [];
161
- Object.defineProperty(navigator, "geolocation", {
162
- value: {
163
- getCurrentPosition: (success: PositionCallback) => {
164
- successCb.push(success);
165
- },
166
- },
167
- configurable: true,
168
- });
169
247
 
170
- const geo = useGeolocation();
171
- expect(geo.loading()).toBe(true);
172
- expect(geo.lat()).toBeNull();
173
- expect(geo.lng()).toBeNull();
248
+ it("next() advances page", () => {
249
+ const p = new Pagination({ total: 30, pageSize: 10 });
250
+ const { page, next } = p.setup() as { page: () => number; next: () => void };
251
+ (next as () => void)();
252
+ expect(page()).toBe(2);
174
253
  });
175
254
 
176
- it("updates lat/lng/loading on success", () => {
177
- let successCb: PositionCallback | null = null;
178
- Object.defineProperty(navigator, "geolocation", {
179
- value: {
180
- getCurrentPosition: (success: PositionCallback) => {
181
- successCb = success;
182
- },
183
- },
184
- configurable: true,
185
- });
186
-
187
- const geo = useGeolocation();
188
-
189
- successCb!({
190
- coords: { latitude: 10.5, longitude: 20.5 } as GeolocationCoordinates,
191
- } as GeolocationPosition);
192
-
193
- expect(geo.lat()).toBe(10.5);
194
- expect(geo.lng()).toBe(20.5);
195
- expect(geo.loading()).toBe(false);
255
+ it("prev() goes back a page", () => {
256
+ const p = new Pagination({ total: 30, pageSize: 10, initial: 3 });
257
+ const { page, prev } = p.setup() as { page: () => number; prev: () => void };
258
+ (prev as () => void)();
259
+ expect(page()).toBe(2);
196
260
  });
197
261
 
198
- it("sets error and loading=false on failure", () => {
199
- let errorCb: PositionErrorCallback | null = null;
200
- Object.defineProperty(navigator, "geolocation", {
201
- value: {
202
- getCurrentPosition: (_s: PositionCallback, error: PositionErrorCallback) => {
203
- errorCb = error;
204
- },
205
- },
206
- configurable: true,
207
- });
208
-
209
- const geo = useGeolocation();
210
- const fakeError = { code: 1, message: "denied" } as GeolocationPositionError;
211
- errorCb!(fakeError);
212
-
213
- expect(geo.error()).toBe(fakeError);
214
- expect(geo.loading()).toBe(false);
262
+ it("next() does not go past last page", () => {
263
+ const p = new Pagination({ total: 10, pageSize: 10 });
264
+ const { page, next } = p.setup() as { page: () => number; next: () => void };
265
+ (next as () => void)();
266
+ expect(page()).toBe(1);
215
267
  });
216
- });
217
-
218
- // ── useTimeAgo ────────────────────────────────────────────────────────────────
219
268
 
220
- describe("useTimeAgo", () => {
221
- beforeEach(() => {
222
- vi.useFakeTimers();
269
+ it("prev() does not go before first page", () => {
270
+ const p = new Pagination({ total: 30, pageSize: 10 });
271
+ const { page, prev } = p.setup() as { page: () => number; prev: () => void };
272
+ (prev as () => void)();
273
+ expect(page()).toBe(1);
223
274
  });
224
275
 
225
- afterEach(() => {
226
- vi.clearAllTimers();
227
- vi.useRealTimers();
276
+ it("goTo() jumps to a page", () => {
277
+ const p = new Pagination({ total: 50, pageSize: 10 });
278
+ const { page, goTo } = p.setup() as { page: () => number; goTo: (n: number) => void };
279
+ (goTo as (n: number) => void)(4);
280
+ expect(page()).toBe(4);
228
281
  });
229
282
 
230
- it("returns a computed string", () => {
231
- const now = Date.now();
232
- const t = useTimeAgo(() => now);
233
- expect(typeof t()).toBe("string");
283
+ it("first() jumps to page 1", () => {
284
+ const p = new Pagination({ total: 50, pageSize: 10, initial: 5 });
285
+ const { page, first } = p.setup() as { page: () => number; first: () => void };
286
+ (first as () => void)();
287
+ expect(page()).toBe(1);
234
288
  });
235
289
 
236
- it("formats seconds ago", () => {
237
- const now = Date.now();
238
- const t = useTimeAgo(() => now - 30_000);
239
- expect(t()).toMatch(/segundo|second/i);
290
+ it("last() jumps to last page", () => {
291
+ const p = new Pagination({ total: 50, pageSize: 10 });
292
+ const { page, last } = p.setup() as { page: () => number; last: () => void };
293
+ (last as () => void)();
294
+ expect(page()).toBe(5);
240
295
  });
241
296
 
242
- it("formats minutes ago", () => {
243
- const now = Date.now();
244
- const t = useTimeAgo(() => now - 5 * 60_000);
245
- expect(t()).toMatch(/minuto|minute/i);
297
+ it("pages() returns array of page numbers", () => {
298
+ const p = new Pagination({ total: 30, pageSize: 10 });
299
+ const { pages } = p.setup() as { pages: () => number[] };
300
+ expect((pages as () => number[])()).toEqual([1, 2, 3]);
246
301
  });
247
302
 
248
- it("formats hours ago", () => {
249
- const now = Date.now();
250
- const t = useTimeAgo(() => now - 2 * 3_600_000);
251
- expect(t()).toMatch(/hora|hour/i);
303
+ it("clamps totalPages for fractional divisions", () => {
304
+ const p = new Pagination({ total: 25, pageSize: 10 });
305
+ const { totalPages } = p.setup() as { totalPages: () => number };
306
+ expect(totalPages()).toBe(3);
252
307
  });
253
308
 
254
- it("formats days ago", () => {
255
- const now = Date.now();
256
- const t = useTimeAgo(() => now - 3 * 86_400_000);
257
- expect(t()).toMatch(/dia|day/i);
309
+ it("throws when pageSize is 0", () => {
310
+ expect(() => new Pagination({ total: 10, pageSize: 0 })).toThrow("pageSize must be greater than 0");
258
311
  });
259
312
 
260
- it("formats months ago", () => {
261
- const now = Date.now();
262
- const t = useTimeAgo(() => now - 60 * 86_400_000);
263
- expect(t()).toMatch(/mes|month/i);
264
- });
265
-
266
- it("accepts a signal source", () => {
267
- const src = signal(Date.now() - 90_000);
268
- const t = useTimeAgo(src);
269
- expect(typeof t()).toBe("string");
270
- });
271
-
272
- it("ticks after 60 seconds via setInterval", () => {
273
- const now = Date.now();
274
- const t = useTimeAgo(() => now - 30_000);
275
- const before = t();
276
- vi.advanceTimersByTime(60_000);
277
- // After ticking, the computed re-evaluates (may or may not change the string)
278
- expect(typeof t()).toBe("string");
279
- // The interval ran — just verify no errors
280
- expect(before).toBeTruthy();
313
+ it("throws when pageSize is negative", () => {
314
+ expect(() => new Pagination({ total: 10, pageSize: -1 })).toThrow("pageSize must be greater than 0");
281
315
  });
282
316
  });