import { describe, expect, it } from 'vitest'; import { z } from 'zod'; import { forEachFieldRecursively } 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 { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders'; 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 { 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 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 = { 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(), }, ], }; // `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: (string | number)[]): 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: (string | number)[]): 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: (string | number)[] = [], ): 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. const coveredPaths = new Set(); for (const forms of Object.values(SECTION_FORMS)) { for (const form of forms) { forEachFieldRecursively(form.schema, (path) => { // Array sections carry a numeric index in `basePath` (`['folders', 0]`); // strip it so covered paths match the index-free enumerated leaves. const key = [...form.basePath, ...path] .filter((segment) => typeof segment !== 'number') .join('.'); coveredPaths.add(key); }); } } describe('editor schema completeness', () => { // Direction 1: every editor field path is a real configuration key. describe('every form field resolves to a matching configuration key', () => { for (const [section, forms] of Object.entries(SECTION_FORMS)) { for (const form of forms) { forEachFieldRecursively(form.schema, (path, field) => { const fullPath = [...form.basePath, ...path]; const key = fullPath.join('.'); it(`${section}: ${key}`, () => { const resolved = resolvePath(fullPath); expect(resolved, `path ${key} is not in the config schema`).not.toBeNull(); // 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); }); } }); });