Chart builder: field-first shelf + value-or-field channels

This commit is contained in:
2026-06-11 23:56:40 +03:00
parent 9ebe398e75
commit 4dcff4601d
11 changed files with 1192 additions and 184 deletions
+78
View File
@@ -260,3 +260,81 @@ describe('createSnippet', () => {
expect(useSnippetStore.getState().snippets).toHaveLength(0);
});
});
describe('field-first assignment (assignField / focusChannel)', () => {
test('a clicked field lands on the first empty channel that accepts it', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5, qty: 2 }]);
cb().init(id); // region→X, count→Y (smart default for one category + measures)
cb().setChannelColumn('x', null);
cb().setChannelColumn('y', null);
cb().assignField('region'); // first empty channel is X
expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
cb().assignField('sales'); // X taken → Y
expect(cb().config.encodings.y).toEqual({ field: 'sales', type: 'quantitative' });
});
test('the armed channel takes the field, then disarms', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().focusChannel('color');
expect(cb().activeChannel).toBe('color');
cb().assignField('region');
expect(cb().config.encodings.color).toEqual({ field: 'region', type: 'nominal' });
expect(cb().activeChannel).toBeNull(); // armed slot cleared after assignment
});
test('skips a channel that cannot take the column (Size needs a measure)', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().focusChannel('size');
cb().assignField('region'); // a category can't go on Size → falls through to an empty axis
expect(cb().config.encodings.size ?? null).toBeNull();
// It landed on the first axis that accepts it instead of being dropped.
const onAxis =
cb().config.encodings.x?.field === 'region' || cb().config.encodings.y?.field === 'region';
expect(onAxis).toBe(true);
});
test('Count of records assigns as a field-less count measure', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelColumn('x', null);
cb().setChannelColumn('y', null);
cb().focusChannel('y');
cb().assignField(COUNT_FIELD);
expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
});
});
describe('constant values (setChannelConstant — the Property model)', () => {
test('sets a fixed colour on Color, emitted as a constant', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelConstant('color', '#c0392b');
expect(cb().config.encodings.color).toMatchObject({ value: '#c0392b' });
});
test('coerces a Size constant to a number', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelConstant('size', '120');
expect(cb().config.encodings.size?.value).toBe(120);
});
test('preserves the prior field type so toggling back to a field restores it', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelColumn('color', 'region'); // nominal field
cb().setChannelConstant('color', '#000000');
expect(cb().config.encodings.color).toEqual({ value: '#000000', type: 'nominal' });
});
test('refuses a constant on a positional channel (X/Y stay field-only)', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
const before = cb().config.encodings.x;
cb().setChannelConstant('x', '50');
expect(cb().config.encodings.x).toBe(before); // unchanged — no-op
});
});