Files
shopdb-flask/frontend/src/components/panelGroups.spec.js
benjamin.baker 7905917cd4
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
Green Up: scope panel-group check to panel-backed tabs
Corporate Tools is the first PCDetail tab that mounts no PluginAssetPanels: it is permission-gated and renders a fixed action list, so no panel can land in it. The vocabulary check asserted every tabs.push key appears in RENDERED_GROUPS, which cd70556 broke by adding that tab.

Adding 'corptools' to the vocabulary would have made it a legal panel group with no panel host - fetched, held, rendered nowhere - the silent-drop failure this file exists to catch, and it would also have failed the server-vocabulary check against ASSET_PANEL_GROUPS. So the invariant is narrowed to panel-backed tabs instead, with a mirror check that any tab mounting a PluginAssetPanels must have its group rendered.
2026-09-06 05:38:44 -04:00

122 lines
5.1 KiB
JavaScript

import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
// The group vocabulary is split across four files and there is no runtime that
// checks they agree. When they disagree the failure is SILENT: a panel in a
// group nothing renders is fetched, held, and shown on no tab, with nothing
// logged. That has eaten cards twice - tracked services moving out of
// 'actions', and the 'metrics' to 'monitors' rename.
//
// So this reads the sources and asserts the vocabularies match. It is a source
// check rather than a behaviour check because RENDERED_GROUPS is a module-local
// constant in the .vue file with no export to assert against.
function read(relative) {
return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8')
}
function renderedGroups() {
const source = read('./PluginAssetPanels.vue')
const match = source.match(/const RENDERED_GROUPS = \[([^\]]*)\]/)
if (!match) throw new Error('RENDERED_GROUPS not found in PluginAssetPanels.vue')
return match[1].split(',')
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
.filter(Boolean)
}
function pcDetailSource() {
return read('../../../plugins/computers/frontend/views/PCDetail.vue')
}
// The tabs that actually HOST panels - those where PCDetail mounts a
// PluginAssetPanels for the group. A tab is only bound by the vocabulary if a
// panel can land in it. Corporate Tools is the first tab that hosts none: it is
// permission-gated and renders a fixed action list, so RENDERED_GROUPS has
// nothing to say about it and requiring it there would make 'corptools' a legal
// panel group with no panel host - fetched, held, rendered nowhere, which is
// the very failure this file exists to catch.
function panelHostedGroups(source) {
return [...source.matchAll(/group="([a-z]+)"/g)].map((match) => match[1])
}
describe('panel group vocabulary', () => {
it('renders a group for every panel-backed tab PCDetail offers', () => {
const groups = renderedGroups()
const source = pcDetailSource()
const hosted = panelHostedGroups(source)
const tabKeys = [...source.matchAll(/tabs\.push\(\{\s*key:\s*'([a-z]+)'/g)]
.map((match) => match[1])
.filter((key) => hosted.includes(key))
expect(tabKeys.length).toBeGreaterThan(0)
for (const key of tabKeys) {
expect(groups).toContain(key)
}
})
it('never hosts panels in a group this build does not render', () => {
// The mirror of that exemption, so opting a tab out of panels cannot become
// a loophole: the moment a tab mounts a PluginAssetPanels, its group MUST
// be rendered, or the panels are fetched and dropped with nothing logged.
const groups = renderedGroups()
const hosted = panelHostedGroups(pcDetailSource())
expect(hosted.length).toBeGreaterThan(0)
for (const group of hosted) {
expect(groups).toContain(group)
}
})
it('gives every rendered group somewhere to actually show', () => {
// A group in RENDERED_GROUPS with no v-show block is the mirror failure:
// the panel is kept rather than dropped, but still appears nowhere.
const source = pcDetailSource()
for (const group of renderedGroups()) {
expect(source).toContain(`activegroup === '${group}'`)
}
})
it('keeps the service/process split wired end to end', () => {
// Pins the specific split rather than only the general rule, so removing
// the tab is a deliberate edit to this test and not a silent regression.
const source = pcDetailSource()
expect(renderedGroups()).toContain('serviceprocess')
expect(source).toContain("key: 'serviceprocess'")
expect(source).toContain("group=\"serviceprocess\"")
})
it('still renders the system monitors group after the rename', () => {
// The rename is label-only; the group key must not drift with it.
expect(renderedGroups()).toContain('monitors')
expect(pcDetailSource()).toContain("label: 'System Monitors'")
})
it('agrees with the server vocabulary that resolves groups first', () => {
// The fourth file this comment block claims to cover, and the one that was
// NOT actually being checked. asset_panels() rewrites any group missing
// from ASSET_PANEL_GROUPS to 'details' before the client sees it, so the
// frontend can be perfectly consistent with itself and still show nothing:
// that is exactly how the Service/Process tab went missing after Phase 3.
const source = read('../../../shopdb/core/api/pluginui.py')
const match = source.match(/ASSET_PANEL_GROUPS = \(([\s\S]*?)\)/)
if (!match) throw new Error('ASSET_PANEL_GROUPS not found in pluginui.py')
const serverGroups = match[1].split(',')
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
.filter(Boolean)
for (const group of renderedGroups()) {
expect(serverGroups).toContain(group)
}
})
it('keeps operator reports in a tab of its own', () => {
const source = pcDetailSource()
expect(renderedGroups()).toContain('reports')
expect(source).toContain("key: 'reports'")
expect(source).toContain('group="reports"')
})
})