@carlonicora/nextjs-jsonapi 1.142.0 → 1.143.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/dist/AbstractService-B0T7h8fX.d.mts +124 -0
- package/dist/AbstractService-Cew0PD0L.d.ts +124 -0
- package/dist/{AuthComponent-DdxCFgUZ.d.mts → AuthComponent-Cik96ElA.d.mts} +1 -1
- package/dist/{AuthComponent-Cd7lcYif.d.ts → AuthComponent-DTTKu3VK.d.ts} +1 -1
- package/dist/{BlockNoteEditor-35DDJH3M.mjs → BlockNoteEditor-34QM4UR5.mjs} +4 -4
- package/dist/{BlockNoteEditor-XXTBZ7DD.js → BlockNoteEditor-LMKWUOJG.js} +19 -19
- package/dist/{BlockNoteEditor-XXTBZ7DD.js.map → BlockNoteEditor-LMKWUOJG.js.map} +1 -1
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-KZHHKPQZ.mjs → chunk-BIL2WQEV.mjs} +247 -1
- package/dist/chunk-BIL2WQEV.mjs.map +1 -0
- package/dist/{chunk-5SFFHIEE.js → chunk-DS4IBFWA.js} +252 -6
- package/dist/chunk-DS4IBFWA.js.map +1 -0
- package/dist/{chunk-7CR4YB7Y.js → chunk-F6PZZ4KI.js} +730 -730
- package/dist/{chunk-7CR4YB7Y.js.map → chunk-F6PZZ4KI.js.map} +1 -1
- package/dist/{chunk-ZV2GZKFR.mjs → chunk-HN5ED7OF.mjs} +2 -2
- package/dist/{chunk-FZ5SPJEU.mjs → chunk-UGSOBBZH.mjs} +4 -4
- package/dist/{chunk-C6UXEPNY.js → chunk-UXJAS6C5.js} +7 -7
- package/dist/{chunk-C6UXEPNY.js.map → chunk-UXJAS6C5.js.map} +1 -1
- package/dist/client/index.d.mts +3 -3
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +3 -3
- package/dist/components/index.d.ts +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/{config-BrkU_Ynz.d.ts → config-Cd5xTEfZ.d.ts} +1 -1
- package/dist/{config-snd2ToEB.d.mts → config-YyhFHxkU.d.mts} +1 -1
- package/dist/contexts/index.d.mts +1 -1
- package/dist/contexts/index.d.ts +1 -1
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +13 -49
- package/dist/core/index.d.ts +13 -49
- package/dist/core/index.js +8 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +7 -1
- package/dist/features/help/index.d.mts +2 -2
- package/dist/features/help/index.d.ts +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.css +29 -0
- package/dist/features/tokenusage/index.css.map +1 -0
- package/dist/features/tokenusage/index.d.mts +388 -0
- package/dist/features/tokenusage/index.d.ts +388 -0
- package/dist/features/tokenusage/index.js +1012 -0
- package/dist/features/tokenusage/index.js.map +1 -0
- package/dist/features/tokenusage/index.mjs +1012 -0
- package/dist/features/tokenusage/index.mjs.map +1 -0
- package/dist/index.d.mts +8 -6
- package/dist/index.d.ts +8 -6
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -2
- package/dist/{s3.service-Dmfxpn-k.d.ts → s3.service-CpwA3sl_.d.ts} +2 -123
- package/dist/{s3.service-DYflLXCt.d.mts → s3.service-Dt6MfuHr.d.mts} +2 -123
- package/dist/server/index.d.mts +3 -2
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/tokenusage-admin.module-5wJ_tZTj.d.ts +61 -0
- package/dist/tokenusage-admin.module-DQVT4O4j.d.mts +61 -0
- package/dist/{types-CQSjy7et.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
- package/dist/{types-DHOxe8rc.d.ts → types-bsm8Fp55.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-tjq2mlDV.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-uOnyJ6j2.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
- package/package.json +6 -1
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +189 -0
- package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +138 -0
- package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +177 -0
- package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +160 -0
- package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +99 -0
- package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +225 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +42 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +41 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageTimelineChart.spec.tsx +47 -0
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +252 -0
- package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +80 -0
- package/src/features/tokenusage/data/TokenUsageAdminService.ts +54 -0
- package/src/features/tokenusage/data/__tests__/tokenusage-admin-models.spec.ts +147 -0
- package/src/features/tokenusage/data/index.ts +8 -0
- package/src/features/tokenusage/data/tokenusage-admin-breakdown.interface.ts +18 -0
- package/src/features/tokenusage/data/tokenusage-admin-breakdown.ts +115 -0
- package/src/features/tokenusage/data/tokenusage-admin-summary.interface.ts +14 -0
- package/src/features/tokenusage/data/tokenusage-admin-summary.ts +90 -0
- package/src/features/tokenusage/data/tokenusage-admin-timeline.interface.ts +13 -0
- package/src/features/tokenusage/data/tokenusage-admin-timeline.ts +94 -0
- package/src/features/tokenusage/data/tokenusage-admin.types.ts +56 -0
- package/src/features/tokenusage/i18n-keys.ts +65 -0
- package/src/features/tokenusage/index.ts +30 -0
- package/src/features/tokenusage/lib/metrics.ts +69 -0
- package/src/features/tokenusage/lib/operation-label.ts +41 -0
- package/src/features/tokenusage/lib/palette.ts +197 -0
- package/src/features/tokenusage/tokenusage-admin.module.ts +22 -0
- package/dist/chunk-5SFFHIEE.js.map +0 -1
- package/dist/chunk-KZHHKPQZ.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-35DDJH3M.mjs.map → BlockNoteEditor-34QM4UR5.mjs.map} +0 -0
- /package/dist/{chunk-ZV2GZKFR.mjs.map → chunk-HN5ED7OF.mjs.map} +0 -0
- /package/dist/{chunk-FZ5SPJEU.mjs.map → chunk-UGSOBBZH.mjs.map} +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useTheme } from "next-themes";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
|
7
|
+
import {
|
|
8
|
+
ChartConfig,
|
|
9
|
+
ChartContainer,
|
|
10
|
+
ChartLegend,
|
|
11
|
+
ChartLegendContent,
|
|
12
|
+
ChartTooltip,
|
|
13
|
+
ChartTooltipContent,
|
|
14
|
+
} from "../../../shadcnui";
|
|
15
|
+
import { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface";
|
|
16
|
+
import { Metric, StackBy } from "../data/tokenusage-admin.types";
|
|
17
|
+
import { operationLabel } from "../lib/operation-label";
|
|
18
|
+
import { CATEGORICAL_CEILING, ChartMode, OTHER_COLOR, seriesColor } from "../lib/palette";
|
|
19
|
+
|
|
20
|
+
/** The rollup series every series past the colour ceiling folds into. */
|
|
21
|
+
const OTHER_SERIES = "other";
|
|
22
|
+
|
|
23
|
+
type TokenUsageTimelineChartProps = {
|
|
24
|
+
rows: TokenUsageAdminTimelineInterface[];
|
|
25
|
+
metric: Metric;
|
|
26
|
+
stackBy: StackBy;
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TimelineBucket = { bucket: string } & Record<string, number | string>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The bucket key, derived with UTC getters.
|
|
34
|
+
*
|
|
35
|
+
* The backend field is `type: "date"` — a calendar day with no time — and the
|
|
36
|
+
* model parses the `YYYY-MM-DD` wire value with `new Date(...)`, which lands on
|
|
37
|
+
* UTC midnight. Reading it back with local getters would shift the bucket a day
|
|
38
|
+
* early west of UTC, so the key (and every label built from it) stays in UTC.
|
|
39
|
+
*/
|
|
40
|
+
const bucketKey = (date: Date): string => {
|
|
41
|
+
const y = date.getUTCFullYear();
|
|
42
|
+
const m = `${date.getUTCMonth() + 1}`.padStart(2, "0");
|
|
43
|
+
const d = `${date.getUTCDate()}`.padStart(2, "0");
|
|
44
|
+
return `${y}-${m}-${d}`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const metricValue = (row: TokenUsageAdminTimelineInterface, metric: Metric): number => {
|
|
48
|
+
if (metric === "cost") return row.cost;
|
|
49
|
+
if (metric === "credits") return row.credits;
|
|
50
|
+
return row.tokensIn + row.tokensOut;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The granularity the data was bucketed at, inferred from the spacing between
|
|
55
|
+
* consecutive buckets. The backend chooses the granularity and the rows carry
|
|
56
|
+
* the consequence, so the axis reads it off the data rather than taking a prop
|
|
57
|
+
* that could disagree with what was actually fetched.
|
|
58
|
+
*/
|
|
59
|
+
const inferGranularity = (buckets: string[]): "day" | "week" | "month" => {
|
|
60
|
+
if (buckets.length < 2) return "day";
|
|
61
|
+
|
|
62
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
63
|
+
let smallestGap = Number.POSITIVE_INFINITY;
|
|
64
|
+
for (let i = 1; i < buckets.length; i += 1) {
|
|
65
|
+
const gap = (Date.parse(buckets[i]) - Date.parse(buckets[i - 1])) / dayMs;
|
|
66
|
+
if (gap > 0 && gap < smallestGap) smallestGap = gap;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (smallestGap >= 28) return "month";
|
|
70
|
+
if (smallestGap >= 7) return "week";
|
|
71
|
+
return "day";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const bucketFormatter = (granularity: "day" | "week" | "month"): Intl.DateTimeFormat =>
|
|
75
|
+
granularity === "month"
|
|
76
|
+
? new Intl.DateTimeFormat("it-IT", { month: "short", year: "numeric", timeZone: "UTC" })
|
|
77
|
+
: new Intl.DateTimeFormat("it-IT", { day: "numeric", month: "short", timeZone: "UTC" });
|
|
78
|
+
|
|
79
|
+
const compactNumber = new Intl.NumberFormat("it-IT", { notation: "compact", maximumFractionDigits: 1 });
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Usage over time as a stacked bar chart.
|
|
83
|
+
*
|
|
84
|
+
* Colour does an IDENTITY job here — each stacked segment is a series, not a
|
|
85
|
+
* magnitude — so it draws the fixed categorical order from `../lib/palette` and
|
|
86
|
+
* never generates or cycles a hue: series past the ceiling are summed into a
|
|
87
|
+
* single "other" segment painted in the palette's neutral.
|
|
88
|
+
*
|
|
89
|
+
* The palette's light-mode validator run carries a sub-3:1 contrast WARN on
|
|
90
|
+
* three slots, which obliges a relief channel. That is why the legend and the
|
|
91
|
+
* value-carrying tooltip below are not optional decoration: they are what makes
|
|
92
|
+
* the low-contrast fills readable.
|
|
93
|
+
*/
|
|
94
|
+
export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: TokenUsageTimelineChartProps) {
|
|
95
|
+
const t = useTranslations();
|
|
96
|
+
const { resolvedTheme } = useTheme();
|
|
97
|
+
const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light";
|
|
98
|
+
|
|
99
|
+
const { chartData, seriesKeys } = useMemo(() => {
|
|
100
|
+
if (rows.length === 0) return { chartData: [] as TimelineBucket[], seriesKeys: [] as string[] };
|
|
101
|
+
|
|
102
|
+
// Totals per raw series decide who keeps an identity colour: the biggest
|
|
103
|
+
// seven do, everything else is summed into "other". Ranking by total (not by
|
|
104
|
+
// first appearance) keeps the assignment stable while a filter changes which
|
|
105
|
+
// series are present.
|
|
106
|
+
const totals = new Map<string, number>();
|
|
107
|
+
for (const row of rows) totals.set(row.series, (totals.get(row.series) ?? 0) + metricValue(row, metric));
|
|
108
|
+
|
|
109
|
+
const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([series]) => series);
|
|
110
|
+
const named = ranked.slice(0, CATEGORICAL_CEILING);
|
|
111
|
+
const folded = new Set(ranked.slice(CATEGORICAL_CEILING));
|
|
112
|
+
|
|
113
|
+
const keys = folded.size > 0 ? [...named, OTHER_SERIES] : named;
|
|
114
|
+
|
|
115
|
+
const byBucket = new Map<string, TimelineBucket>();
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
const key = bucketKey(row.bucket);
|
|
118
|
+
let entry = byBucket.get(key);
|
|
119
|
+
if (!entry) {
|
|
120
|
+
entry = { bucket: key };
|
|
121
|
+
for (const series of keys) entry[series] = 0;
|
|
122
|
+
byBucket.set(key, entry);
|
|
123
|
+
}
|
|
124
|
+
const series = folded.has(row.series) ? OTHER_SERIES : row.series;
|
|
125
|
+
entry[series] = (entry[series] as number) + metricValue(row, metric);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
chartData: [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)),
|
|
130
|
+
seriesKeys: keys,
|
|
131
|
+
};
|
|
132
|
+
}, [rows, metric]);
|
|
133
|
+
|
|
134
|
+
const seriesLabel = (series: string): string => {
|
|
135
|
+
if (series === OTHER_SERIES) {
|
|
136
|
+
const key = "token_usage.series.other";
|
|
137
|
+
return t.has(key) ? t(key) : "Altro";
|
|
138
|
+
}
|
|
139
|
+
// Stacking by company makes the series a company NAME, which is data, not
|
|
140
|
+
// vocabulary — it is never looked up. Scope and type keys are vocabulary the
|
|
141
|
+
// consuming app supplies, with the raw key as the fallback.
|
|
142
|
+
if (stackBy === "company") return series;
|
|
143
|
+
return operationLabel(
|
|
144
|
+
series,
|
|
145
|
+
(key) => t(key),
|
|
146
|
+
(key) => t.has(key),
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const chartConfig = useMemo(
|
|
151
|
+
() => Object.fromEntries(seriesKeys.map((series) => [series, { label: seriesLabel(series) }])) as ChartConfig,
|
|
152
|
+
[seriesKeys, stackBy],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (chartData.length === 0) {
|
|
156
|
+
const key = "token_usage.timeline.empty";
|
|
157
|
+
return (
|
|
158
|
+
<p className={className ? `text-muted-foreground text-sm ${className}` : "text-muted-foreground text-sm"}>
|
|
159
|
+
{t.has(key) ? t(key) : "Nessun dato nel periodo selezionato"}
|
|
160
|
+
</p>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const granularity = inferGranularity(chartData.map((entry) => entry.bucket));
|
|
165
|
+
const formatBucket = bucketFormatter(granularity);
|
|
166
|
+
const seriesColorFor = (series: string, index: number) =>
|
|
167
|
+
series === OTHER_SERIES ? OTHER_COLOR : seriesColor(index, mode);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className={className}>
|
|
171
|
+
{/* The pivot, exposed for assertions and for screen readers that would
|
|
172
|
+
otherwise get nothing from the SVG. */}
|
|
173
|
+
<span className="sr-only" data-testid="timeline-data">
|
|
174
|
+
{JSON.stringify(chartData)}
|
|
175
|
+
</span>
|
|
176
|
+
<span className="sr-only" data-testid="timeline-series">
|
|
177
|
+
{JSON.stringify(seriesKeys)}
|
|
178
|
+
</span>
|
|
179
|
+
|
|
180
|
+
<ChartContainer config={chartConfig} className="aspect-auto h-72 w-full">
|
|
181
|
+
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
|
182
|
+
<CartesianGrid vertical={false} />
|
|
183
|
+
<XAxis
|
|
184
|
+
dataKey="bucket"
|
|
185
|
+
tickLine={false}
|
|
186
|
+
axisLine={false}
|
|
187
|
+
tickMargin={8}
|
|
188
|
+
minTickGap={16}
|
|
189
|
+
tickFormatter={(value: string) => formatBucket.format(new Date(`${value}T00:00:00.000Z`))}
|
|
190
|
+
/>
|
|
191
|
+
<YAxis
|
|
192
|
+
tickLine={false}
|
|
193
|
+
axisLine={false}
|
|
194
|
+
tickMargin={8}
|
|
195
|
+
width={48}
|
|
196
|
+
tickFormatter={(value: number) => compactNumber.format(value)}
|
|
197
|
+
/>
|
|
198
|
+
<ChartTooltip
|
|
199
|
+
content={
|
|
200
|
+
<ChartTooltipContent
|
|
201
|
+
labelFormatter={(value) => formatBucket.format(new Date(`${String(value)}T00:00:00.000Z`))}
|
|
202
|
+
/>
|
|
203
|
+
}
|
|
204
|
+
/>
|
|
205
|
+
{seriesKeys.length >= 2 ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
206
|
+
{seriesKeys.map((series, index) => (
|
|
207
|
+
<Bar
|
|
208
|
+
key={series}
|
|
209
|
+
dataKey={series}
|
|
210
|
+
stackId="usage"
|
|
211
|
+
maxBarSize={28}
|
|
212
|
+
fill={seriesColorFor(series, index)}
|
|
213
|
+
// A 2px stroke in the surface colour is the gap between stacked
|
|
214
|
+
// segments and between neighbouring bars — the palette's own
|
|
215
|
+
// secondary-encoding channel, not a border.
|
|
216
|
+
stroke="var(--background)"
|
|
217
|
+
strokeWidth={2}
|
|
218
|
+
radius={index === seriesKeys.length - 1 ? [4, 4, 0, 0] : 0}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</BarChart>
|
|
222
|
+
</ChartContainer>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TokenUsageAdminTiles } from "../TokenUsageAdminTiles";
|
|
4
|
+
|
|
5
|
+
const row = (scope: string, window: string, cost: number, calls = 1) =>
|
|
6
|
+
({ id: `${scope}|${window}`, scope, window, cost, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls }) as any;
|
|
7
|
+
|
|
8
|
+
const summary = [
|
|
9
|
+
row("customer", "current", 9.46, 2956),
|
|
10
|
+
row("platform", "current", 9.47, 7681),
|
|
11
|
+
row("total", "current", 18.93, 10637),
|
|
12
|
+
row("customer", "previous", 8.0, 2000),
|
|
13
|
+
row("platform", "previous", 10.0, 8000),
|
|
14
|
+
row("total", "previous", 18.0, 10000),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe("TokenUsageAdminTiles", () => {
|
|
18
|
+
it("shows both cost centres with their deltas against the previous window", () => {
|
|
19
|
+
render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode={false} />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByTestId("tile-customer")).toHaveTextContent("9,46");
|
|
22
|
+
expect(screen.getByTestId("tile-platform")).toHaveTextContent("9,47");
|
|
23
|
+
// customer 9.46 vs 8.00 => +18%
|
|
24
|
+
expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("18");
|
|
25
|
+
// platform 9.47 vs 10.00 => -5%
|
|
26
|
+
expect(screen.getByTestId("tile-platform-delta")).toHaveTextContent("5");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("hides the platform tile in single-customer mode", () => {
|
|
30
|
+
render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode />);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByTestId("tile-customer")).toBeInTheDocument();
|
|
33
|
+
expect(screen.queryByTestId("tile-platform")).not.toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("suppresses the delta rather than showing Infinity when the previous window is zero", () => {
|
|
37
|
+
const zeroPrev = summary.map((r) => (r.window === "previous" ? { ...r, cost: 0 } : r));
|
|
38
|
+
render(<TokenUsageAdminTiles summary={zeroPrev as any} metric="cost" singleCustomerMode={false} />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("—");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TokenUsageRankedBar } from "../TokenUsageRankedBar";
|
|
4
|
+
|
|
5
|
+
const row = (id: string, label: string, cost: number) =>
|
|
6
|
+
({ id, label, cost, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls: 1 }) as any;
|
|
7
|
+
|
|
8
|
+
describe("TokenUsageRankedBar", () => {
|
|
9
|
+
it("direct-labels each row with its value and its share of the total", () => {
|
|
10
|
+
render(
|
|
11
|
+
<TokenUsageRankedBar
|
|
12
|
+
rows={[row("a", "Studio Rossi", 6), row("b", "Studio Bianchi", 4)]}
|
|
13
|
+
metric="cost"
|
|
14
|
+
emptyLabel="nessun dato"
|
|
15
|
+
/>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByTestId("ranked-value-a")).toHaveTextContent("6,00");
|
|
19
|
+
expect(screen.getByTestId("ranked-share-a")).toHaveTextContent("60");
|
|
20
|
+
expect(screen.getByTestId("ranked-share-b")).toHaveTextContent("40");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders the empty label when there are no rows", () => {
|
|
24
|
+
render(<TokenUsageRankedBar rows={[]} metric="cost" emptyLabel="nessun dato" />);
|
|
25
|
+
expect(screen.getByText("nessun dato")).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("assigns colour by rank position from one ramp, so no two rows share an identity hue by accident", () => {
|
|
29
|
+
render(
|
|
30
|
+
<TokenUsageRankedBar
|
|
31
|
+
rows={[row("a", "A", 3), row("b", "B", 2), row("c", "C", 1)]}
|
|
32
|
+
metric="cost"
|
|
33
|
+
emptyLabel="nessun dato"
|
|
34
|
+
/>,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const colours = ["a", "b", "c"].map((id) => screen.getByTestId(`ranked-fill-${id}`).getAttribute("data-ramp-step"));
|
|
38
|
+
expect(new Set(colours).size).toBe(3);
|
|
39
|
+
expect(colours).toEqual(["0", "1", "2"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TokenUsageTimelineChart } from "../TokenUsageTimelineChart";
|
|
4
|
+
|
|
5
|
+
const row = (bucket: string, series: string, cost: number) =>
|
|
6
|
+
({
|
|
7
|
+
id: `${bucket}|${series}`,
|
|
8
|
+
bucket: new Date(bucket),
|
|
9
|
+
series,
|
|
10
|
+
cost,
|
|
11
|
+
credits: 0,
|
|
12
|
+
tokensIn: 0,
|
|
13
|
+
tokensOut: 0,
|
|
14
|
+
cached: 0,
|
|
15
|
+
calls: 1,
|
|
16
|
+
}) as any;
|
|
17
|
+
|
|
18
|
+
describe("TokenUsageTimelineChart", () => {
|
|
19
|
+
it("pivots flat rows into one entry per bucket with a key per series", () => {
|
|
20
|
+
render(
|
|
21
|
+
<TokenUsageTimelineChart
|
|
22
|
+
rows={[row("2026-08-01", "customer", 1), row("2026-08-01", "platform", 2), row("2026-08-02", "customer", 3)]}
|
|
23
|
+
metric="cost"
|
|
24
|
+
stackBy="scope"
|
|
25
|
+
/>,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const data = JSON.parse(screen.getByTestId("timeline-data").textContent!);
|
|
29
|
+
expect(data).toHaveLength(2);
|
|
30
|
+
expect(data[0]).toMatchObject({ customer: 1, platform: 2 });
|
|
31
|
+
expect(data[1]).toMatchObject({ customer: 3 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("folds series beyond the 7-colour ceiling into a single 'other' series", () => {
|
|
35
|
+
const rows = Array.from({ length: 10 }, (_, i) => row("2026-08-01", `type${i}`, 10 - i));
|
|
36
|
+
render(<TokenUsageTimelineChart rows={rows} metric="cost" stackBy="type" />);
|
|
37
|
+
|
|
38
|
+
const series = JSON.parse(screen.getByTestId("timeline-series").textContent!);
|
|
39
|
+
expect(series).toHaveLength(8);
|
|
40
|
+
expect(series[7]).toBe("other");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders the empty state without a chart when there are no rows", () => {
|
|
44
|
+
render(<TokenUsageTimelineChart rows={[]} metric="cost" stackBy="scope" />);
|
|
45
|
+
expect(screen.queryByTestId("timeline-data")).not.toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { SharedProvider } from "../../../contexts";
|
|
6
|
+
import { usePageUrlGenerator } from "../../../hooks";
|
|
7
|
+
import { BreadcrumbItemData } from "../../../interfaces";
|
|
8
|
+
import { TokenUsageAdminFilterBar } from "../components/TokenUsageAdminFilterBar";
|
|
9
|
+
import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
|
|
10
|
+
import type { TokenUsageAdminSummaryInterface } from "../data/tokenusage-admin-summary.interface";
|
|
11
|
+
import type { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface";
|
|
12
|
+
import type { Granularity, Metric, StackBy } from "../data/tokenusage-admin.types";
|
|
13
|
+
import { TokenUsageAdminService } from "../data/TokenUsageAdminService";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Route the breadcrumb links back to. The page itself lives in the consuming
|
|
17
|
+
* app (a360ai mounts it at `/administration/token-usage`); the constant is here
|
|
18
|
+
* because the breadcrumb is built by this provider, not by the app.
|
|
19
|
+
*/
|
|
20
|
+
const TOKEN_USAGE_ADMIN_PAGE_URL = "/administration/token-usage";
|
|
21
|
+
|
|
22
|
+
/** Rows kept per ranked panel before the repository folds the tail into "other". */
|
|
23
|
+
const DEFAULT_TOP_N = 10;
|
|
24
|
+
|
|
25
|
+
export type TokenUsageAdminFilterState = {
|
|
26
|
+
/** ISO 8601 instant. */
|
|
27
|
+
from: string;
|
|
28
|
+
/** ISO 8601 instant. */
|
|
29
|
+
to: string;
|
|
30
|
+
granularity: Granularity;
|
|
31
|
+
stackBy: StackBy;
|
|
32
|
+
companyId?: string;
|
|
33
|
+
metric: Metric;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface TokenUsageAdminContextType {
|
|
37
|
+
summary: TokenUsageAdminSummaryInterface[];
|
|
38
|
+
timeline: TokenUsageAdminTimelineInterface[];
|
|
39
|
+
byCompany: TokenUsageAdminBreakdownInterface[];
|
|
40
|
+
byUser: TokenUsageAdminBreakdownInterface[];
|
|
41
|
+
/** Platform-side spend split by operation. Always empty in single-customer mode. */
|
|
42
|
+
/** Platform spend by operation — empty in single-customer mode. */
|
|
43
|
+
byOperation: TokenUsageAdminBreakdownInterface[];
|
|
44
|
+
/** Customer spend by operation — the customer-side mirror of byOperation. */
|
|
45
|
+
byCustomerOperation: TokenUsageAdminBreakdownInterface[];
|
|
46
|
+
companies: { id: string; label: string }[];
|
|
47
|
+
filters: TokenUsageAdminFilterState;
|
|
48
|
+
/** Merges a partial patch into the current filters; every key is optional. */
|
|
49
|
+
setFilters: (next: Partial<TokenUsageAdminFilterState>) => void;
|
|
50
|
+
/** `true` once a company filter is applied — the platform panels are meaningless then. */
|
|
51
|
+
singleCustomerMode: boolean;
|
|
52
|
+
isLoading: boolean;
|
|
53
|
+
error: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TokenUsageAdminContext = createContext<TokenUsageAdminContextType | undefined>(undefined);
|
|
57
|
+
|
|
58
|
+
/** Current calendar month to now, which is the window the page opens on. */
|
|
59
|
+
function defaultRange(): { from: string; to: string } {
|
|
60
|
+
const now = new Date();
|
|
61
|
+
const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
62
|
+
return { from: start.toISOString(), to: now.toISOString() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type TokenUsageAdminProviderProps = {
|
|
66
|
+
children: ReactNode;
|
|
67
|
+
/** Pre-select a company, which puts the page in single-customer mode from the first render. */
|
|
68
|
+
initialCompanyId?: string;
|
|
69
|
+
/** ISO 8601 instant. Defaults to the start of the current month. */
|
|
70
|
+
initialFrom?: string;
|
|
71
|
+
/** ISO 8601 instant. Defaults to now. */
|
|
72
|
+
initialTo?: string;
|
|
73
|
+
/** Rows per ranked panel. Defaults to 10. */
|
|
74
|
+
topN?: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Owns every filter the administrative token-usage page reads, fetches the five
|
|
79
|
+
* panels behind it, and publishes the filter bar into the page title bar.
|
|
80
|
+
*
|
|
81
|
+
* The filter bar is rendered into `title.functions` here — NOT in the container —
|
|
82
|
+
* because `RoundPageContainer`'s title bar reads `title.functions` from
|
|
83
|
+
* `SharedContext`, and a descendant cannot inject nodes into an ancestor's
|
|
84
|
+
* provider value. That is why the filter state lives at this level.
|
|
85
|
+
*/
|
|
86
|
+
export const TokenUsageAdminProvider = ({
|
|
87
|
+
children,
|
|
88
|
+
initialCompanyId,
|
|
89
|
+
initialFrom,
|
|
90
|
+
initialTo,
|
|
91
|
+
topN = DEFAULT_TOP_N,
|
|
92
|
+
}: TokenUsageAdminProviderProps) => {
|
|
93
|
+
const t = useTranslations();
|
|
94
|
+
const generateUrl = usePageUrlGenerator();
|
|
95
|
+
|
|
96
|
+
const [filters, setFilterState] = useState<TokenUsageAdminFilterState>(() => {
|
|
97
|
+
const range = defaultRange();
|
|
98
|
+
return {
|
|
99
|
+
from: initialFrom ?? range.from,
|
|
100
|
+
to: initialTo ?? range.to,
|
|
101
|
+
granularity: "day",
|
|
102
|
+
stackBy: "scope",
|
|
103
|
+
companyId: initialCompanyId,
|
|
104
|
+
metric: "cost",
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const [summary, setSummary] = useState<TokenUsageAdminSummaryInterface[]>([]);
|
|
109
|
+
const [timeline, setTimeline] = useState<TokenUsageAdminTimelineInterface[]>([]);
|
|
110
|
+
const [byCompany, setByCompany] = useState<TokenUsageAdminBreakdownInterface[]>([]);
|
|
111
|
+
const [byUser, setByUser] = useState<TokenUsageAdminBreakdownInterface[]>([]);
|
|
112
|
+
const [byOperation, setByOperation] = useState<TokenUsageAdminBreakdownInterface[]>([]);
|
|
113
|
+
const [byCustomerOperation, setByCustomerOperation] = useState<TokenUsageAdminBreakdownInterface[]>([]);
|
|
114
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
115
|
+
const [error, setError] = useState<string | null>(null);
|
|
116
|
+
|
|
117
|
+
const { from, to, granularity, stackBy, companyId, metric } = filters;
|
|
118
|
+
const singleCustomerMode = Boolean(companyId);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
// `cancelled` is the out-of-order guard: a filter change fires a new request
|
|
122
|
+
// while the previous one is still in flight, and without this flag the slower
|
|
123
|
+
// (older) response would land last and overwrite the fresher state.
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
|
|
126
|
+
setIsLoading(true);
|
|
127
|
+
setError(null);
|
|
128
|
+
|
|
129
|
+
const base = { from, to, companyId };
|
|
130
|
+
|
|
131
|
+
Promise.all([
|
|
132
|
+
TokenUsageAdminService.getSummary(base),
|
|
133
|
+
TokenUsageAdminService.getTimeline({ ...base, granularity, stackBy }),
|
|
134
|
+
TokenUsageAdminService.getBreakdown({ ...base, dimension: "company", scope: "customer", limit: topN }),
|
|
135
|
+
TokenUsageAdminService.getBreakdown({ ...base, dimension: "user", scope: "customer", limit: topN }),
|
|
136
|
+
// Platform spend is, by definition, the usage with no owning company, so
|
|
137
|
+
// filtering it by one is meaningless — the call is skipped entirely rather
|
|
138
|
+
// than issued and discarded.
|
|
139
|
+
companyId
|
|
140
|
+
? Promise.resolve<TokenUsageAdminBreakdownInterface[]>([])
|
|
141
|
+
: TokenUsageAdminService.getBreakdown({ ...base, dimension: "operation", scope: "platform", limit: topN }),
|
|
142
|
+
// Customer spend by operation — unlike the platform panel this one IS
|
|
143
|
+
// meaningful under a company filter, so it is always requested.
|
|
144
|
+
TokenUsageAdminService.getBreakdown({ ...base, dimension: "operation", scope: "customer", limit: topN }),
|
|
145
|
+
])
|
|
146
|
+
.then(([nextSummary, nextTimeline, nextByCompany, nextByUser, nextByOperation, nextByCustomerOperation]) => {
|
|
147
|
+
if (cancelled) return;
|
|
148
|
+
setSummary(nextSummary ?? []);
|
|
149
|
+
setTimeline(nextTimeline ?? []);
|
|
150
|
+
setByCompany(nextByCompany ?? []);
|
|
151
|
+
setByUser(nextByUser ?? []);
|
|
152
|
+
setByOperation(nextByOperation ?? []);
|
|
153
|
+
setByCustomerOperation(nextByCustomerOperation ?? []);
|
|
154
|
+
})
|
|
155
|
+
.catch((err) => {
|
|
156
|
+
if (cancelled) return;
|
|
157
|
+
console.error("Failed to load administrative token usage:", err);
|
|
158
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
159
|
+
})
|
|
160
|
+
.finally(() => {
|
|
161
|
+
if (cancelled) return;
|
|
162
|
+
setIsLoading(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
cancelled = true;
|
|
167
|
+
};
|
|
168
|
+
}, [from, to, granularity, stackBy, companyId, topN]);
|
|
169
|
+
|
|
170
|
+
const setFilters = useCallback((next: Partial<TokenUsageAdminFilterState>) => {
|
|
171
|
+
setFilterState((prev) => ({ ...prev, ...next }));
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Derived from the company breakdown, per the page design. Note the
|
|
176
|
+
* consequence: once a company filter is applied the breakdown returns that
|
|
177
|
+
* company alone, so the selector narrows to it — clearing the filter restores
|
|
178
|
+
* the full list.
|
|
179
|
+
*/
|
|
180
|
+
const companies = useMemo(
|
|
181
|
+
() => byCompany.filter((row) => row.id !== "other").map((row) => ({ id: row.id, label: row.label })),
|
|
182
|
+
[byCompany],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const breadcrumb = (): BreadcrumbItemData[] => [
|
|
186
|
+
{
|
|
187
|
+
name: t("token_usage.admin.title"),
|
|
188
|
+
href: generateUrl({ page: TOKEN_USAGE_ADMIN_PAGE_URL }),
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const title = () => ({
|
|
193
|
+
type: t("token_usage.admin.title"),
|
|
194
|
+
functions: (
|
|
195
|
+
<TokenUsageAdminFilterBar
|
|
196
|
+
key="tokenUsageAdminFilterBar"
|
|
197
|
+
from={from}
|
|
198
|
+
to={to}
|
|
199
|
+
granularity={granularity}
|
|
200
|
+
companyId={companyId}
|
|
201
|
+
metric={metric}
|
|
202
|
+
companies={companies}
|
|
203
|
+
onChange={setFilters}
|
|
204
|
+
/>
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const contextValue = useMemo<TokenUsageAdminContextType>(
|
|
209
|
+
() => ({
|
|
210
|
+
summary,
|
|
211
|
+
timeline,
|
|
212
|
+
byCompany,
|
|
213
|
+
byUser,
|
|
214
|
+
byOperation,
|
|
215
|
+
byCustomerOperation,
|
|
216
|
+
companies,
|
|
217
|
+
filters,
|
|
218
|
+
setFilters,
|
|
219
|
+
singleCustomerMode,
|
|
220
|
+
isLoading,
|
|
221
|
+
error,
|
|
222
|
+
}),
|
|
223
|
+
[
|
|
224
|
+
summary,
|
|
225
|
+
timeline,
|
|
226
|
+
byCompany,
|
|
227
|
+
byUser,
|
|
228
|
+
byOperation,
|
|
229
|
+
byCustomerOperation,
|
|
230
|
+
companies,
|
|
231
|
+
filters,
|
|
232
|
+
setFilters,
|
|
233
|
+
singleCustomerMode,
|
|
234
|
+
isLoading,
|
|
235
|
+
error,
|
|
236
|
+
],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
|
|
241
|
+
<TokenUsageAdminContext.Provider value={contextValue}>{children}</TokenUsageAdminContext.Provider>
|
|
242
|
+
</SharedProvider>
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export const useTokenUsageAdmin = (): TokenUsageAdminContextType => {
|
|
247
|
+
const ctx = useContext(TokenUsageAdminContext);
|
|
248
|
+
if (!ctx) {
|
|
249
|
+
throw new Error("useTokenUsageAdmin() called outside <TokenUsageAdminProvider>.");
|
|
250
|
+
}
|
|
251
|
+
return ctx;
|
|
252
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { TokenUsageAdminService } from "../../data/TokenUsageAdminService";
|
|
4
|
+
import { TokenUsageAdminProvider, useTokenUsageAdmin } from "../TokenUsageAdminContext";
|
|
5
|
+
|
|
6
|
+
vi.mock("../../data/TokenUsageAdminService", () => ({
|
|
7
|
+
TokenUsageAdminService: {
|
|
8
|
+
getSummary: vi.fn(async () => []),
|
|
9
|
+
getTimeline: vi.fn(async () => []),
|
|
10
|
+
getBreakdown: vi.fn(async () => []),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
function Probe() {
|
|
15
|
+
const ctx = useTokenUsageAdmin();
|
|
16
|
+
return <span data-testid="mode">{String(ctx.singleCustomerMode)}</span>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("TokenUsageAdminContext", () => {
|
|
20
|
+
// The mocked service is module-level, so its call counts survive across tests.
|
|
21
|
+
// Without this reset the per-test `toHaveBeenCalledTimes` assertions would
|
|
22
|
+
// measure the whole file rather than one render.
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("throws when used outside its provider", () => {
|
|
28
|
+
expect(() => render(<Probe />)).toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("requests every panel on mount and reports not-single-customer by default", async () => {
|
|
32
|
+
render(
|
|
33
|
+
<TokenUsageAdminProvider>
|
|
34
|
+
<Probe />
|
|
35
|
+
</TokenUsageAdminProvider>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
await waitFor(() => expect(TokenUsageAdminService.getSummary).toHaveBeenCalled());
|
|
39
|
+
|
|
40
|
+
// Four ranked panels: company, user, customer-by-operation, platform-by-operation.
|
|
41
|
+
// Asserted by dimension+scope rather than by count alone, so a wrong pairing
|
|
42
|
+
// fails here instead of silently rendering the wrong data.
|
|
43
|
+
const calls = (TokenUsageAdminService.getBreakdown as ReturnType<typeof vi.fn>).mock.calls.map(([args]) => [
|
|
44
|
+
args.dimension,
|
|
45
|
+
args.scope,
|
|
46
|
+
]);
|
|
47
|
+
expect(calls).toEqual(
|
|
48
|
+
expect.arrayContaining([
|
|
49
|
+
["company", "customer"],
|
|
50
|
+
["user", "customer"],
|
|
51
|
+
["operation", "customer"],
|
|
52
|
+
["operation", "platform"],
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
expect(calls).toHaveLength(4);
|
|
56
|
+
expect(screen.getByTestId("mode")).toHaveTextContent("false");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("skips only the PLATFORM operation breakdown once a company filter is applied", async () => {
|
|
60
|
+
render(
|
|
61
|
+
<TokenUsageAdminProvider initialCompanyId="c1">
|
|
62
|
+
<Probe />
|
|
63
|
+
</TokenUsageAdminProvider>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await waitFor(() => expect(TokenUsageAdminService.getSummary).toHaveBeenCalled());
|
|
67
|
+
|
|
68
|
+
const calls = (TokenUsageAdminService.getBreakdown as ReturnType<typeof vi.fn>).mock.calls.map(([args]) => [
|
|
69
|
+
args.dimension,
|
|
70
|
+
args.scope,
|
|
71
|
+
]);
|
|
72
|
+
// Platform spend has no owning company, so filtering it is meaningless and
|
|
73
|
+
// the call is skipped. The CUSTOMER operation panel is still meaningful and
|
|
74
|
+
// must survive — that distinction is the point of this test.
|
|
75
|
+
expect(calls).toContainEqual(["operation", "customer"]);
|
|
76
|
+
expect(calls).not.toContainEqual(["operation", "platform"]);
|
|
77
|
+
expect(calls).toHaveLength(3);
|
|
78
|
+
expect(screen.getByTestId("mode")).toHaveTextContent("true");
|
|
79
|
+
});
|
|
80
|
+
});
|