|
12 | 12 | import { describe, expect, it } from 'vitest'; |
13 | 13 | import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; |
14 | 14 | import { validateReferenceIntegrity } from './reference-integrity-suite.js'; |
| 15 | +import { runRuntimeAuthoringRules } from './runtime-gate.js'; |
15 | 16 | import { |
16 | 17 | validateListViewFieldRefs as validateListViewFieldRefsUnrecorded, |
17 | 18 | LIST_VIEW_FIELD_UNKNOWN, |
18 | 19 | LIST_VIEW_FIELD_DOTTED, |
19 | 20 | LIST_VIEW_GROUP_ORDER_UNORDERABLE, |
| 21 | + LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT, |
20 | 22 | LIST_VIEW_OBJECT_OUTSIDE_CLOSURE, |
21 | 23 | LIST_VIEW_OBJECT_UNKNOWN, |
22 | 24 | listViewWalkedPositions, |
@@ -1295,3 +1297,190 @@ describe('[#22161] one-line fixes — list-view-field-unknown, list-view-field-d |
1295 | 1297 | } |
1296 | 1298 | }); |
1297 | 1299 | }); |
| 1300 | + |
| 1301 | +// [#26719] A list view's chart bound to a dataset declared over ANOTHER object |
| 1302 | +// than the one the list view lists. Measured at the save door before the rule |
| 1303 | +// landed: such a view saved and published 200 with no advisory. |
| 1304 | +describe('[#26719] a list-view chart bound to a dataset over another object', () => { |
| 1305 | + const DATASETS = [ |
| 1306 | + // Over the list's own object. |
| 1307 | + { |
| 1308 | + name: 'duly_task_metrics', |
| 1309 | + object: 'duly_task', |
| 1310 | + dimensions: [{ name: 'status', field: 'status' }], |
| 1311 | + measures: [{ name: 'task_count', aggregate: 'count' }], |
| 1312 | + }, |
| 1313 | + // Over another object. |
| 1314 | + { |
| 1315 | + name: 'duly_person_metrics', |
| 1316 | + object: 'duly_person', |
| 1317 | + dimensions: [{ name: 'name', field: 'name' }], |
| 1318 | + measures: [{ name: 'person_count', aggregate: 'count' }], |
| 1319 | + }, |
| 1320 | + // Over `duly_task`, reaching `duly_person` through the declared `owner` |
| 1321 | + // relationship. |
| 1322 | + { |
| 1323 | + name: 'duly_task_by_owner', |
| 1324 | + object: 'duly_task', |
| 1325 | + include: ['owner'], |
| 1326 | + dimensions: [{ name: 'owner_name', field: 'owner.name' }], |
| 1327 | + measures: [{ name: 'task_count', aggregate: 'count' }], |
| 1328 | + }, |
| 1329 | + ]; |
| 1330 | + const chartOn = (dataset: string, extra: AnyRec = {}): AnyRec => ({ |
| 1331 | + name: 'by_status', |
| 1332 | + type: 'chart', |
| 1333 | + data: { provider: 'object', object: 'duly_task' }, |
| 1334 | + columns: ['title'], |
| 1335 | + chart: { chartType: 'bar', dataset, dimensions: ['status'], values: ['task_count'] }, |
| 1336 | + ...extra, |
| 1337 | + }); |
| 1338 | + const withDatasets = (stack: AnyRec): AnyRec => ({ ...stack, datasets: DATASETS }); |
| 1339 | + const rulesOf = (fs: Array<{ rule: string }>) => fs.map((f) => f.rule); |
| 1340 | + |
| 1341 | + it('REFUSES a dataset over another object — `error`, at `chart.dataset`, naming the view, the dataset and both objects', () => { |
| 1342 | + const findings = validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_person_metrics')))); |
| 1343 | + expect(findings).toHaveLength(1); |
| 1344 | + const [f] = findings; |
| 1345 | + expect(f).toMatchObject({ |
| 1346 | + severity: 'error', |
| 1347 | + rule: LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT, |
| 1348 | + path: 'views[0].list.chart.dataset', |
| 1349 | + }); |
| 1350 | + expect(f.where).toContain('duly_task'); |
| 1351 | + for (const name of ['"duly_person_metrics"', '"duly_person"', '"duly_task"']) { |
| 1352 | + expect(f.message, name).toContain(name); |
| 1353 | + } |
| 1354 | + expect(f.hint).not.toContain('\n'); |
| 1355 | + expect(f.hint.length, f.hint).toBeLessThanOrEqual(200); |
| 1356 | + }); |
| 1357 | + |
| 1358 | + it('CONTROL — a chart over a dataset on the list\'s own object reports nothing', () => { |
| 1359 | + expect(validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_task_metrics'))))).toEqual([]); |
| 1360 | + }); |
| 1361 | + |
| 1362 | + it('REFUSES a dataset that reaches the list\'s object only through a declared relationship', () => { |
| 1363 | + // A `duly_person` list charting a dataset over `duly_task` that includes |
| 1364 | + // `owner`: the chart still counts tasks. |
| 1365 | + const listView = chartOn('duly_task_by_owner', { data: { provider: 'object', object: 'duly_person' }, columns: ['name'] }); |
| 1366 | + const findings = validateListViewFieldRefs(withDatasets(stackWith(listView))); |
| 1367 | + expect(rulesOf(findings)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]); |
| 1368 | + expect(findings[0].message).toContain('"duly_task_by_owner" over object "duly_task"'); |
| 1369 | + }); |
| 1370 | + |
| 1371 | + it('…while the same dataset under the `duly_task` list is that object\'s own', () => { |
| 1372 | + expect(validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_task_by_owner'))))).toEqual([]); |
| 1373 | + }); |
| 1374 | + |
| 1375 | + it.each([ |
| 1376 | + ['objects[].listViews.<key>', { objects: [{ ...OBJECTS[0], listViews: { by_status: chartOn('duly_person_metrics', { data: undefined }) } }, OBJECTS[1]] }, 'objects[0].listViews.by_status.chart.dataset'], |
| 1377 | + ['views[].listViews.<key>', { objects: OBJECTS, views: [{ name: 'duly_task', object: 'duly_task', listViews: { by_status: chartOn('duly_person_metrics') } }] }, 'views[0].listViews.by_status.chart.dataset'], |
| 1378 | + ['a flattened list overlay', { objects: OBJECTS, views: [{ ...chartOn('duly_person_metrics'), name: 'duly_task.by_status', object: 'duly_task', viewKind: 'list' }] }, 'views[0].chart.dataset'], |
| 1379 | + ['a ViewItem record', { objects: OBJECTS, views: [{ name: 'duly_task.by_status', object: 'duly_task', viewKind: 'list', config: chartOn('duly_person_metrics') }] }, 'views[0].config.chart.dataset'], |
| 1380 | + ])('walks %s', (_label, stack, path) => { |
| 1381 | + const findings = validateListViewFieldRefs(withDatasets(stack as AnyRec)); |
| 1382 | + expect(findings.map((f) => [f.rule, f.path])).toEqual([[LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT, path]]); |
| 1383 | + }); |
| 1384 | + |
| 1385 | + it('judges the list view\'s own `data.object` binding, which wins over its container\'s', () => { |
| 1386 | + // A built-in list view of `duly_task` retargeted to `duly_person`: the |
| 1387 | + // person dataset is its own, the task dataset is foreign. |
| 1388 | + const retargeted = (dataset: string) => ({ |
| 1389 | + objects: [{ ...OBJECTS[0], listViews: { people: chartOn(dataset, { data: { provider: 'object', object: 'duly_person' }, columns: ['name'] }) } }, OBJECTS[1]], |
| 1390 | + }); |
| 1391 | + expect(validateListViewFieldRefs(withDatasets(retargeted('duly_person_metrics')))).toEqual([]); |
| 1392 | + expect(rulesOf(validateListViewFieldRefs(withDatasets(retargeted('duly_task_metrics'))))).toEqual([ |
| 1393 | + LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT, |
| 1394 | + ]); |
| 1395 | + }); |
| 1396 | + |
| 1397 | + it('is silent where another verdict owns the case: an unknown dataset, a dataset with no `object`, an unresolved binding', () => { |
| 1398 | + expect(validateListViewFieldRefs(withDatasets(stackWith(chartOn('no_such_dataset'))))).toEqual([]); |
| 1399 | + const objectless = { ...stackWith(chartOn('loose_metrics')), datasets: [{ name: 'loose_metrics', measures: [] }] }; |
| 1400 | + expect(validateListViewFieldRefs(objectless)).toEqual([]); |
| 1401 | + const unbound = stackWith(chartOn('duly_person_metrics', { data: { provider: 'object', object: 'sys_elsewhere' } })); |
| 1402 | + expect(validateListViewFieldRefs(withDatasets(unbound))).toEqual([]); |
| 1403 | + }); |
| 1404 | + |
| 1405 | + it('judges an object that declares no readable field map — the verdict reads object NAMES only', () => { |
| 1406 | + const external = [{ name: 'duly_task', label: 'Task', external: true }, OBJECTS[1]]; |
| 1407 | + const findings = validateListViewFieldRefs(withDatasets(stackWith(chartOn('duly_person_metrics'), external))); |
| 1408 | + expect(rulesOf(findings)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]); |
| 1409 | + }); |
| 1410 | + |
| 1411 | + for (const command of ['validate', 'build'] as const) { |
| 1412 | + it(`fails \`${command}\``, () => { |
| 1413 | + const { errors } = splitBySeverity( |
| 1414 | + runAuthoringRules(command, { normalized: withDatasets(stackWith(chartOn('duly_person_metrics'))) }), |
| 1415 | + ); |
| 1416 | + expect(errors.map((f) => f.rule)).toContain(LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT); |
| 1417 | + }); |
| 1418 | + } |
| 1419 | + |
| 1420 | + describe('at the runtime save door', () => { |
| 1421 | + const SCOPE = { packageId: 'com.qa.gap', dependencies: [] as string[] }; |
| 1422 | + const owned = (o: AnyRec, pkg: string) => ({ ...o, _packageId: pkg }); |
| 1423 | + const objects = [owned(OBJECTS[0], 'com.qa.gap'), owned(OBJECTS[1], 'com.other.app')]; |
| 1424 | + const datasets = [owned(DATASETS[0], 'com.qa.gap'), owned(DATASETS[1], 'com.other.app')]; |
| 1425 | + const item = (dataset: string): AnyRec => ({ |
| 1426 | + name: 'duly_task.by_status', |
| 1427 | + object: 'duly_task', |
| 1428 | + viewKind: 'list', |
| 1429 | + label: 'By status', |
| 1430 | + config: chartOn(dataset), |
| 1431 | + }); |
| 1432 | + const write = (dataset: string) => |
| 1433 | + runRuntimeAuthoringRules({ type: 'view', item: item(dataset), context: { objects, datasets }, packageScope: SCOPE }); |
| 1434 | + |
| 1435 | + it('REFUSES a view written into one package bound to a dataset over another package\'s object', () => { |
| 1436 | + const result = write('duly_person_metrics'); |
| 1437 | + expect(rulesOf(result.errors)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]); |
| 1438 | + expect(result.errors[0].path).toMatch(/chart\.dataset$/); |
| 1439 | + }); |
| 1440 | + |
| 1441 | + it('CONTROL — the same view bound to a dataset over its own object is admitted', () => { |
| 1442 | + const result = write('duly_task_metrics'); |
| 1443 | + expect(result.errors).toEqual([]); |
| 1444 | + }); |
| 1445 | + |
| 1446 | + // The `object` door: an object's built-in list views ride the object write. |
| 1447 | + // A minimal object the object door's own rules admit, so the control's |
| 1448 | + // empty `errors` speaks for this rule alone. |
| 1449 | + const doorTask = owned({ |
| 1450 | + name: 'duly_task', |
| 1451 | + label: 'Task', |
| 1452 | + sharingModel: 'private', |
| 1453 | + fields: [ |
| 1454 | + { name: 'title', type: 'text', label: 'Title' }, |
| 1455 | + { name: 'status', type: 'text', label: 'Status' }, |
| 1456 | + ], |
| 1457 | + }, 'com.qa.gap'); |
| 1458 | + const writeObject = (dataset: string) => |
| 1459 | + runRuntimeAuthoringRules({ |
| 1460 | + type: 'object', |
| 1461 | + item: { ...doorTask, listViews: { by_status: chartOn(dataset, { data: undefined }) } }, |
| 1462 | + context: { objects: [doorTask, objects[1]], datasets }, |
| 1463 | + packageScope: SCOPE, |
| 1464 | + }); |
| 1465 | + |
| 1466 | + it('REFUSES an object write whose built-in list view charts a dataset over another package\'s object', () => { |
| 1467 | + const result = writeObject('duly_person_metrics'); |
| 1468 | + expect(rulesOf(result.errors)).toEqual([LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT]); |
| 1469 | + expect(result.errors[0].path).toMatch(/listViews\.by_status\.chart\.dataset$/); |
| 1470 | + }); |
| 1471 | + |
| 1472 | + it('CONTROL — the same object write charting a dataset over itself is admitted', () => { |
| 1473 | + const result = writeObject('duly_task_metrics'); |
| 1474 | + expect(result.errors).toEqual([]); |
| 1475 | + }); |
| 1476 | + }); |
| 1477 | + |
| 1478 | + it('`os explain` carries the reasoning, the relationship answer included', () => { |
| 1479 | + const entry = explainRule(LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT); |
| 1480 | + expect(entry, 'no `os explain` entry').toBeDefined(); |
| 1481 | + const text = entry!.paragraphs.join('\n'); |
| 1482 | + for (const fact of ['base `object`', '`include`', 'to-one relationships', 'a draft save', '`chart-dataset-unknown`', 'dashboard']) { |
| 1483 | + expect(text, fact).toContain(fact); |
| 1484 | + } |
| 1485 | + }); |
| 1486 | +}); |
0 commit comments