Skip to content

Commit 285a39f

Browse files
committed
test(service-analytics): pin the four drill sidecars on one answer, byte for byte
One dataset answer carries all four drill-through sidecars at once: a stage grouping beside a month-bucketed date dimension, with a per-stage subtotal and the grand total. The answer is read as the declared AnalyticsResult with no cast, and the serialised sidecars are pinned byte for byte, so the retirement of the local augmentation that follows can be shown to move nothing. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97005ae commit 285a39f

1 file changed

Lines changed: 101 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20727 — the four drill-through sidecars are set on the declared
5+
* `AnalyticsResult`, with nothing in between.
6+
*
7+
* `AnalyticsResult` (`@objectstack/spec/contracts`) declares `dimensionFields`,
8+
* `drillRawRows`, `drillRawTotals` and `drillRanges` (#20700). The service once
9+
* set them through a module-private augmentation of that type; it now sets the
10+
* declared members directly. That retirement moved no byte of any answer, and
11+
* this file holds that line.
12+
*
13+
* The fixture is the one answer that carries all four at once: a grouping by
14+
* an equality-drillable dimension (`stage`) beside a month-bucketed date
15+
* dimension (`close_date`), with a `totals` selection (a per-stage subtotal
16+
* and the grand total). The answer is read as `AnalyticsResult` with no cast,
17+
* so each member read below compiles only because the contract declares it.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { DatasetSchema } from '@objectstack/spec/ui';
22+
import type { ExecutionContext } from '@objectstack/spec/kernel';
23+
import type { AnalyticsResult } from '@objectstack/spec/contracts';
24+
import { AnalyticsService } from '../analytics-service.js';
25+
26+
const CTX = { tenantId: 'org_A' } as ExecutionContext;
27+
28+
const DATASET = DatasetSchema.parse({
29+
name: 'pipe_drill',
30+
label: 'Pipe',
31+
object: 'opportunity',
32+
include: [],
33+
dimensions: [
34+
{ name: 'stage', field: 'stage', type: 'string' },
35+
{ name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' },
36+
],
37+
measures: [{ name: 'cnt', aggregate: 'count' }],
38+
});
39+
40+
/** The grouped field names of one `executeAggregate` call (a date bucket arrives as `{ field, dateGranularity }`). */
41+
function groupedFields(groupBy: unknown[] | undefined): string[] {
42+
return (groupBy ?? []).map((g) => (typeof g === 'string' ? g : (g as { field: string }).field));
43+
}
44+
45+
/**
46+
* Granularity bucketing runs through the ObjectQL aggregate path, so the driver
47+
* answers already-bucketed rows: the main grid (stage × month), the per-stage
48+
* subtotal, and the grand total, told apart by what each call groups by.
49+
*/
50+
function drillService(): AnalyticsService {
51+
return new AnalyticsService({
52+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
53+
executeAggregate: async (_object, { groupBy }) => {
54+
const grouped = groupedFields(groupBy);
55+
if (grouped.includes('close_date')) {
56+
return [
57+
{ stage: 'qualification', close_date: '2026-06', cnt: 3 },
58+
{ stage: 'won', close_date: '2026-07', cnt: 2 },
59+
];
60+
}
61+
if (grouped.includes('stage')) {
62+
return [
63+
{ stage: 'qualification', cnt: 3 },
64+
{ stage: 'won', cnt: 2 },
65+
];
66+
}
67+
return [{ cnt: 5 }];
68+
},
69+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
70+
});
71+
}
72+
73+
const DRILL_SIDECARS = ['dimensionFields', 'drillRawRows', 'drillRawTotals', 'drillRanges'];
74+
75+
describe('the drill-through sidecars are emitted on the declared AnalyticsResult (#20727)', () => {
76+
it('a grouped, date-bucketed answer with totals carries all four, byte for byte', async () => {
77+
const answer: AnalyticsResult = await drillService().queryDataset(
78+
DATASET,
79+
{ dimensions: ['stage', 'close_date'], measures: ['cnt'], totals: { groupings: [['stage'], []] } },
80+
CTX,
81+
);
82+
83+
// All four are present, in the order the service sets them.
84+
expect(Object.keys(answer).filter((k) => DRILL_SIDECARS.includes(k))).toEqual(DRILL_SIDECARS);
85+
86+
const sidecars = {
87+
dimensionFields: answer.dimensionFields,
88+
drillRawRows: answer.drillRawRows,
89+
drillRawTotals: answer.drillRawTotals,
90+
drillRanges: answer.drillRanges,
91+
};
92+
expect(JSON.stringify(sidecars)).toBe(
93+
'{"dimensionFields":{"stage":"stage"},' +
94+
'"drillRawRows":[{"stage":"qualification"},{"stage":"won"}],' +
95+
'"drillRawTotals":[[{"stage":"qualification"},{"stage":"won"}],[{}]],' +
96+
'"drillRanges":[' +
97+
'{"close_date":{"field":"close_date","gte":"2026-06-01","lt":"2026-07-01"}},' +
98+
'{"close_date":{"field":"close_date","gte":"2026-07-01","lt":"2026-08-01"}}]}',
99+
);
100+
});
101+
});

0 commit comments

Comments
 (0)