mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Profile per-column cardinality and extent; add data-aware chart warnings (M4, A3/A4)
This commit is contained in:
@@ -223,6 +223,206 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
expect(hint?.message).toMatch(/reduce the number of categories/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data-aware hints (from profiled column stats)', () => {
|
||||
/** A BuilderColumns carrying stats for the named field. */
|
||||
const withStats = (
|
||||
name: string,
|
||||
stats: {
|
||||
distinct?: number;
|
||||
distinctCapped?: boolean;
|
||||
numericExtent?: { min: number; max: number } | null;
|
||||
},
|
||||
): BuilderColumns => ({
|
||||
columns: [name],
|
||||
columnTypes: [{ name, type: 'string' }],
|
||||
columnStats: [
|
||||
{
|
||||
name,
|
||||
distinct: stats.distinct ?? 1,
|
||||
distinctCapped: stats.distinctCapped ?? false,
|
||||
numericExtent: stats.numericExtent ?? null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('flags an aggregated category axis that still has too many distinct values', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'sku', type: 'nominal' },
|
||||
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' }, // aggregated, so not one-per-row
|
||||
},
|
||||
},
|
||||
500,
|
||||
withStats('sku', { distinct: 48 }),
|
||||
);
|
||||
const hint = w.find((m) => /distinct values/.test(m.message));
|
||||
expect(hint?.channel).toBe('x');
|
||||
expect(hint?.message).toMatch(/48 distinct values/);
|
||||
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
|
||||
});
|
||||
|
||||
it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'sku', type: 'nominal' },
|
||||
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
|
||||
},
|
||||
},
|
||||
500,
|
||||
withStats('sku', { distinct: 50, distinctCapped: true }),
|
||||
);
|
||||
expect(w.some((m) => /more than 50 distinct values/.test(m.message))).toBe(true);
|
||||
});
|
||||
|
||||
it('is silent for a small-cardinality aggregated category axis', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'sku', type: 'nominal' },
|
||||
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
|
||||
},
|
||||
},
|
||||
500,
|
||||
withStats('sku', { distinct: 6 }),
|
||||
);
|
||||
expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
|
||||
});
|
||||
|
||||
it('warns when a discrete colour series has too many categories', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'sku', type: 'nominal' },
|
||||
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
|
||||
color: { field: 'tag', type: 'nominal' },
|
||||
},
|
||||
},
|
||||
500,
|
||||
{
|
||||
columns: ['tag'],
|
||||
columnTypes: [{ name: 'tag', type: 'string' }],
|
||||
columnStats: [{ name: 'tag', distinct: 20, distinctCapped: false, numericExtent: null }],
|
||||
},
|
||||
);
|
||||
const hint = w.find((m) => m.channel === 'color' && /legend/.test(m.message));
|
||||
expect(hint?.message).toMatch(/20 categories/);
|
||||
});
|
||||
|
||||
it('does not flag a continuous (quantitative) colour ramp for cardinality', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'point',
|
||||
encodings: {
|
||||
x: { field: 'a', type: 'quantitative' },
|
||||
y: { field: 'b', type: 'quantitative' },
|
||||
color: { field: 'score', type: 'quantitative' }, // a ramp, no per-value legend
|
||||
},
|
||||
},
|
||||
500,
|
||||
{
|
||||
columns: ['score'],
|
||||
columnTypes: [{ name: 'score', type: 'number' }],
|
||||
columnStats: [
|
||||
{
|
||||
name: 'score',
|
||||
distinct: 50,
|
||||
distinctCapped: true,
|
||||
numericExtent: { min: 0, max: 9 },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(w.some((m) => /legend/.test(m.message))).toBe(false);
|
||||
});
|
||||
|
||||
it('guards Size against a field whose values go negative', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'point',
|
||||
encodings: {
|
||||
x: { field: 'a', type: 'quantitative' },
|
||||
y: { field: 'b', type: 'quantitative' },
|
||||
size: { field: 'delta', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
500,
|
||||
{
|
||||
columns: ['delta'],
|
||||
columnTypes: [{ name: 'delta', type: 'number' }],
|
||||
columnStats: [
|
||||
{
|
||||
name: 'delta',
|
||||
distinct: 30,
|
||||
distinctCapped: false,
|
||||
numericExtent: { min: -12, max: 40 },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const hint = w.find((m) => m.channel === 'size');
|
||||
expect(hint?.message).toMatch(/can't show negative values/);
|
||||
expect(hint?.message).toMatch(/down to -12/);
|
||||
});
|
||||
|
||||
it('does not guard Size when the field is wholly non-negative', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'point',
|
||||
encodings: {
|
||||
x: { field: 'a', type: 'quantitative' },
|
||||
y: { field: 'b', type: 'quantitative' },
|
||||
size: { field: 'amount', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
500,
|
||||
{
|
||||
columns: ['amount'],
|
||||
columnTypes: [{ name: 'amount', type: 'number' }],
|
||||
columnStats: [
|
||||
{
|
||||
name: 'amount',
|
||||
distinct: 30,
|
||||
distinctCapped: false,
|
||||
numericExtent: { min: 0, max: 40 },
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(w.some((m) => m.channel === 'size')).toBe(false);
|
||||
});
|
||||
|
||||
it('skips data-aware hints entirely when no column stats are supplied', () => {
|
||||
// Old/URL datasets: same crowded config, but without stats the hints stay quiet.
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'sku', type: 'nominal' },
|
||||
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
|
||||
size: { field: 'qty', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
500,
|
||||
);
|
||||
expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
|
||||
expect(w.some((m) => m.channel === 'size')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultBuilderConfig', () => {
|
||||
|
||||
Reference in New Issue
Block a user