@happyvertical/smrt-projects 0.52.0 → 0.53.2
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/AGENTS.md +20 -9
- package/README.md +32 -1
- package/SERVICE_TIME_MIGRATION.md +45 -3
- package/dist/chunks/{Issue-Dwyg6-_O.js → Issue-DG5UNAGa.js} +6 -5
- package/dist/chunks/Issue-DG5UNAGa.js.map +1 -0
- package/dist/chunks/{PullRequest-D27EkBdR.js → PullRequest-CN7LpEl4.js} +2 -2
- package/dist/chunks/{PullRequest-D27EkBdR.js.map → PullRequest-CN7LpEl4.js.map} +1 -1
- package/dist/chunks/repository-client-scope-CAnWW7-r.js +50 -0
- package/dist/chunks/repository-client-scope-CAnWW7-r.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -591
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +3 -1484
- package/dist/models/Issue.d.ts.map +1 -1
- package/dist/models/Repository.d.ts.map +1 -1
- package/dist/models/service-evidence.d.ts +13 -92
- package/dist/models/service-evidence.d.ts.map +1 -1
- package/dist/playground.js +2 -2
- package/dist/playground.js.map +1 -1
- package/dist/repository-client-scope.d.ts +41 -0
- package/dist/repository-client-scope.d.ts.map +1 -0
- package/dist/services/service-evidence-service.d.ts +6 -55
- package/dist/services/service-evidence-service.d.ts.map +1 -1
- package/dist/services/subscription-commercial-resolver.d.ts +1 -1
- package/dist/services/subscription-commercial-resolver.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +10 -1068
- package/dist/svelte/components/ApprovalActions.svelte +1 -1
- package/dist/svelte/components/ApprovalActions.svelte.d.ts +1 -1
- package/dist/svelte/components/ApprovalActions.svelte.d.ts.map +1 -1
- package/dist/svelte/i18n.d.ts +0 -9
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +0 -9
- package/dist/svelte/index.d.ts +2 -8
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +3 -4
- package/dist/svelte/playground.d.ts +7 -2
- package/dist/svelte/playground.d.ts.map +1 -1
- package/dist/svelte/playground.js +7 -2
- package/dist/svelte/utils.d.ts +4 -48
- package/dist/svelte/utils.d.ts.map +1 -1
- package/dist/svelte/utils.js +4 -56
- package/dist/types.d.ts +1 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -19
- package/dist/chunks/Issue-Dwyg6-_O.js.map +0 -1
- package/dist/svelte/components/DurationDisplay.svelte +0 -81
- package/dist/svelte/components/DurationDisplay.svelte.d.ts +0 -20
- package/dist/svelte/components/DurationDisplay.svelte.d.ts.map +0 -1
- package/dist/svelte/components/TimeEntryCard.svelte +0 -304
- package/dist/svelte/components/TimeEntryCard.svelte.d.ts +0 -25
- package/dist/svelte/components/TimeEntryCard.svelte.d.ts.map +0 -1
- package/dist/svelte/components/TimeEntryList.svelte +0 -360
- package/dist/svelte/components/TimeEntryList.svelte.d.ts +0 -24
- package/dist/svelte/components/TimeEntryList.svelte.d.ts.map +0 -1
- package/dist/svelte/components/TimeSummary.svelte +0 -176
- package/dist/svelte/components/TimeSummary.svelte.d.ts +0 -30
- package/dist/svelte/components/TimeSummary.svelte.d.ts.map +0 -1
- package/dist/svelte/components/__tests__/ApprovalActions.test.js +0 -41
- package/dist/svelte/components/__tests__/BulkActions.test.js +0 -46
- package/dist/svelte/components/__tests__/DeliverySurfaces.test.js +0 -98
- package/dist/svelte/components/__tests__/DevelopmentRequest.test.js +0 -52
- package/dist/svelte/components/__tests__/DurationDisplay.test.js +0 -57
- package/dist/svelte/components/__tests__/ProjectBoard.test.js +0 -156
- package/dist/svelte/components/__tests__/RejectDialog.test.js +0 -45
- package/dist/svelte/components/__tests__/TimeEntryCard.test.js +0 -45
- package/dist/svelte/components/__tests__/TimeEntryList.test.js +0 -50
- package/dist/svelte/components/__tests__/TimeSummary.test.js +0 -39
- package/dist/svelte/components/utils.d.ts +0 -51
- package/dist/svelte/components/utils.d.ts.map +0 -1
- package/dist/svelte/components/utils.js +0 -58
- package/dist/svelte/utils.test.js +0 -44
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for RejectDialog via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import RejectDialog from '../RejectDialog.svelte';
|
|
8
|
-
const baseProps = (over = {}) => ({
|
|
9
|
-
open: true,
|
|
10
|
-
onconfirm: vi.fn(),
|
|
11
|
-
oncancel: vi.fn(),
|
|
12
|
-
...over,
|
|
13
|
-
});
|
|
14
|
-
describe('RejectDialog', () => {
|
|
15
|
-
it('renders the dialog with a reason field when open', () => {
|
|
16
|
-
render(RejectDialog, { props: baseProps() });
|
|
17
|
-
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
18
|
-
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
19
|
-
expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument();
|
|
20
|
-
});
|
|
21
|
-
it('renders nothing when closed', () => {
|
|
22
|
-
render(RejectDialog, { props: baseProps({ open: false }) });
|
|
23
|
-
expect(screen.queryByRole('dialog')).toBeNull();
|
|
24
|
-
});
|
|
25
|
-
it('requires a reason before the confirm button is enabled', async () => {
|
|
26
|
-
const onconfirm = vi.fn();
|
|
27
|
-
render(RejectDialog, { props: baseProps({ onconfirm }) });
|
|
28
|
-
const confirm = screen.getByRole('button', { name: 'Reject' });
|
|
29
|
-
expect(confirm).toBeDisabled();
|
|
30
|
-
await userEvent.type(screen.getByRole('textbox'), 'Insufficient detail');
|
|
31
|
-
expect(confirm).toBeEnabled();
|
|
32
|
-
await userEvent.click(confirm);
|
|
33
|
-
expect(onconfirm).toHaveBeenCalledWith('Insufficient detail');
|
|
34
|
-
});
|
|
35
|
-
it('cancels via the cancel button', async () => {
|
|
36
|
-
const oncancel = vi.fn();
|
|
37
|
-
render(RejectDialog, { props: baseProps({ oncancel }) });
|
|
38
|
-
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
39
|
-
expect(oncancel).toHaveBeenCalledTimes(1);
|
|
40
|
-
});
|
|
41
|
-
it('is axe-clean', async () => {
|
|
42
|
-
const { container } = render(RejectDialog, { props: baseProps() });
|
|
43
|
-
await expectNoA11yViolations(container);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for TimeEntryCard via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import TimeEntryCard from '../TimeEntryCard.svelte';
|
|
8
|
-
const entry = {
|
|
9
|
-
id: 'e1',
|
|
10
|
-
date: '2026-06-01',
|
|
11
|
-
description: 'Implement feature',
|
|
12
|
-
status: 'draft',
|
|
13
|
-
hours: 3,
|
|
14
|
-
hourlyRate: 10000, // $100.00/h in cents (#2401)
|
|
15
|
-
amount: 30000, // 3 h x $100.00
|
|
16
|
-
workerName: 'Ada Lovelace',
|
|
17
|
-
};
|
|
18
|
-
describe('TimeEntryCard', () => {
|
|
19
|
-
it('renders the entry description, status, and worker', () => {
|
|
20
|
-
render(TimeEntryCard, { props: { entry } });
|
|
21
|
-
expect(screen.getByText('Implement feature')).toBeInTheDocument();
|
|
22
|
-
expect(screen.getByText('DRAFT')).toBeInTheDocument();
|
|
23
|
-
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
|
|
24
|
-
});
|
|
25
|
-
it('forwards checkbox selection through onselect', async () => {
|
|
26
|
-
const onselect = vi.fn();
|
|
27
|
-
render(TimeEntryCard, {
|
|
28
|
-
props: { entry, selectable: true, onselect },
|
|
29
|
-
});
|
|
30
|
-
await userEvent.click(screen.getByLabelText('Select time entry for Implement feature'));
|
|
31
|
-
expect(onselect).toHaveBeenCalledWith('e1', true);
|
|
32
|
-
});
|
|
33
|
-
it('invokes onclick when the card is activated', async () => {
|
|
34
|
-
const onclick = vi.fn();
|
|
35
|
-
render(TimeEntryCard, { props: { entry, onclick } });
|
|
36
|
-
await userEvent.click(screen.getByText('Implement feature'));
|
|
37
|
-
expect(onclick).toHaveBeenCalledTimes(1);
|
|
38
|
-
});
|
|
39
|
-
it('is axe-clean', async () => {
|
|
40
|
-
const { container } = render(TimeEntryCard, {
|
|
41
|
-
props: { entry, selectable: true, onselect: vi.fn() },
|
|
42
|
-
});
|
|
43
|
-
await expectNoA11yViolations(container);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for TimeEntryList via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import TimeEntryList from '../TimeEntryList.svelte';
|
|
8
|
-
const entries = [
|
|
9
|
-
{
|
|
10
|
-
id: 'e1',
|
|
11
|
-
date: '2026-06-01',
|
|
12
|
-
description: 'First task',
|
|
13
|
-
status: 'draft',
|
|
14
|
-
hours: 2,
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
id: 'e2',
|
|
18
|
-
date: '2026-06-02',
|
|
19
|
-
description: 'Second task',
|
|
20
|
-
status: 'draft',
|
|
21
|
-
hours: 4,
|
|
22
|
-
},
|
|
23
|
-
];
|
|
24
|
-
describe('TimeEntryList', () => {
|
|
25
|
-
it('renders a card per entry', () => {
|
|
26
|
-
render(TimeEntryList, { props: { entries } });
|
|
27
|
-
expect(screen.getByText('First task')).toBeInTheDocument();
|
|
28
|
-
expect(screen.getByText('Second task')).toBeInTheDocument();
|
|
29
|
-
});
|
|
30
|
-
it('shows the empty message when there are no entries', () => {
|
|
31
|
-
render(TimeEntryList, {
|
|
32
|
-
props: { entries: [], emptyMessage: 'No time entries' },
|
|
33
|
-
});
|
|
34
|
-
expect(screen.getByText('No time entries')).toBeInTheDocument();
|
|
35
|
-
});
|
|
36
|
-
it('selects all entries via the header checkbox', async () => {
|
|
37
|
-
const onselectionchange = vi.fn();
|
|
38
|
-
render(TimeEntryList, {
|
|
39
|
-
props: { entries, selectable: true, onselectionchange },
|
|
40
|
-
});
|
|
41
|
-
await userEvent.click(screen.getByLabelText('Select all time entries'));
|
|
42
|
-
expect(onselectionchange).toHaveBeenCalledWith(['e1', 'e2']);
|
|
43
|
-
});
|
|
44
|
-
it('is axe-clean', async () => {
|
|
45
|
-
const { container } = render(TimeEntryList, {
|
|
46
|
-
props: { entries, selectable: true, onselectionchange: vi.fn() },
|
|
47
|
-
});
|
|
48
|
-
await expectNoA11yViolations(container);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for TimeSummary via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it } from 'vitest';
|
|
7
|
-
import TimeSummary from '../TimeSummary.svelte';
|
|
8
|
-
describe('TimeSummary', () => {
|
|
9
|
-
it('renders the total hours and value cards', () => {
|
|
10
|
-
render(TimeSummary, {
|
|
11
|
-
props: { totalHours: 12, totalAmount: 600, entryCount: 3 },
|
|
12
|
-
});
|
|
13
|
-
expect(screen.getByText('Total Hours')).toBeInTheDocument();
|
|
14
|
-
expect(screen.getByText('Total Value')).toBeInTheDocument();
|
|
15
|
-
expect(screen.getByText('3 entries')).toBeInTheDocument();
|
|
16
|
-
});
|
|
17
|
-
it('shows pending and approved cards when enabled and non-zero', () => {
|
|
18
|
-
render(TimeSummary, {
|
|
19
|
-
props: {
|
|
20
|
-
totalHours: 12,
|
|
21
|
-
totalAmount: 600,
|
|
22
|
-
pendingHours: 4,
|
|
23
|
-
pendingAmount: 200,
|
|
24
|
-
approvedHours: 8,
|
|
25
|
-
approvedAmount: 400,
|
|
26
|
-
showPending: true,
|
|
27
|
-
showApproved: true,
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
expect(screen.getByText('Pending Approval')).toBeInTheDocument();
|
|
31
|
-
expect(screen.getByText('Approved')).toBeInTheDocument();
|
|
32
|
-
});
|
|
33
|
-
it('is axe-clean', async () => {
|
|
34
|
-
const { container } = render(TimeSummary, {
|
|
35
|
-
props: { totalHours: 12, totalAmount: 600 },
|
|
36
|
-
});
|
|
37
|
-
await expectNoA11yViolations(container);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared utilities for time tracking components
|
|
3
|
-
*/
|
|
4
|
-
export type Currency = 'CAD' | 'USD';
|
|
5
|
-
export type TimeEntryStatus = 'draft' | 'submitted' | 'approved' | 'rejected';
|
|
6
|
-
/** Alias for TimeEntryStatus for approval workflows */
|
|
7
|
-
export type ApprovalStatus = TimeEntryStatus;
|
|
8
|
-
/**
|
|
9
|
-
* Time entry data structure
|
|
10
|
-
*/
|
|
11
|
-
export interface TimeEntry {
|
|
12
|
-
id: string;
|
|
13
|
-
date: Date | string;
|
|
14
|
-
hours: number;
|
|
15
|
-
description: string;
|
|
16
|
-
status: TimeEntryStatus;
|
|
17
|
-
/** Integer minor units of the display currency — $19.99 is 1999 (#2401). */
|
|
18
|
-
amount?: number;
|
|
19
|
-
workerName?: string;
|
|
20
|
-
mileage?: number;
|
|
21
|
-
/** Minor units per hour, so `hours * hourlyRate` is minor units (#2401). */
|
|
22
|
-
hourlyRate?: number;
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Status color mapping using M3 design tokens
|
|
26
|
-
*/
|
|
27
|
-
export declare const statusColors: Record<TimeEntryStatus, string>;
|
|
28
|
-
/**
|
|
29
|
-
* Format a date for display (e.g., "Jan 5")
|
|
30
|
-
*/
|
|
31
|
-
export declare function formatDate(date: Date | string): string;
|
|
32
|
-
/**
|
|
33
|
-
* Format a minor-units amount for display.
|
|
34
|
-
*
|
|
35
|
-
* Money in this package is integer minor units (#2401) — `$19.99` is `1999` —
|
|
36
|
-
* and `Intl.NumberFormat` expects major units, so the scale is undone here.
|
|
37
|
-
* Without it a $19.99 charge renders as $1,999.00.
|
|
38
|
-
*
|
|
39
|
-
* The currency's own minor-unit exponent is used rather than a hard-coded 100,
|
|
40
|
-
* so zero-decimal currencies (JPY, KRW) are not divided by anything.
|
|
41
|
-
*/
|
|
42
|
-
export declare function formatCurrency(amount: number, currency?: Currency): string;
|
|
43
|
-
/**
|
|
44
|
-
* Format hours for display (e.g., "8.5h")
|
|
45
|
-
*/
|
|
46
|
-
export declare function formatHours(hours: number): string;
|
|
47
|
-
/**
|
|
48
|
-
* Format hours in HH:MM format (e.g., "8:30")
|
|
49
|
-
*/
|
|
50
|
-
export declare function formatHoursHHMM(hours: number): string;
|
|
51
|
-
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/utils.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,QAAQ,GAAG,KAAK,GAAG,KAAK,CAAC;AAErC,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,WAAW,GAAG,UAAU,GAAG,UAAU,CAAC;AAE9E,uDAAuD;AACvD,MAAM,MAAM,cAAc,GAAG,eAAe,CAAC;AAE7C;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,IAAI,GAAG,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,eAAe,CAAC;IACxB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,eAAe,EAAE,MAAM,CAKxD,CAAC;AAEF;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,CAGtD;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,GAAE,QAAgB,GACzB,MAAM,CAcR;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIrD"}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared utilities for time tracking components
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Status color mapping using M3 design tokens
|
|
6
|
-
*/
|
|
7
|
-
export const statusColors = {
|
|
8
|
-
draft: 'var(--md-sys-color-outline)',
|
|
9
|
-
submitted: 'var(--md-sys-color-tertiary)',
|
|
10
|
-
approved: 'var(--md-sys-color-primary)',
|
|
11
|
-
rejected: 'var(--md-sys-color-error)',
|
|
12
|
-
};
|
|
13
|
-
/**
|
|
14
|
-
* Format a date for display (e.g., "Jan 5")
|
|
15
|
-
*/
|
|
16
|
-
export function formatDate(date) {
|
|
17
|
-
const d = typeof date === 'string' ? new Date(date) : date;
|
|
18
|
-
return d.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Format a minor-units amount for display.
|
|
22
|
-
*
|
|
23
|
-
* Money in this package is integer minor units (#2401) — `$19.99` is `1999` —
|
|
24
|
-
* and `Intl.NumberFormat` expects major units, so the scale is undone here.
|
|
25
|
-
* Without it a $19.99 charge renders as $1,999.00.
|
|
26
|
-
*
|
|
27
|
-
* The currency's own minor-unit exponent is used rather than a hard-coded 100,
|
|
28
|
-
* so zero-decimal currencies (JPY, KRW) are not divided by anything.
|
|
29
|
-
*/
|
|
30
|
-
export function formatCurrency(amount, currency = 'CAD') {
|
|
31
|
-
// No fraction-digit override: the currency's own exponent is both the scale
|
|
32
|
-
// to undo and the precision to print. Forcing `minimumFractionDigits: 2`
|
|
33
|
-
// (as this did before minor units) would pin `maximumFractionDigits` to 2 as
|
|
34
|
-
// well, so a zero-decimal currency like JPY would report a scale of 2 — and
|
|
35
|
-
// then render `¥1,999.00` for a currency that has no fractional part at all.
|
|
36
|
-
// For CAD/USD the resolved default is already 2, so their output is
|
|
37
|
-
// unchanged.
|
|
38
|
-
const format = new Intl.NumberFormat('en-CA', {
|
|
39
|
-
style: 'currency',
|
|
40
|
-
currency,
|
|
41
|
-
});
|
|
42
|
-
const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
|
|
43
|
-
return format.format(amount / 10 ** digits);
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Format hours for display (e.g., "8.5h")
|
|
47
|
-
*/
|
|
48
|
-
export function formatHours(hours) {
|
|
49
|
-
return `${hours.toFixed(1)}h`;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Format hours in HH:MM format (e.g., "8:30")
|
|
53
|
-
*/
|
|
54
|
-
export function formatHoursHHMM(hours) {
|
|
55
|
-
const h = Math.floor(hours);
|
|
56
|
-
const m = Math.round((hours - h) * 60);
|
|
57
|
-
return `${h}:${m.toString().padStart(2, '0')}`;
|
|
58
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Currency formatting for integer minor units (#2401).
|
|
3
|
-
*
|
|
4
|
-
* `formatCurrency` receives what the database stores — integer minor units —
|
|
5
|
-
* and `Intl.NumberFormat` formats major units, so the scale has to be undone
|
|
6
|
-
* first. Which scale depends on the currency, which is the trap this file
|
|
7
|
-
* guards: a hard-coded `/100`, or a `minimumFractionDigits: 2` override that
|
|
8
|
-
* pins the resolved exponent to 2, both mis-render every currency that does not
|
|
9
|
-
* have exactly two decimal places.
|
|
10
|
-
*/
|
|
11
|
-
import { describe, expect, it } from 'vitest';
|
|
12
|
-
import { formatCurrency as formatCurrencyFromComponents } from './components/utils.js';
|
|
13
|
-
import { formatCurrency } from './utils.js';
|
|
14
|
-
describe('formatCurrency (#2401)', () => {
|
|
15
|
-
it('renders a two-decimal currency by undoing the cent scale', () => {
|
|
16
|
-
// 19999 minor units is $199.99, not $19,999.00.
|
|
17
|
-
expect(formatCurrency(19999, 'CAD')).toContain('199.99');
|
|
18
|
-
expect(formatCurrency(19999, 'CAD')).not.toContain('19,999');
|
|
19
|
-
});
|
|
20
|
-
it('renders zero as the currency zero rather than nothing', () => {
|
|
21
|
-
expect(formatCurrency(0, 'CAD')).toContain('0.00');
|
|
22
|
-
});
|
|
23
|
-
it('does not rescale or add decimals to a zero-decimal currency', () => {
|
|
24
|
-
// JPY has no minor unit, so 1999 is ¥1,999 — dividing by 100 here would
|
|
25
|
-
// report ¥19.99, a hundredfold understatement, and printing two decimals
|
|
26
|
-
// would invent a fractional part the currency does not have.
|
|
27
|
-
const formatted = formatCurrency(1999, 'JPY');
|
|
28
|
-
expect(formatted).toContain('1,999');
|
|
29
|
-
expect(formatted).not.toContain('1,999.00');
|
|
30
|
-
});
|
|
31
|
-
it('uses a three-decimal currency exponent for a three-decimal currency', () => {
|
|
32
|
-
// BHD is 1000 fils to the dinar, so 1999 fils is BHD 1.999.
|
|
33
|
-
expect(formatCurrency(1999, 'BHD')).toContain('1.999');
|
|
34
|
-
});
|
|
35
|
-
it('is duplicated verbatim under components/, so both copies must agree', () => {
|
|
36
|
-
for (const [amount, currency] of [
|
|
37
|
-
[19999, 'CAD'],
|
|
38
|
-
[1999, 'JPY'],
|
|
39
|
-
[0, 'USD'],
|
|
40
|
-
]) {
|
|
41
|
-
expect(formatCurrencyFromComponents(amount, currency)).toBe(formatCurrency(amount, currency));
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
});
|