@blueprint-chart/mcp 0.1.1 → 0.1.4
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/README.md +31 -15
- package/dist/cli.js +15 -2
- package/dist/dsl/capabilityMatrix.d.ts +22 -0
- package/dist/dsl/capabilityMatrix.js +37 -0
- package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
- package/dist/dsl/capabilityMatrix.test.js +49 -0
- package/dist/dsl/chartTypes.d.ts +16 -0
- package/dist/dsl/chartTypes.js +37 -0
- package/dist/dsl/chartTypes.test.d.ts +1 -0
- package/dist/dsl/chartTypes.test.js +32 -0
- package/dist/dsl/dataKey.d.ts +25 -0
- package/dist/dsl/dataKey.js +42 -0
- package/dist/dsl/dataKey.test.d.ts +1 -0
- package/dist/dsl/dataKey.test.js +35 -0
- package/dist/dsl/goalRanking.d.ts +7 -0
- package/dist/dsl/goalRanking.js +76 -0
- package/dist/dsl/goalRanking.test.d.ts +1 -0
- package/dist/dsl/goalRanking.test.js +83 -0
- package/dist/dsl/parseErrorHints.d.ts +12 -0
- package/dist/dsl/parseErrorHints.js +32 -0
- package/dist/dsl/parseErrorHints.test.d.ts +1 -0
- package/dist/dsl/parseErrorHints.test.js +26 -0
- package/dist/dsl/semanticWarnings.d.ts +7 -0
- package/dist/dsl/semanticWarnings.js +66 -0
- package/dist/dsl/semanticWarnings.test.d.ts +1 -0
- package/dist/dsl/semanticWarnings.test.js +32 -0
- package/dist/dsl/suggest.d.ts +1 -0
- package/dist/dsl/suggest.js +66 -0
- package/dist/dsl/suggest.test.d.ts +1 -0
- package/dist/dsl/suggest.test.js +34 -0
- package/dist/dsl/universalProperties.d.ts +30 -0
- package/dist/dsl/universalProperties.js +52 -0
- package/dist/dsl/universalProperties.test.d.ts +1 -0
- package/dist/dsl/universalProperties.test.js +26 -0
- package/dist/dsl/validate.d.ts +10 -0
- package/dist/dsl/validate.js +68 -0
- package/dist/dsl/validate.test.d.ts +1 -0
- package/dist/dsl/validate.test.js +73 -0
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +1 -0
- package/dist/errors.test.js +21 -0
- package/dist/lib/zodToJsonSchema.d.ts +10 -5
- package/dist/lib/zodToJsonSchema.js +14 -6
- package/dist/links/buildUrls.d.ts +14 -0
- package/dist/links/buildUrls.js +20 -0
- package/dist/links/buildUrls.test.d.ts +1 -0
- package/dist/links/buildUrls.test.js +28 -0
- package/dist/links/editorConfig.d.ts +4 -0
- package/dist/links/editorConfig.js +15 -0
- package/dist/links/editorConfig.test.d.ts +1 -0
- package/dist/links/editorConfig.test.js +28 -0
- package/dist/links/encode.d.ts +11 -0
- package/dist/links/encode.js +19 -0
- package/dist/links/encode.test.d.ts +1 -0
- package/dist/links/encode.test.js +37 -0
- package/dist/parse.js +14 -6
- package/dist/parse.test.js +8 -0
- package/dist/prompts/authorChart.js +23 -18
- package/dist/prompts/authorChart.test.js +6 -0
- package/dist/render/diagnose.d.ts +19 -0
- package/dist/render/diagnose.js +100 -0
- package/dist/render/diagnose.test.d.ts +1 -0
- package/dist/render/diagnose.test.js +53 -0
- package/dist/render/frame.d.ts +10 -0
- package/dist/render/frame.js +10 -0
- package/dist/render/frame.test.d.ts +1 -0
- package/dist/render/frame.test.js +12 -0
- package/dist/render/jsdomEnv.d.ts +2 -1
- package/dist/render/jsdomEnv.js +14 -1
- package/dist/render/jsdomEnv.test.js +36 -2
- package/dist/render/renderSceneState.d.ts +5 -1
- package/dist/render/renderSceneState.js +4 -3
- package/dist/render/renderSceneState.test.js +13 -7
- package/dist/render/validatePipeline.d.ts +23 -0
- package/dist/render/validatePipeline.js +41 -0
- package/dist/render/validatePipeline.test.d.ts +1 -0
- package/dist/render/validatePipeline.test.js +34 -0
- package/dist/resources/docsReader.d.ts +4 -1
- package/dist/resources/docsReader.js +23 -6
- package/dist/resources/docsReader.test.js +27 -2
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/samples.d.ts +1 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +75 -5
- package/dist/server.test.js +105 -4
- package/dist/tools/describeChartType.d.ts +41 -0
- package/dist/tools/describeChartType.js +143 -0
- package/dist/tools/describeChartType.test.d.ts +1 -0
- package/dist/tools/describeChartType.test.js +78 -0
- package/dist/tools/exportChart.d.ts +17 -0
- package/dist/tools/exportChart.js +31 -0
- package/dist/tools/exportChart.test.d.ts +1 -0
- package/dist/tools/exportChart.test.js +43 -0
- package/dist/tools/getExample.d.ts +20 -0
- package/dist/tools/getExample.js +55 -0
- package/dist/tools/getExample.test.d.ts +1 -0
- package/dist/tools/getExample.test.js +40 -0
- package/dist/tools/getGrammar.d.ts +17 -0
- package/dist/tools/getGrammar.js +38 -0
- package/dist/tools/getGrammar.test.d.ts +1 -0
- package/dist/tools/getGrammar.test.js +35 -0
- package/dist/tools/inspect.d.ts +8 -1
- package/dist/tools/inspect.js +40 -7
- package/dist/tools/inspect.test.js +62 -13
- package/dist/tools/listChartTypes.d.ts +14 -0
- package/dist/tools/listChartTypes.js +42 -0
- package/dist/tools/listChartTypes.test.d.ts +1 -0
- package/dist/tools/listChartTypes.test.js +42 -0
- package/dist/tools/listPalettes.d.ts +13 -0
- package/dist/tools/listPalettes.js +12 -0
- package/dist/tools/listPalettes.test.d.ts +1 -0
- package/dist/tools/listPalettes.test.js +15 -0
- package/dist/tools/recommend.js +3 -1
- package/dist/tools/recommend.test.js +40 -0
- package/dist/tools/render.d.ts +14 -12
- package/dist/tools/render.js +96 -28
- package/dist/tools/render.test.js +137 -1
- package/dist/tools/searchExamples.d.ts +28 -0
- package/dist/tools/searchExamples.js +54 -0
- package/dist/tools/searchExamples.test.d.ts +1 -0
- package/dist/tools/searchExamples.test.js +32 -0
- package/dist/tools/validate.d.ts +9 -3
- package/dist/tools/validate.js +11 -1
- package/dist/tools/validate.test.js +33 -11
- package/dist/transports/http.d.ts +4 -2
- package/dist/transports/http.js +232 -23
- package/dist/transports/http.test.js +158 -22
- package/package.json +5 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +9 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates raw PEG parser messages from @blueprint-chart/lib into plain,
|
|
3
|
+
* actionable guidance. The raw token-class messages (e.g.
|
|
4
|
+
* `Expected "\t" or [^\t\n\r{}=] but "\n" found`) were the worst-rated error
|
|
5
|
+
* in the authoring usability test — newcomers averaged 6.3 attempts-to-valid,
|
|
6
|
+
* largely stuck on data syntax. Unknown messages pass through unchanged.
|
|
7
|
+
*/
|
|
8
|
+
export interface HumanizedError {
|
|
9
|
+
message: string;
|
|
10
|
+
suggestion?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function humanizeParseError(raw: string): HumanizedError;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const RULES = [
|
|
2
|
+
{
|
|
3
|
+
// The PEG message contains the literal two-char sequence backslash-t (e.g.
|
|
4
|
+
// `Expected "\t" or [^\t\n\r{}=] ...`); \\t in this literal matches that
|
|
5
|
+
// backslash-t, not an actual tab.
|
|
6
|
+
match: /Expected .*\\t.* but .* found/,
|
|
7
|
+
message: 'A data row must be written as `"Label" = value` (a quoted label, `=`, then the value). Multiple values per row are comma-separated.',
|
|
8
|
+
suggestion: 'Single series: `"Asia" = 59.4`. Multi-series: add `_series = "Gold","Silver"` then `"USA" = 40,44`.',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
match: /Expected whitespace but ":" found/,
|
|
12
|
+
message: 'The chart declaration uses a block, not a colon. Write `chart <type> { … }`.',
|
|
13
|
+
suggestion: 'chart donut {\n data { "A" = 1 }\n}',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
match: /Expected (?:"chart"|end of input)[^"]*but "d" found/,
|
|
17
|
+
message: 'The `data { … }` block must be nested inside the chart block, not at the top level.',
|
|
18
|
+
suggestion: 'chart bar-vertical {\n data { "A" = 1 }\n}',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
match: /Expected "=" .* but ":" found/,
|
|
22
|
+
message: 'Properties use `=`, not `:`. Write `title = "…"`.',
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
export function humanizeParseError(raw) {
|
|
26
|
+
for (const rule of RULES) {
|
|
27
|
+
if (rule.match.test(raw)) {
|
|
28
|
+
return { message: rule.message, suggestion: rule.suggestion };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { message: raw };
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { humanizeParseError } from './parseErrorHints';
|
|
3
|
+
describe('humanizeParseError', () => {
|
|
4
|
+
// Real lib message (with trailing period): 'Expected "\t" or [^\t\n\r{}=] but "\n" found.'
|
|
5
|
+
it('explains the tab/delimiter error for data rows', () => {
|
|
6
|
+
const h = humanizeParseError('Expected "\\t" or [^\\t\\n\\r{}=] but "\\n" found.');
|
|
7
|
+
expect(h.message).toMatch(/data row/i);
|
|
8
|
+
expect(h.suggestion).toMatch(/_series|comma/i);
|
|
9
|
+
});
|
|
10
|
+
// Real lib message (with trailing period): 'Expected whitespace but ":" found.'
|
|
11
|
+
it('explains a YAML-style chart declaration', () => {
|
|
12
|
+
const h = humanizeParseError('Expected whitespace but ":" found.');
|
|
13
|
+
expect(h.message).toMatch(/chart <type> \{/i);
|
|
14
|
+
});
|
|
15
|
+
// Real lib message: 'Expected "chart" or optional whitespace but "d" found.'
|
|
16
|
+
// (data block written at the top level, not inside a chart block)
|
|
17
|
+
it('explains data at the top level', () => {
|
|
18
|
+
const h = humanizeParseError('Expected "chart" or optional whitespace but "d" found.');
|
|
19
|
+
expect(h.message).toMatch(/inside the chart block/i);
|
|
20
|
+
});
|
|
21
|
+
it('passes unknown messages through unchanged', () => {
|
|
22
|
+
const h = humanizeParseError('something totally different');
|
|
23
|
+
expect(h.message).toBe('something totally different');
|
|
24
|
+
expect(h.suggestion).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ChartNode } from '@blueprint-chart/lib';
|
|
2
|
+
import type { ValidationIssue } from './validate';
|
|
3
|
+
/** Like ValidationIssue but with a widened string `code` for the new W_* advisory codes. */
|
|
4
|
+
export type WarningIssue = Omit<ValidationIssue, 'code'> & {
|
|
5
|
+
code: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function collectWarnings(ast: ChartNode): WarningIssue[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { canonicalChartType } from './chartTypes';
|
|
2
|
+
import { lookupCapability, statusOf } from './capabilityMatrix';
|
|
3
|
+
// Chart types that REQUIRE multiple series to be meaningful.
|
|
4
|
+
const MULTI_SERIES_TYPES = new Set([
|
|
5
|
+
'bar-multi',
|
|
6
|
+
'bar-grouped',
|
|
7
|
+
'bar-stacked',
|
|
8
|
+
'bar-split',
|
|
9
|
+
'column-stacked',
|
|
10
|
+
'line-multi',
|
|
11
|
+
'area-stacked',
|
|
12
|
+
]);
|
|
13
|
+
function checkKey(type, key, path) {
|
|
14
|
+
const cell = lookupCapability(type, key);
|
|
15
|
+
const status = statusOf(cell);
|
|
16
|
+
if (status === 'inapplicable') {
|
|
17
|
+
return {
|
|
18
|
+
code: 'W_NO_EFFECT',
|
|
19
|
+
path,
|
|
20
|
+
message: `"${key}" has no effect on a ${type} chart.${cell.note ? ' ' + cell.note : ''}`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (status === 'not-implemented') {
|
|
24
|
+
return {
|
|
25
|
+
code: 'W_NOT_IMPLEMENTED',
|
|
26
|
+
path,
|
|
27
|
+
message: `"${key}" is not yet honored by the ${type} renderer.${cell.note ? ' ' + cell.note : ''}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
export function collectWarnings(ast) {
|
|
33
|
+
const issues = [];
|
|
34
|
+
const type = canonicalChartType(ast.chartType) ?? ast.chartType;
|
|
35
|
+
for (const prop of ast.properties ?? []) {
|
|
36
|
+
const issue = checkKey(type, prop.key, `chart.${prop.key}`);
|
|
37
|
+
if (issue) {
|
|
38
|
+
issues.push(issue);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if ((ast.colorizes ?? []).some(c => c.fromHighlight !== true)) {
|
|
42
|
+
const issue = checkKey(type, 'colorize', 'chart.colorize');
|
|
43
|
+
if (issue) {
|
|
44
|
+
issues.push(issue);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if ((ast.highlights?.length ?? 0) > 0 || (ast.colorizes ?? []).some(c => c.fromHighlight === true)) {
|
|
48
|
+
const issue = checkKey(type, 'highlight', 'chart.highlight');
|
|
49
|
+
if (issue) {
|
|
50
|
+
issues.push(issue);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (MULTI_SERIES_TYPES.has(type)) {
|
|
54
|
+
const entries = ast.data?.entries ?? [];
|
|
55
|
+
const hasSeriesHeader = entries.some(e => e.key === '_series');
|
|
56
|
+
const hasMultiValueRow = entries.some(e => (e.values?.length ?? 0) > 1);
|
|
57
|
+
if (!hasSeriesHeader && !hasMultiValueRow && entries.length > 0) {
|
|
58
|
+
issues.push({
|
|
59
|
+
code: 'W_MULTISERIES_SHAPE',
|
|
60
|
+
path: 'data',
|
|
61
|
+
message: `A ${type} chart needs multiple series, but the data parsed as single-value rows with no \`_series\` header. Add a \`_series = "A","B",…\` row and comma-separated values per row (e.g. \`"USA" = 40,44,42\`).`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return issues;
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseDsl } from '../parse';
|
|
3
|
+
import { collectWarnings } from './semanticWarnings';
|
|
4
|
+
function warn(src) {
|
|
5
|
+
const r = parseDsl(src);
|
|
6
|
+
if (!r.ok) {
|
|
7
|
+
throw new Error('parse failed: ' + JSON.stringify(r.errors));
|
|
8
|
+
}
|
|
9
|
+
return collectWarnings(r.data.ast);
|
|
10
|
+
}
|
|
11
|
+
describe('collectWarnings', () => {
|
|
12
|
+
it('warns W_NO_EFFECT for sort on donut', () => {
|
|
13
|
+
const w = warn('chart donut {\n sort = descending\n data {\n "A" = 1\n "B" = 2\n }\n}');
|
|
14
|
+
expect(w.some(i => i.code === 'W_NO_EFFECT' && i.path === 'chart.sort')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it('does not warn for colorize on donut (now supported since W1c)', () => {
|
|
17
|
+
const w = warn('chart donut {\n data { "A" = 1 }\n colorize "A" { color = "#f00" }\n}');
|
|
18
|
+
expect(w.some(i => i.path.includes('colorize'))).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it('warns W_MULTISERIES_SHAPE when a multi-series type parsed zero series', () => {
|
|
21
|
+
const w = warn('chart bar-multi {\n data {\n "A" = 1\n "B" = 2\n }\n}');
|
|
22
|
+
expect(w.some(i => i.code === 'W_MULTISERIES_SHAPE')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it('is silent for a well-formed multi-series chart', () => {
|
|
25
|
+
const w = warn('chart bar-multi {\n data {\n _series = "X","Y"\n "A" = 1,2\n "B" = 3,4\n }\n}');
|
|
26
|
+
expect(w.filter(i => i.code === 'W_MULTISERIES_SHAPE')).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
it('is silent for a clean single-series bar chart', () => {
|
|
29
|
+
const w = warn('chart bar-vertical {\n title = "t"\n data { "A" = 1 }\n}');
|
|
30
|
+
expect(w).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function nearestSuggestion(input: string, candidates: readonly string[], maxDistance?: number): string | undefined;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
function distance(a, b) {
|
|
2
|
+
if (a === b) {
|
|
3
|
+
return 0;
|
|
4
|
+
}
|
|
5
|
+
if (a.length === 0) {
|
|
6
|
+
return b.length;
|
|
7
|
+
}
|
|
8
|
+
if (b.length === 0) {
|
|
9
|
+
return a.length;
|
|
10
|
+
}
|
|
11
|
+
const m = a.length;
|
|
12
|
+
const n = b.length;
|
|
13
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
14
|
+
for (let i = 0; i <= m; i++) {
|
|
15
|
+
dp[i][0] = i;
|
|
16
|
+
}
|
|
17
|
+
for (let j = 0; j <= n; j++) {
|
|
18
|
+
dp[0][j] = j;
|
|
19
|
+
}
|
|
20
|
+
for (let i = 1; i <= m; i++) {
|
|
21
|
+
for (let j = 1; j <= n; j++) {
|
|
22
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
23
|
+
const deletion = dp[i - 1][j] + 1;
|
|
24
|
+
const insertion = dp[i][j - 1] + 1;
|
|
25
|
+
const substitution = dp[i - 1][j - 1] + cost;
|
|
26
|
+
dp[i][j] = Math.min(deletion, insertion, substitution);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return dp[m][n];
|
|
30
|
+
}
|
|
31
|
+
const DEFAULT_MAX_DISTANCE = 10;
|
|
32
|
+
/**
|
|
33
|
+
* Hand-curated synonyms for property keys authors commonly guess wrong but that
|
|
34
|
+
* are too edit-distant for the Levenshtein scan to catch. `subtitle` is the
|
|
35
|
+
* canonical example: authors reach for it constantly, but the real key is
|
|
36
|
+
* `description` (distance 7), so the distance scan wrongly suggests `title`.
|
|
37
|
+
*/
|
|
38
|
+
const SYNONYMS = {
|
|
39
|
+
subtitle: 'description',
|
|
40
|
+
subhead: 'description',
|
|
41
|
+
subheading: 'description',
|
|
42
|
+
author: 'byline',
|
|
43
|
+
credit: 'byline',
|
|
44
|
+
caption: 'note',
|
|
45
|
+
footnote: 'note',
|
|
46
|
+
};
|
|
47
|
+
export function nearestSuggestion(input, candidates, maxDistance = DEFAULT_MAX_DISTANCE) {
|
|
48
|
+
const synonym = SYNONYMS[input];
|
|
49
|
+
if (synonym && candidates.includes(synonym)) {
|
|
50
|
+
return synonym;
|
|
51
|
+
}
|
|
52
|
+
let best;
|
|
53
|
+
for (const cand of candidates) {
|
|
54
|
+
const d = distance(input, cand);
|
|
55
|
+
if (d > maxDistance) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// Prefer candidates that start with the input (prefix match bonus)
|
|
59
|
+
const prefixBonus = cand.startsWith(input) ? -1000 : 0;
|
|
60
|
+
const score = d + prefixBonus;
|
|
61
|
+
if (!best || score < best.score || (score === best.score && cand < best.name)) {
|
|
62
|
+
best = { name: cand, dist: d, score };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return best?.name;
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { nearestSuggestion } from './suggest';
|
|
3
|
+
describe('nearestSuggestion', () => {
|
|
4
|
+
it('returns the closest match within edit distance', () => {
|
|
5
|
+
expect(nearestSuggestion('bar', ['bar-vertical', 'bar-horizontal', 'line']))
|
|
6
|
+
.toBe('bar-vertical');
|
|
7
|
+
});
|
|
8
|
+
it('returns the alphabetically-first on tie', () => {
|
|
9
|
+
expect(nearestSuggestion('xx', ['ab', 'ba'])).toBe('ab');
|
|
10
|
+
});
|
|
11
|
+
it('returns undefined when no candidate is within max distance', () => {
|
|
12
|
+
expect(nearestSuggestion('zz', ['absolutely-unrelated'], 3)).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
it('returns undefined when candidates is empty', () => {
|
|
15
|
+
expect(nearestSuggestion('any', [])).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
it('handles exact match', () => {
|
|
18
|
+
expect(nearestSuggestion('line', ['line', 'pie'])).toBe('line');
|
|
19
|
+
});
|
|
20
|
+
it('suggests description for the common subtitle mistake', () => {
|
|
21
|
+
const keys = ['title', 'description', 'byline', 'source', 'sourceUrl', 'note'];
|
|
22
|
+
expect(nearestSuggestion('subtitle', keys)).toBe('description');
|
|
23
|
+
});
|
|
24
|
+
it('still does edit-distance matching for other typos', () => {
|
|
25
|
+
const keys = ['title', 'description', 'colorPalette'];
|
|
26
|
+
expect(nearestSuggestion('titel', keys)).toBe('title');
|
|
27
|
+
});
|
|
28
|
+
it('falls back to edit-distance when the synonym target is absent from candidates', () => {
|
|
29
|
+
const keys = ['title', 'byline', 'note']; // no 'description'
|
|
30
|
+
// subtitle's synonym (description) isn't a candidate, so the scan runs;
|
|
31
|
+
// nearest by distance among these is 'title'
|
|
32
|
+
expect(nearestSuggestion('subtitle', keys)).toBe('title');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata for a universal chart property (type, description, and optional
|
|
3
|
+
* choice list). Consumed by `describeChartType` to build the properties list.
|
|
4
|
+
*/
|
|
5
|
+
export interface UniversalPropertyMeta {
|
|
6
|
+
type: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
choices?: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Static metadata for universal properties that are consumed by astToDefinition
|
|
12
|
+
* directly rather than registered per chart type via getChartOptions. These
|
|
13
|
+
* appear in chart DSL across all chart types (e.g. sort, title, colors).
|
|
14
|
+
*
|
|
15
|
+
* This is the single source of truth for both the metadata and the membership
|
|
16
|
+
* of the universal-properties set. `UNIVERSAL_PROPERTIES` is derived from the
|
|
17
|
+
* keys of this object so the two cannot drift apart.
|
|
18
|
+
*/
|
|
19
|
+
export declare const UNIVERSAL_PROPERTY_META: Readonly<Record<string, UniversalPropertyMeta>>;
|
|
20
|
+
/**
|
|
21
|
+
* Property keys recognized at the chart top level regardless of chart type.
|
|
22
|
+
*
|
|
23
|
+
* Derived from the keys of UNIVERSAL_PROPERTY_META to keep membership and
|
|
24
|
+
* metadata in lockstep. If a new universal property is added to the lib,
|
|
25
|
+
* add it to UNIVERSAL_PROPERTY_META above. The sample-roundtrip test (added
|
|
26
|
+
* in Task 6) catches drift: if a sample uses a key we don't know about, the
|
|
27
|
+
* validator will error on it and that test will fail.
|
|
28
|
+
*/
|
|
29
|
+
export declare const UNIVERSAL_PROPERTIES: ReadonlySet<string>;
|
|
30
|
+
export declare function isUniversalProperty(key: string): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static metadata for universal properties that are consumed by astToDefinition
|
|
3
|
+
* directly rather than registered per chart type via getChartOptions. These
|
|
4
|
+
* appear in chart DSL across all chart types (e.g. sort, title, colors).
|
|
5
|
+
*
|
|
6
|
+
* This is the single source of truth for both the metadata and the membership
|
|
7
|
+
* of the universal-properties set. `UNIVERSAL_PROPERTIES` is derived from the
|
|
8
|
+
* keys of this object so the two cannot drift apart.
|
|
9
|
+
*/
|
|
10
|
+
export const UNIVERSAL_PROPERTY_META = {
|
|
11
|
+
// Attribution metadata
|
|
12
|
+
title: { type: 'text', description: 'Chart title' },
|
|
13
|
+
description: { type: 'text', description: 'Chart description / subtitle' },
|
|
14
|
+
byline: { type: 'text', description: 'Byline / author credit' },
|
|
15
|
+
source: { type: 'text', description: 'Data source label' },
|
|
16
|
+
sourceUrl: { type: 'text', description: 'Data source URL' },
|
|
17
|
+
note: { type: 'text', description: 'Footer note' },
|
|
18
|
+
// Sort directives
|
|
19
|
+
sort: { type: 'select', description: 'Sort direction for categories', choices: ['ascending', 'descending', 'none'] },
|
|
20
|
+
sortMode: { type: 'select', description: 'Sort mode for grouped/stacked charts', choices: ['total', 'within-groups', 'none'] },
|
|
21
|
+
// Theme + palette (apply to every chart type)
|
|
22
|
+
theme: { type: 'text', description: 'Visual theme name' },
|
|
23
|
+
colorPalette: { type: 'text', description: 'Named color palette' },
|
|
24
|
+
colors: { type: 'colors', description: 'Custom color list' },
|
|
25
|
+
// Frame / layout
|
|
26
|
+
padding: { type: 'text', description: 'Frame padding (CSS shorthand)' },
|
|
27
|
+
background: { type: 'text', description: 'Background color' },
|
|
28
|
+
frameSizing: { type: 'select', description: 'Frame sizing mode', choices: ['auto', 'standard', 'aspect-ratio'] },
|
|
29
|
+
aspectRatio: { type: 'text', description: 'Aspect ratio (width / height)' },
|
|
30
|
+
// Value-label toggles (shared across bar/column variants but used universally
|
|
31
|
+
// in samples; per-type registry overrides when stricter)
|
|
32
|
+
valueLabels: { type: 'boolean', description: 'Show value labels on bars/segments' },
|
|
33
|
+
verticalLabelPosition: { type: 'select', description: 'Vertical axis label position', choices: ['auto', 'inside', 'outside', 'off'] },
|
|
34
|
+
horizontalLabelPosition: { type: 'select', description: 'Horizontal axis label position', choices: ['auto', 'inside', 'outside', 'off'] },
|
|
35
|
+
verticalGridStyle: { type: 'select', description: 'Vertical grid line style', choices: ['solid', 'dashed', 'dotted', 'none'] },
|
|
36
|
+
horizontalGridStyle: { type: 'select', description: 'Horizontal grid line style', choices: ['solid', 'dashed', 'dotted', 'none'] },
|
|
37
|
+
// TODO(lib): expose heightMode on area-stacked via getChartOptions
|
|
38
|
+
heightMode: { type: 'text', description: 'Height mode for stacked area charts' },
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Property keys recognized at the chart top level regardless of chart type.
|
|
42
|
+
*
|
|
43
|
+
* Derived from the keys of UNIVERSAL_PROPERTY_META to keep membership and
|
|
44
|
+
* metadata in lockstep. If a new universal property is added to the lib,
|
|
45
|
+
* add it to UNIVERSAL_PROPERTY_META above. The sample-roundtrip test (added
|
|
46
|
+
* in Task 6) catches drift: if a sample uses a key we don't know about, the
|
|
47
|
+
* validator will error on it and that test will fail.
|
|
48
|
+
*/
|
|
49
|
+
export const UNIVERSAL_PROPERTIES = new Set(Object.keys(UNIVERSAL_PROPERTY_META));
|
|
50
|
+
export function isUniversalProperty(key) {
|
|
51
|
+
return UNIVERSAL_PROPERTIES.has(key);
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { UNIVERSAL_PROPERTIES, UNIVERSAL_PROPERTY_META, isUniversalProperty } from './universalProperties';
|
|
3
|
+
describe('universalProperties', () => {
|
|
4
|
+
it('includes attribution metadata', () => {
|
|
5
|
+
expect(UNIVERSAL_PROPERTIES.has('title')).toBe(true);
|
|
6
|
+
expect(UNIVERSAL_PROPERTIES.has('description')).toBe(true);
|
|
7
|
+
expect(UNIVERSAL_PROPERTIES.has('byline')).toBe(true);
|
|
8
|
+
expect(UNIVERSAL_PROPERTIES.has('source')).toBe(true);
|
|
9
|
+
expect(UNIVERSAL_PROPERTIES.has('sourceUrl')).toBe(true);
|
|
10
|
+
expect(UNIVERSAL_PROPERTIES.has('note')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
it('includes universal sort directives', () => {
|
|
13
|
+
expect(UNIVERSAL_PROPERTIES.has('sort')).toBe(true);
|
|
14
|
+
expect(UNIVERSAL_PROPERTIES.has('sortMode')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it('isUniversalProperty matches', () => {
|
|
17
|
+
expect(isUniversalProperty('title')).toBe(true);
|
|
18
|
+
expect(isUniversalProperty('madeUpKey')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it('UNIVERSAL_PROPERTY_META has schema entries matching the set', () => {
|
|
21
|
+
expect(UNIVERSAL_PROPERTY_META['title']?.type).toBe('text');
|
|
22
|
+
expect(UNIVERSAL_PROPERTY_META['sort']?.choices).toContain('ascending');
|
|
23
|
+
// Membership and metadata are in lockstep
|
|
24
|
+
expect(new Set(Object.keys(UNIVERSAL_PROPERTY_META))).toEqual(UNIVERSAL_PROPERTIES);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChartNode } from '@blueprint-chart/lib';
|
|
2
|
+
export type ValidationCode = 'E_UNKNOWN_CHART_TYPE' | 'E_UNKNOWN_PROPERTY' | 'E_INVALID_PROPERTY_VALUE' | 'E_DUPLICATE_BLOCK' | 'E_EMPTY_DATA' | 'E_UNKNOWN_DATA_KEY';
|
|
3
|
+
export interface ValidationIssue {
|
|
4
|
+
code: ValidationCode;
|
|
5
|
+
path: string;
|
|
6
|
+
message: string;
|
|
7
|
+
suggestion?: string;
|
|
8
|
+
context?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export declare function validateAst(ast: ChartNode): ValidationIssue[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getChartOptions } from '@blueprint-chart/lib';
|
|
2
|
+
import { canonicalChartType, isKnownChartType, listCanonicalChartTypes } from './chartTypes';
|
|
3
|
+
import { UNIVERSAL_PROPERTIES } from './universalProperties';
|
|
4
|
+
import { nearestSuggestion } from './suggest';
|
|
5
|
+
import { looksLikeUnquotedKey } from './dataKey';
|
|
6
|
+
function chartLevelKnownKeys(chartType) {
|
|
7
|
+
const canonical = canonicalChartType(chartType) ?? chartType;
|
|
8
|
+
const perType = getChartOptions(canonical).map(o => o.key);
|
|
9
|
+
return [...UNIVERSAL_PROPERTIES, ...perType];
|
|
10
|
+
}
|
|
11
|
+
export function validateAst(ast) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
const chartType = ast.chartType;
|
|
14
|
+
const chartTypeKnown = isKnownChartType(chartType);
|
|
15
|
+
if (!chartTypeKnown) {
|
|
16
|
+
const known = listCanonicalChartTypes();
|
|
17
|
+
issues.push({
|
|
18
|
+
code: 'E_UNKNOWN_CHART_TYPE',
|
|
19
|
+
path: 'chart',
|
|
20
|
+
message: `Unknown chart type "${chartType}". Known types: ${known.join(', ')}.`,
|
|
21
|
+
suggestion: nearestSuggestion(chartType, known),
|
|
22
|
+
context: { got: chartType, known },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// Chart-level property keys (only meaningful when the chart type is known)
|
|
26
|
+
if (chartTypeKnown) {
|
|
27
|
+
const known = chartLevelKnownKeys(chartType);
|
|
28
|
+
const knownSet = new Set(known);
|
|
29
|
+
for (const prop of ast.properties ?? []) {
|
|
30
|
+
if (!knownSet.has(prop.key)) {
|
|
31
|
+
issues.push({
|
|
32
|
+
code: 'E_UNKNOWN_PROPERTY',
|
|
33
|
+
path: `chart.${prop.key}`,
|
|
34
|
+
message: `Unknown property "${prop.key}" on chart ${chartType}.`,
|
|
35
|
+
suggestion: nearestSuggestion(prop.key, known),
|
|
36
|
+
context: { got: prop.key, chartType },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Data block
|
|
42
|
+
const dataEntries = ast.data?.entries ?? [];
|
|
43
|
+
if (dataEntries.length === 0) {
|
|
44
|
+
issues.push({
|
|
45
|
+
code: 'E_EMPTY_DATA',
|
|
46
|
+
path: 'data',
|
|
47
|
+
message: 'Chart has no data. Add a `data { … }` block with at least one entry.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
for (const entry of dataEntries) {
|
|
52
|
+
// The `_series` pseudo-key is a multi-series header, not a row label.
|
|
53
|
+
if (entry.key === '_series') {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (looksLikeUnquotedKey(entry)) {
|
|
57
|
+
issues.push({
|
|
58
|
+
code: 'E_UNKNOWN_DATA_KEY',
|
|
59
|
+
path: `data.${entry.key}`,
|
|
60
|
+
message: `Data key "${entry.key}" looks like an identifier, but data rows expect quoted string labels (e.g. \`"${entry.key}" = …\`).`,
|
|
61
|
+
context: { got: entry.key },
|
|
62
|
+
suggestion: `"${entry.key}"`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return issues;
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parse, samples } from '@blueprint-chart/lib';
|
|
3
|
+
import { validateAst } from './validate';
|
|
4
|
+
function ast(src) {
|
|
5
|
+
return parse(src);
|
|
6
|
+
}
|
|
7
|
+
describe('validateAst', () => {
|
|
8
|
+
it('returns empty issues for a known chart type with data', () => {
|
|
9
|
+
const a = ast('chart bar-vertical { data { "E" = 1 } }');
|
|
10
|
+
expect(validateAst(a)).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
it('reports E_UNKNOWN_CHART_TYPE with suggestion', () => {
|
|
13
|
+
const a = ast('chart bar { data { "E" = 1 } }');
|
|
14
|
+
const issues = validateAst(a);
|
|
15
|
+
expect(issues.length).toBeGreaterThanOrEqual(1);
|
|
16
|
+
const chartIssue = issues.find(i => i.code === 'E_UNKNOWN_CHART_TYPE');
|
|
17
|
+
expect(chartIssue).toBeDefined();
|
|
18
|
+
expect(chartIssue.path).toBe('chart');
|
|
19
|
+
expect(chartIssue.suggestion).toMatch(/^bar-/);
|
|
20
|
+
expect(chartIssue.context?.got).toBe('bar');
|
|
21
|
+
expect(Array.isArray(chartIssue.context?.known)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('reports E_EMPTY_DATA when data block is missing', () => {
|
|
24
|
+
const a = ast('chart bar-vertical { title = "x" }');
|
|
25
|
+
const issues = validateAst(a);
|
|
26
|
+
expect(issues.some(i => i.code === 'E_EMPTY_DATA')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('reports E_EMPTY_DATA when data block has zero entries', () => {
|
|
29
|
+
const a = ast('chart bar-vertical { data {} }');
|
|
30
|
+
const issues = validateAst(a);
|
|
31
|
+
expect(issues.some(i => i.code === 'E_EMPTY_DATA')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('does not double-report when chart type is unknown but data is fine', () => {
|
|
34
|
+
const a = ast('chart bar { data { "E" = 1 } }');
|
|
35
|
+
const issues = validateAst(a);
|
|
36
|
+
expect(issues.find(i => i.code === 'E_EMPTY_DATA')).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('validateAst — properties and data keys', () => {
|
|
40
|
+
it('reports E_UNKNOWN_PROPERTY for an unknown chart-level key', () => {
|
|
41
|
+
const a = ast('chart bar-vertical { totallyMadeUp = 1 data { "E" = 1 } }');
|
|
42
|
+
const issues = validateAst(a);
|
|
43
|
+
const unk = issues.find(i => i.code === 'E_UNKNOWN_PROPERTY');
|
|
44
|
+
expect(unk).toBeDefined();
|
|
45
|
+
expect(unk.context?.got).toBe('totallyMadeUp');
|
|
46
|
+
});
|
|
47
|
+
it('does not report a known per-chart-type property as unknown', () => {
|
|
48
|
+
const a = ast('chart bar-vertical { barGap = 0.2 data { "E" = 1 } }');
|
|
49
|
+
expect(validateAst(a).filter(i => i.code === 'E_UNKNOWN_PROPERTY')).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
it('does not report a known universal property as unknown', () => {
|
|
52
|
+
const a = ast('chart bar-vertical { title = "x" data { "E" = 1 } }');
|
|
53
|
+
expect(validateAst(a).filter(i => i.code === 'E_UNKNOWN_PROPERTY')).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
it('suggests a near-miss universal property', () => {
|
|
56
|
+
const a = ast('chart bar-vertical { titl = "x" data { "E" = 1 } }');
|
|
57
|
+
const issues = validateAst(a);
|
|
58
|
+
const unk = issues.find(i => i.code === 'E_UNKNOWN_PROPERTY');
|
|
59
|
+
expect(unk?.suggestion).toBe('title');
|
|
60
|
+
});
|
|
61
|
+
it('reports E_UNKNOWN_DATA_KEY for unquoted-identifier data keys when chart expects labels', () => {
|
|
62
|
+
const a = ast('chart bar-vertical { data { unquotedKey = 1 } }');
|
|
63
|
+
const issues = validateAst(a);
|
|
64
|
+
expect(issues.some(i => i.code === 'E_UNKNOWN_DATA_KEY')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
it('roundtrips every shipped sample with no errors', () => {
|
|
67
|
+
for (const sample of samples) {
|
|
68
|
+
const a = ast(sample.dsl);
|
|
69
|
+
const issues = validateAst(a);
|
|
70
|
+
expect(issues, `sample ${sample.id}: ${JSON.stringify(issues)}`).toEqual([]);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
package/dist/errors.d.ts
CHANGED
|
@@ -3,15 +3,32 @@ export declare const ErrorCode: {
|
|
|
3
3
|
readonly E_PARSE: "E_PARSE";
|
|
4
4
|
readonly E_SEMANTIC: "E_SEMANTIC";
|
|
5
5
|
readonly E_RENDER: "E_RENDER";
|
|
6
|
+
readonly E_CONFIG: "E_CONFIG";
|
|
6
7
|
readonly E_INTERNAL: "E_INTERNAL";
|
|
7
8
|
};
|
|
8
9
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
9
10
|
export interface ToolErrorEntry {
|
|
11
|
+
/**
|
|
12
|
+
* Item-level error code in `E_XXX` shape.
|
|
13
|
+
*
|
|
14
|
+
* Intentionally typed as `string` rather than the `ErrorCode` enum.
|
|
15
|
+
* Item-level codes are driven by the context that produced the error:
|
|
16
|
+
* - validation errors use `ValidationCode` values (e.g. `'E_UNKNOWN_PROPERTY'`)
|
|
17
|
+
* - render pre-flight uses `RenderDiagnosticCode` values (e.g. `'E_NO_DATA'`)
|
|
18
|
+
* - individual tools may define their own codes (e.g. `'E_UNKNOWN_SAMPLE'`)
|
|
19
|
+
*
|
|
20
|
+
* These codes differ from the top-level `ToolResult.code` field, which always
|
|
21
|
+
* uses the `ErrorCode` enum and categorises the failure class (parse, input,
|
|
22
|
+
* render, etc.). Item-level codes do not need to be enumerated here.
|
|
23
|
+
*/
|
|
24
|
+
code?: string;
|
|
10
25
|
path?: string;
|
|
11
26
|
line?: number;
|
|
12
27
|
column?: number;
|
|
13
28
|
message: string;
|
|
14
29
|
snippet?: string;
|
|
30
|
+
context?: Record<string, unknown>;
|
|
31
|
+
suggestion?: string;
|
|
15
32
|
}
|
|
16
33
|
export type ToolResult<T> = {
|
|
17
34
|
ok: true;
|
|
@@ -22,7 +39,9 @@ export type ToolResult<T> = {
|
|
|
22
39
|
errors: ToolErrorEntry[];
|
|
23
40
|
};
|
|
24
41
|
export declare function toolOk<T>(data: T): ToolResult<T>;
|
|
25
|
-
export declare function toolError<T = never>(code: ErrorCode, errors: ToolErrorEntry[]): ToolResult<T
|
|
42
|
+
export declare function toolError<T = never>(code: ErrorCode, errors: ToolErrorEntry[]): Extract<ToolResult<T>, {
|
|
43
|
+
ok: false;
|
|
44
|
+
}>;
|
|
26
45
|
export declare function isToolError<T>(r: ToolResult<T>): r is Extract<ToolResult<T>, {
|
|
27
46
|
ok: false;
|
|
28
47
|
}>;
|
package/dist/errors.js
CHANGED
package/dist/errors.test.js
CHANGED
|
@@ -20,4 +20,25 @@ describe('errors', () => {
|
|
|
20
20
|
const r = toolError(ErrorCode.E_INTERNAL, [{ message: 'x' }]);
|
|
21
21
|
expect(isToolError(r)).toBe(true);
|
|
22
22
|
});
|
|
23
|
+
it('includes E_CONFIG for missing server configuration', () => {
|
|
24
|
+
expect(ErrorCode.E_CONFIG).toBe('E_CONFIG');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('ToolErrorEntry structured fields', () => {
|
|
28
|
+
it('accepts code, context, and suggestion on a tool-error entry', () => {
|
|
29
|
+
const entry = {
|
|
30
|
+
code: 'E_UNKNOWN_CHART_TYPE',
|
|
31
|
+
path: 'chart',
|
|
32
|
+
message: 'Unknown chart type "bar"',
|
|
33
|
+
context: { got: 'bar', knownTypes: ['bar-vertical'] },
|
|
34
|
+
suggestion: 'bar-vertical',
|
|
35
|
+
};
|
|
36
|
+
const r = toolError(ErrorCode.E_SEMANTIC, [entry]);
|
|
37
|
+
expect(r.ok).toBe(false);
|
|
38
|
+
if (!r.ok) {
|
|
39
|
+
expect(r.errors[0].code).toBe('E_UNKNOWN_CHART_TYPE');
|
|
40
|
+
expect(r.errors[0].context).toEqual({ got: 'bar', knownTypes: ['bar-vertical'] });
|
|
41
|
+
expect(r.errors[0].suggestion).toBe('bar-vertical');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
23
44
|
});
|