Files
advanced-camera-card/tests/components-lib/editor/schema/completeness.test.ts
T

395 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import {
forEachFieldRecursively,
getFormConfigPaths,
} from '../../../../src/components-lib/editor/form-data';
import {
getCameraSchema,
getCameraTriggersSchema,
getTriggerEventSchema,
} from '../../../../src/components-lib/editor/schema/cameras';
import { getDimensionsSectionForms } from '../../../../src/components-lib/editor/schema/dimensions';
import { getEditorModeForms } from '../../../../src/components-lib/editor/schema/editor-mode';
import { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders';
import { getFullEditorForms } from '../../../../src/components-lib/editor/schema/full';
import { getImageSectionForms } from '../../../../src/components-lib/editor/schema/image';
import { getLiveSectionForms } from '../../../../src/components-lib/editor/schema/live';
import { getMediaGallerySectionForms } from '../../../../src/components-lib/editor/schema/media-gallery';
import { getMediaViewerSectionForms } from '../../../../src/components-lib/editor/schema/media-viewer';
import { getMenuSectionForms } from '../../../../src/components-lib/editor/schema/menu';
import { getPerformanceSectionForms } from '../../../../src/components-lib/editor/schema/performance';
import { getProfilesSectionForms } from '../../../../src/components-lib/editor/schema/profiles';
import { getRemoteControlSectionForms } from '../../../../src/components-lib/editor/schema/remote-control';
import {
getSimpleCameraForms,
getSimpleMenuForms,
getSimpleTopLevelForms,
} from '../../../../src/components-lib/editor/schema/simple';
import { getStatusBarSectionForms } from '../../../../src/components-lib/editor/schema/status-bar';
import { getTimelineSectionForms } from '../../../../src/components-lib/editor/schema/timeline';
import {
getViewKeyboardShortcutsSectionForms,
getViewSectionForms,
} from '../../../../src/components-lib/editor/schema/view';
import {
findBinding,
isComputedFieldBinding,
type ConfigPath,
type EditorForm,
} from '../../../../src/components-lib/editor/types';
import { advancedCameraCardConfigSchema } from '../../../../src/config/schema/types';
import { PTZ_KEYBOARD_SHORTCUTS } from '../../../../src/config/schema/view';
import type { HAFormSelectorSchema } from '../../../../src/ha/types';
// ============================================================================
// Editor <-> Zod Schema Completeness tests
//
// Two directions:
// - Direction 1 (editor -> config): every editor field path is a real config
// key, its selector kind matches the zod type (number/boolean/enum), and its
// dropdown offers every enum value.
// - Direction 2 (config -> editor): every configurable zod leaf appears in an
// editor form, unless explicitly excepted. Catches config fields forgotten in
// the editor.
// ============================================================================
// Configuration subtrees the editor renders with a dedicated widget instead of
// an `ha-form` field, so the field walk cannot see them.
const EDITOR_CUSTOM_WIDGETS = PTZ_KEYBOARD_SHORTCUTS.map(
(name) => `view.keyboard_shortcuts.${name}`,
);
// Configuration subtrees intentionally kept YAML-only.
const EDITOR_EXCLUDED = [
'cameras_global',
'elements',
'automations',
'overrides',
'debug',
'type',
'card_id',
'card_mod',
// Advanced/freeform camera subtrees not surfaced in the editor.
'cameras.jsmpeg',
'cameras.ptz',
'cameras.dimensions.grid',
'cameras.triggers.events.context',
// Free-form CSS variable overrides.
'view.theme.overrides',
// Not surfaced in the editor (the maintainer declined adding it).
'view.render_entities',
// Section-level action handlers (`tap_action` etc.) need a dedicated action
// editor and have never been exposed.
'view.actions',
'image.actions',
'media_gallery.actions',
'live.actions',
'media_viewer.actions',
// Free-form CSS object for the PTZ control styling.
'live.controls.ptz.style',
'media_viewer.controls.ptz.style',
// Advanced folder path parsers/matchers/templating: text-editor only.
'folders.ha.path',
];
// Every section's forms, keyed by section, so the harness walks the full
// field tree of everything rendered via `ha-form`.
const SECTION_FORMS: Record<string, EditorForm[]> = {
profiles: getProfilesSectionForms(),
view: [...getViewSectionForms(), ...getViewKeyboardShortcutsSectionForms()],
image: getImageSectionForms(),
live: getLiveSectionForms(),
media_gallery: getMediaGallerySectionForms(),
media_viewer: getMediaViewerSectionForms(),
menu: getMenuSectionForms(),
status_bar: getStatusBarSectionForms(),
timeline: getTimelineSectionForms(),
dimensions: getDimensionsSectionForms(),
performance: getPerformanceSectionForms(),
remote_control: getRemoteControlSectionForms(),
// Array section: one representative item at index 0 for testing purposes. The
// numeric index is stripped when building covered paths, so this checks the
// item's fields (`folders.type`, ...) against the array element's schema.
folders: [{ basePath: ['folders', 0], schema: getFolderSchema() }],
// Array section split across the camera form, the triggers sub-form, and the
// per-event item form (the triggers group is hand-built to host the events
// list, so its fields live in a separate schema).
cameras: [
{
basePath: ['cameras', 0],
schema: getCameraSchema({
otherCameras: [{ value: 'camera.other', label: 'Other' }],
folders: [{ value: 'folder-1', label: 'Folder' }],
}),
},
{
basePath: ['cameras', 0, 'triggers'],
schema: getCameraTriggersSchema(),
},
{
basePath: ['cameras', 0, 'triggers', 'events', 0],
schema: getTriggerEventSchema(),
},
],
};
// The forms that are not a section of the configuration: the switch between the
// editors, and the simple editor, which gathers its fields from across the
// configuration and binds each to where its setting is stored. Their names are
// the editor's own, so a failure says which set of forms it came from. Every
// path they address is one a section covers too, so they add nothing to
// direction 2 and everything to direction 1.
const UNSECTIONED_FORMS: Record<string, EditorForm[]> = {
editor_mode: getEditorModeForms(),
simple_top_level: getSimpleTopLevelForms(),
simple_menu: getSimpleMenuForms(),
// The camera form is the array item at index 0, as the array sections above.
simple_cameras: getSimpleCameraForms(0),
};
const ALL_FORMS: Record<string, EditorForm[]> = {
...SECTION_FORMS,
...UNSECTIONED_FORMS,
};
// `z.core.$ZodType` is zod v4's base schema type for introspection: it is what
// `.unwrap()` and `ZodArray.element` return, and what every schema (including
// the config root) is assignable to. The classic `z.ZodType` is a subtype with
// extra methods, so those introspection results would not assign back to it.
// Unwrap the zod wrappers that carry no structural meaning for path
// navigation (optional/default/nullable/readonly/lazy).
const unwrap = (schema: z.core.$ZodType): z.core.$ZodType => {
let current: z.core.$ZodType = schema;
for (let guard = 0; guard < 20; guard++) {
if (
current instanceof z.ZodOptional ||
current instanceof z.ZodNullable ||
current instanceof z.ZodDefault ||
current instanceof z.ZodReadonly
) {
current = current.unwrap();
} else if (current instanceof z.ZodLazy) {
current = current.unwrap();
} else {
break;
}
}
return current;
};
// Resolve a configuration path to its zod leaf schema, or null if the path
// does not exist in the configuration schema.
const resolvePath = (path: ConfigPath): z.core.$ZodType | null => {
let current: z.core.$ZodType = advancedCameraCardConfigSchema;
for (const segment of path) {
current = unwrap(current);
if (current instanceof z.ZodObject) {
const next: z.core.$ZodType | undefined = current.shape[segment];
if (!next) {
return null;
}
current = next;
} else if (current instanceof z.ZodArray) {
current = current.element;
} else {
return null;
}
}
return unwrap(current);
};
// A selector object has exactly one key naming its kind (`{ number: {} }` ->
// 'number', `{ select: {} }` -> 'select'), so its single key is the kind.
const selectorKind = (field: HAFormSelectorSchema): string =>
Object.keys(field.selector)[0];
// Collect the literal values of a zod enum or union-of-literals, or null if the
// schema is not an enumeration.
const enumValues = (schema: z.core.$ZodType): unknown[] | null => {
if (schema instanceof z.ZodEnum) {
return Object.values(schema.enum);
}
if (schema instanceof z.ZodUnion) {
const values: unknown[] = [];
for (const option of schema.options) {
const inner = unwrap(option);
if (inner instanceof z.ZodLiteral) {
values.push(inner.value);
} else {
return null;
}
}
return values;
}
return null;
};
// True if a config path lies inside a subtree the editor forms do not own
// (YAML-only, or rendered by a custom widget), so the completeness walk skips
// it.
const isPruned = (path: ConfigPath): boolean => {
const key = path.join('.');
return [...EDITOR_CUSTOM_WIDGETS, ...EDITOR_EXCLUDED].some(
(prefix) => key === prefix || key.startsWith(`${prefix}.`),
);
};
// Enumerate the configuration schema's leaf field paths (a leaf is anything
// that is not a plain object; arrays are descended into rather than treated
// as leaves), pruning the subtrees the editor does not own.
const enumerateConfigLeavesRecursively = (
schema: z.core.$ZodType,
prefix: ConfigPath = [],
): string[] => {
if (prefix.length && isPruned(prefix)) {
return [];
}
const current = unwrap(schema);
if (current instanceof z.ZodObject) {
return Object.keys(current.shape).flatMap((key) =>
enumerateConfigLeavesRecursively(current.shape[key], [...prefix, key]),
);
}
if (current instanceof z.ZodArray) {
// Descend into array items without an index: array sections cover the item
// fields once (e.g. `folders.type`, not `folders.0.type`).
return enumerateConfigLeavesRecursively(current.element, prefix);
}
return [prefix.join('.')];
};
// Every configuration path the editor forms currently cover. Array sections
// carry a numeric index (`['folders', 0]`); strip it so covered paths match the
// index-free enumerated leaves.
const coveredPaths = new Set(
Object.values(ALL_FORMS)
.flat()
.flatMap(getFormConfigPaths)
.map((path) => path.filter((segment) => typeof segment !== 'number').join('.')),
);
// One check per configuration path a field addresses. A field that reads and
// writes itself addresses more than one, and its selector deliberately does not
// match how the value is stored (a switch for a named mode, a list of names for
// one boolean per menu button), so only the existence of its paths is checked.
interface FieldCheck {
section: string;
path: ConfigPath;
field: HAFormSelectorSchema;
checkSelector: boolean;
}
const getFieldChecks = (): FieldCheck[] => {
const checks: FieldCheck[] = [];
for (const [section, forms] of Object.entries(ALL_FORMS)) {
for (const form of forms) {
forEachFieldRecursively(form.schema, (formPath, field) => {
const binding = findBinding(form, formPath);
const computed = !!binding && isComputedFieldBinding(binding);
const paths = !binding
? [[...form.basePath, ...formPath]]
: isComputedFieldBinding(binding)
? binding.configPaths
: [binding.configPath];
checks.push(
...paths.map((path) => ({ section, path, field, checkSelector: !computed })),
);
});
}
}
return checks;
};
describe('editor schema completeness', () => {
// The derivation asks `getFullEditorForms` what the full editor shows, while
// this harness walks the sections it is given above. The two must describe
// the same editor, or a section could be checked here and invisible to the
// derivation, or the reverse.
it('should check every form the full editor shows', () => {
const walked = new Set(
Object.entries(ALL_FORMS)
.filter(([section]) => !(section in UNSECTIONED_FORMS))
.flatMap(([, forms]) => forms)
.flatMap(getFormConfigPaths)
.map((path) => path.join('.')),
);
for (const path of getFullEditorForms().flatMap(getFormConfigPaths)) {
expect(walked, `${path.join('.')} is shown but not checked`).toContain(
path.join('.'),
);
}
});
// Direction 1: every editor field path is a real configuration key.
describe('every form field resolves to a matching configuration key', () => {
for (const { section, path, field, checkSelector } of getFieldChecks()) {
const key = path.join('.');
it(`${section}: ${key}`, () => {
const resolved = resolvePath(path);
expect(resolved, `path ${key} is not in the config schema`).not.toBeNull();
if (!checkSelector) {
return;
}
// Direction 1b: the selector kind matches the zod type where the
// type is unambiguous (number/boolean/enum).
const kind = selectorKind(field);
if (resolved instanceof z.ZodNumber) {
expect(kind, `${key} should use a number selector`).toBe('number');
} else if (resolved instanceof z.ZodBoolean) {
expect(kind, `${key} should use a boolean selector`).toBe('boolean');
} else {
const values = resolved ? enumValues(resolved) : null;
if (values) {
expect(kind, `${key} should use a select selector`).toBe('select');
// Direction 1c: the dropdown offers every enum value.
if ('select' in field.selector) {
const options = field.selector.select.options.map((option) =>
typeof option === 'object' ? option.value : option,
);
for (const value of values) {
expect(
options,
`${key} dropdown is missing enum value ${String(value)}`,
).toContain(value);
}
}
}
}
});
}
});
// Direction 2: every configurable field (outside the excluded subtrees)
// appears in the editor. This is the forgotten-feature catcher.
describe('every configurable field appears in the editor', () => {
for (const path of enumerateConfigLeavesRecursively(
advancedCameraCardConfigSchema,
)) {
it(path, () => {
expect(
coveredPaths.has(path),
`${path} is a configurable field but is not in any editor form ` +
`(add it to the editor, or to EDITOR_CUSTOM_WIDGETS / EDITOR_EXCLUDED)`,
).toBe(true);
});
}
});
});