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 = { 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 = { 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 = { ...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); }); } }); });