diff --git a/package.json b/package.json index afd59850..2b3798c9 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "vis-util": "^5.0.7", "web-dialog": "^0.0.11", "xss": "^1.0.15", - "zod": "^3.23.8" + "zod": "^4.3.6" }, "devDependencies": { "@babel/core": "^7.24.7", diff --git a/rollup.config.js b/rollup.config.js index 9a8b5a17..34c881e1 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -89,7 +89,7 @@ const outputEntryTemplate = { sourcemap: dev, }; -const CIRCULAR_DEPENDENCY_IGNORE_REGEXP = /(ha-nunjucks|ts-py-datetime)/; +const CIRCULAR_DEPENDENCY_IGNORE_REGEXP = /(ha-nunjucks|ts-py-datetime|zod\/v4)/; /** * @type {import('rollup').RollupOptions} diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 691dfdde..a672918d 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -167,7 +167,7 @@ export class CameraManager { // order, to ensure that the defaults in the cameras global config do not // override the values specified in the per-camera config. const cameras = (config.cameras ?? []).map((camera) => - recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera), + recursivelyMergeObjectsNotArrays(config?.cameras_global, camera), ); try { diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 7f491f31..2dabab9f 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -1,5 +1,5 @@ import { isEqual } from 'lodash-es'; -import { isConfigUpgradeable } from '../../config/management.js'; +import { copyConfig, isConfigUpgradeable } from '../../config/management.js'; import { setProfiles } from '../../config/profiles/set-profiles.js'; import { AdvancedCameraCardConfig, @@ -8,7 +8,7 @@ import { } from '../../config/schema/types.js'; import { RawAdvancedCameraCardConfig } from '../../config/types.js'; import { localize } from '../../localize/localize.js'; -import { getParseErrorPaths } from '../../utils/zod.js'; +import { getParseError } from '../../utils/zod/parse-errors.js'; import { InitializationAspect } from '../initialization-manager.js'; import { CardConfigAPI } from '../types.js'; import { setAutomationsFromConfig } from './load-automations.js'; @@ -64,7 +64,7 @@ export class ConfigManager { const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { const configUpgradeable = isConfigUpgradeable(inputConfig); - const hint = getParseErrorPaths(parseResult.error); + const hint = getParseError(parseResult.error); let upgradeMessage = ''; if (configUpgradeable) { upgradeMessage = `${localize('error.upgrade_available')}. `; @@ -72,12 +72,22 @@ export class ConfigManager { throw new Error( upgradeMessage + `${localize('error.invalid_configuration')}: ` + - (hint && hint.size - ? JSON.stringify([...hint], null, ' ') - : localize('error.invalid_configuration_no_hint')), + (hint ?? localize('error.invalid_configuration_no_hint')), ); } - const config = setProfiles(inputConfig, parseResult.data, parseResult.data.profiles); + const config = advancedCameraCardConfigSchema.parse( + setProfiles( + inputConfig, + + // The config is cloned here because Zod 4 returns shared constant + // defaults by reference. Since setProfiles() mutates the configuration + // in-place, those mutations would "pollute" the global defaults and break + // test isolation if we didn't use a fresh clone here. + copyConfig(parseResult.data), + + parseResult.data.profiles, + ), + ); this._rawConfig = inputConfig; if (isEqual(this._config, config)) { diff --git a/src/card-controller/config/overrides-manager.ts b/src/card-controller/config/overrides-manager.ts index 8bddbfcc..33487035 100644 --- a/src/card-controller/config/overrides-manager.ts +++ b/src/card-controller/config/overrides-manager.ts @@ -94,7 +94,7 @@ export class OverridesManager { if (!parseResult.success) { throw new OverrideConfigurationError( localize('error.invalid_configuration_override'), - [parseResult.error.errors, output], + [parseResult.error.issues, output], ); } return parseResult.data; diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index 52480fd3..2fd32975 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -16,8 +16,7 @@ import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity import { CachedValueController } from '../components-lib/cached-value-controller.js'; import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js'; import { CameraConfig } from '../config/schema/cameras.js'; -import { ImageMode } from '../config/schema/common/image.js'; -import { ImageViewConfig } from '../config/schema/image.js'; +import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js'; import { isHassDifferent } from '../ha/is-hass-different.js'; import { HomeAssistant } from '../ha/types.js'; import defaultImage from '../images/iris-screensaver.jpg'; @@ -43,7 +42,7 @@ import { renderMessage } from './message.js'; const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000; export const resolveImageMode = (options?: { - imageConfig?: ImageViewConfig; + imageConfig?: ImageBaseConfig; cameraConfig?: CameraConfig; }): Exclude => { if (!options?.imageConfig?.mode) { @@ -87,7 +86,7 @@ export class AdvancedCameraCardImageUpdatingPlayer // 'show_image_during_load' option is true for live views, an overridden // config may be used here). @property({ attribute: false, hasChanged: contentsChanged }) - public imageConfig?: ImageViewConfig; + public imageConfig?: ImageBaseConfig; @state() protected _message: Message | null = null; diff --git a/src/config/profiles/set-profiles.ts b/src/config/profiles/set-profiles.ts index 5243fb90..edc84e6b 100644 --- a/src/config/profiles/set-profiles.ts +++ b/src/config/profiles/set-profiles.ts @@ -1,4 +1,4 @@ -import { deepRemoveDefaults } from '../../utils/zod.js'; +import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults.js'; import { getConfigValue, setConfigValue } from '../management.js'; import { ProfileType } from '../schema/profiles.js'; import { advancedCameraCardConfigSchema } from '../schema/types.js'; @@ -31,7 +31,7 @@ export const setProfiles = ( if (!defaultLessParseResult.success) { return outputConfig; } - const defaultLessConfig = defaultLessParseResult.data; + const defaultLessConfig = defaultLessParseResult.data as RawAdvancedCameraCardConfig; const setIfNotSpecified = (key: string, value: unknown) => { if (getConfigValue(defaultLessConfig, key) === undefined) { diff --git a/src/config/schema/actions/custom/internal.ts b/src/config/schema/actions/custom/internal.ts index b4767961..83360f09 100644 --- a/src/config/schema/actions/custom/internal.ts +++ b/src/config/schema/actions/custom/internal.ts @@ -9,7 +9,7 @@ export const internalCallbackActionConfigSchema = advanced_camera_card_action: z.literal(INTERNAL_CALLBACK_ACTION), // The callback is expected to be called with a CardController API object. - callback: z.function().args(z.any()).returns(z.promise(z.void())), + callback: z.function().input([z.any()]).output(z.promise(z.void())), }); export type InternalCallbackActionConfig = z.infer< typeof internalCallbackActionConfigSchema diff --git a/src/config/schema/actions/stock/call-service.ts b/src/config/schema/actions/stock/call-service.ts index c2644df4..60361b80 100644 --- a/src/config/schema/actions/stock/call-service.ts +++ b/src/config/schema/actions/stock/call-service.ts @@ -9,7 +9,7 @@ import { targetSchema } from './target'; export const callServiceActionSchema = actionBaseSchema.extend({ action: z.literal('call-service'), service: z.string(), - data: z.object({}).passthrough().optional(), + data: z.looseObject({}).optional(), target: targetSchema.optional(), }); export type CallServiceActionConfig = z.infer; diff --git a/src/config/schema/actions/stock/custom.ts b/src/config/schema/actions/stock/custom.ts index afd05d62..93973f8e 100644 --- a/src/config/schema/actions/stock/custom.ts +++ b/src/config/schema/actions/stock/custom.ts @@ -5,5 +5,5 @@ export const customActionSchema = actionBaseSchema .extend({ action: z.literal('fire-dom-event'), }) - .passthrough(); + .loose(); export type CustomActionConfig = z.infer; diff --git a/src/config/schema/actions/stock/perform-action.ts b/src/config/schema/actions/stock/perform-action.ts index 1e6deb81..58606ca0 100644 --- a/src/config/schema/actions/stock/perform-action.ts +++ b/src/config/schema/actions/stock/perform-action.ts @@ -5,7 +5,7 @@ import { targetSchema } from './target'; export const performActionActionSchema = actionBaseSchema.extend({ action: z.literal('perform-action'), perform_action: z.string(), - data: z.object({}).passthrough().optional(), + data: z.looseObject({}).optional(), target: targetSchema.optional(), }); export type PerformActionActionConfig = z.infer; diff --git a/src/config/schema/actions/stock/target.ts b/src/config/schema/actions/stock/target.ts index ab1e0704..0d64427f 100644 --- a/src/config/schema/actions/stock/target.ts +++ b/src/config/schema/actions/stock/target.ts @@ -1,10 +1,10 @@ import { HassServiceTarget } from 'home-assistant-js-websocket'; import { z } from 'zod'; -export const targetSchema: z.ZodSchema = z.object({ - entity_id: z.string().or(z.string().array()).optional(), - device_id: z.string().or(z.string().array()).optional(), - area_id: z.string().or(z.string().array()).optional(), - floor_id: z.string().or(z.string().array()).optional(), - label_id: z.string().or(z.string().array()).optional(), +export const targetSchema: z.ZodSchema = z.object({ + entity_id: z.union([z.string(), z.string().array()]).optional(), + device_id: z.union([z.string(), z.string().array()]).optional(), + area_id: z.union([z.string(), z.string().array()]).optional(), + label_id: z.union([z.string(), z.string().array()]).optional(), + floor_id: z.union([z.string(), z.string().array()]).optional(), }); diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index 00cdcd64..e6b27550 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -29,18 +29,15 @@ export type StatusBarActionConfig = z.infer< status_bar_action: 'add' | 'remove' | 'reset'; items?: StatusBarItem[]; }; -export const statusBarActionConfigSchema: z.ZodSchema< - StatusBarActionConfig, - z.ZodTypeDef, - unknown -> = advancedCameraCardCustomActionsBaseSchema.extend({ - advanced_camera_card_action: z.literal('status_bar'), - status_bar_action: z.enum(['add', 'remove', 'reset']), - items: z - .lazy(() => statusBarItemSchema) - .array() - .optional(), -}); +export const statusBarActionConfigSchema: z.ZodSchema = + advancedCameraCardCustomActionsBaseSchema.extend({ + advanced_camera_card_action: z.literal('status_bar'), + status_bar_action: z.enum(['add', 'remove', 'reset']), + items: z + .lazy(() => statusBarItemSchema) + .array() + .optional(), + }); const advancedCameraCardCustomActionSchema = z.union([ cameraSelectActionConfigSchema, @@ -69,7 +66,6 @@ export const actionConfigSchema = z.union([ advancedCameraCardCustomActionSchema, ]); export type ActionConfig = z.infer; - export const actionsBaseSchema = z .object({ tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), @@ -81,7 +77,7 @@ export const actionsBaseSchema = z // Passthrough to allow (at least) entity/camera_image to go through. This // card doesn't need these attributes, but handleAction() in // custom_card_helpers may depending on how the action is configured. - .passthrough(); + .loose(); export type Actions = z.infer; export interface AuxillaryActionConfig { diff --git a/src/config/schema/camera/ptz.ts b/src/config/schema/camera/ptz.ts index 8c963aaa..ad0e1711 100644 --- a/src/config/schema/camera/ptz.ts +++ b/src/config/schema/camera/ptz.ts @@ -77,7 +77,7 @@ export const ptzCameraConfigSchema = z.preprocess( .preprocess( dataPTZFormatToFullFormat(''), z.union([ - z.record(performActionActionSchema), + z.record(z.string(), performActionActionSchema), // This is used by the data_ style of action. z.object({ service: z.string().optional() }), diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 0714640d..a952ae96 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -4,8 +4,8 @@ import { mediaLayoutConfigSchema } from './camera/media-layout'; import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz'; import { aspectRatioSchema } from './common/aspect-ratio'; import { eventsMediaTypeSchema } from './common/events-media'; +import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image'; import { severitySchema } from './common/severity'; -import { imageBaseConfigSchema, imageConfigDefault } from './common/image'; const CAMERA_TRIGGER_EVENT_TYPES = [ // An event whether or not it has any media yet associated with it. @@ -56,7 +56,7 @@ const webrtcCardConfigSchema = z entity: z.string().optional(), url: z.string().optional(), }) - .passthrough(); + .loose(); const jsmpegConfigSchema = z.object({ options: z @@ -150,6 +150,8 @@ export const cameraConfigDefault = { ssl_ciphers: 'auto' as const, ssl_verification: 'auto' as const, }, + go2rtc: go2rtcConfigDefault, + image: imageBaseConfigDefault, always_error_if_entity_unavailable: false, }; @@ -217,7 +219,7 @@ const cameraMediaConfigSchema = z.object({ }); export const cameraConfigSchema = z - .object({ + .looseObject({ camera_entity: z.string().optional(), // Used for presentation in the UI (autodetected from the entity if @@ -318,7 +320,7 @@ export const cameraConfigSchema = z // Live provider options. live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), go2rtc: go2rtcConfigSchema.optional().default(go2rtcConfigDefault), - image: imageBaseConfigSchema.optional().default(imageConfigDefault), + image: imageBaseConfigSchema.optional().default(imageBaseConfigDefault), jsmpeg: jsmpegConfigSchema.optional(), webrtc_card: webrtcCardConfigSchema.optional(), diff --git a/src/config/schema/common/controls/ptz.ts b/src/config/schema/common/controls/ptz.ts index 06037a52..57917f19 100644 --- a/src/config/schema/common/controls/ptz.ts +++ b/src/config/schema/common/controls/ptz.ts @@ -22,6 +22,6 @@ export const ptzControlsConfigSchema = z.object({ hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom), hide_home: z.boolean().default(ptzControlsDefaults.hide_home), - style: z.object({}).passthrough().optional(), + style: z.looseObject({}).optional(), }); export type PTZControlsConfig = z.infer; diff --git a/src/config/schema/common/controls/thumbnails.ts b/src/config/schema/common/controls/thumbnails.ts index cc71e73f..33c30e8d 100644 --- a/src/config/schema/common/controls/thumbnails.ts +++ b/src/config/schema/common/controls/thumbnails.ts @@ -5,7 +5,7 @@ export const THUMBNAIL_WIDTH_MIN = 75; export const THUMBNAIL_WIDTH_DEFAULT = 100; export const THUMBNAIL_WIDTH_MAX = 300; -const thumbnailControlsBaseDefaults = { +export const thumbnailControlsBaseDefaults = { size: THUMBNAIL_WIDTH_DEFAULT, show_details: true, show_favorite_control: true, diff --git a/src/config/schema/common/image.ts b/src/config/schema/common/image.ts index 8569eeb4..d374c1e9 100644 --- a/src/config/schema/common/image.ts +++ b/src/config/schema/common/image.ts @@ -1,8 +1,12 @@ import { z } from 'zod'; -export const imageConfigDefault = { +export const imageBaseConfigDefault = { mode: 'auto' as const, refresh_seconds: 1, +}; + +export const imageConfigDefault = { + ...imageBaseConfigDefault, zoomable: true, }; @@ -10,11 +14,13 @@ const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const; export type ImageMode = (typeof IMAGE_MODES)[number]; export const imageBaseConfigSchema = z.object({ - mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), + mode: z.enum(IMAGE_MODES).default(imageBaseConfigDefault.mode), - refresh_seconds: z.number().min(0).default(imageConfigDefault.refresh_seconds), + refresh_seconds: z.number().min(0).default(imageBaseConfigDefault.refresh_seconds), url: z.string().optional(), entity: z.string().optional(), entity_parameters: z.string().optional(), }); + +export type ImageBaseConfig = z.infer; diff --git a/src/config/schema/conditions/stock/types.ts b/src/config/schema/conditions/stock/types.ts index 32cace37..6d95b365 100644 --- a/src/config/schema/conditions/stock/types.ts +++ b/src/config/schema/conditions/stock/types.ts @@ -10,4 +10,3 @@ export const stockConditionSchema = z.discriminatedUnion('condition', [ screenConditionSchema, usersConditionSchema, ]); -export type StockCondition = z.infer; diff --git a/src/config/schema/elements/base.ts b/src/config/schema/elements/base.ts index 9da19578..0ff83aaf 100644 --- a/src/config/schema/elements/base.ts +++ b/src/config/schema/elements/base.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { actionsBaseSchema } from '../actions/types'; export const elementsBaseSchema = actionsBaseSchema.extend({ - style: z.record(z.string().nullable().or(z.undefined()).or(z.number())).optional(), + style: z + .record(z.string(), z.string().nullable().or(z.undefined()).or(z.number())) + .optional(), title: z.string().nullable().optional(), }); diff --git a/src/config/schema/elements/custom/menu/base.ts b/src/config/schema/elements/custom/menu/base.ts index c03ca165..8e769011 100644 --- a/src/config/schema/elements/custom/menu/base.ts +++ b/src/config/schema/elements/custom/menu/base.ts @@ -11,5 +11,6 @@ export const menuBaseSchema = z.object({ .optional(), alignment: z.enum(['matching', 'opposing']).default('matching').optional(), icon: z.string().optional(), + state_color: z.boolean().default(true).optional(), permanent: z.boolean().default(false).optional(), }); diff --git a/src/config/schema/elements/custom/menu/icon.ts b/src/config/schema/elements/custom/menu/icon.ts index b9cd0da4..f5036323 100644 --- a/src/config/schema/elements/custom/menu/icon.ts +++ b/src/config/schema/elements/custom/menu/icon.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { iconSchema } from '../../stock/icon'; import { menuBaseSchema } from './base'; -export const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({ +export const menuIconSchema = menuBaseSchema.extend(iconSchema.shape).extend({ type: z.literal('custom:advanced-camera-card-menu-icon'), }); export type MenuIcon = z.infer; diff --git a/src/config/schema/elements/custom/menu/state-icon.ts b/src/config/schema/elements/custom/menu/state-icon.ts index bc6e2141..eef52edb 100644 --- a/src/config/schema/elements/custom/menu/state-icon.ts +++ b/src/config/schema/elements/custom/menu/state-icon.ts @@ -3,9 +3,9 @@ import { stateIconSchema } from '../../stock/state-icon'; import { menuBaseSchema } from './base'; export const menuStateIconSchema = menuBaseSchema - .merge(stateIconSchema) + .extend(stateIconSchema.shape) .extend({ type: z.literal('custom:advanced-camera-card-menu-state-icon'), }) - .merge(menuBaseSchema); + .extend(menuBaseSchema.shape); export type MenuStateIcon = z.infer; diff --git a/src/config/schema/elements/custom/menu/submenu-select.ts b/src/config/schema/elements/custom/menu/submenu-select.ts index 2727921e..df57c2eb 100644 --- a/src/config/schema/elements/custom/menu/submenu-select.ts +++ b/src/config/schema/elements/custom/menu/submenu-select.ts @@ -3,8 +3,10 @@ import { stateIconSchema } from '../../stock/state-icon'; import { menuBaseSchema } from './base'; import { menuSubmenuItemSchema } from './submenu'; -export const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({ - type: z.literal('custom:advanced-camera-card-menu-submenu-select'), - options: z.record(menuSubmenuItemSchema.deepPartial()).optional(), -}); +export const menuSubmenuSelectSchema = menuBaseSchema + .extend(stateIconSchema.shape) + .extend({ + type: z.literal('custom:advanced-camera-card-menu-submenu-select'), + options: z.record(z.string(), menuSubmenuItemSchema.partial()).optional(), + }); export type MenuSubmenuSelect = z.infer; diff --git a/src/config/schema/elements/custom/menu/submenu.ts b/src/config/schema/elements/custom/menu/submenu.ts index e6f7b02f..b49a9f44 100644 --- a/src/config/schema/elements/custom/menu/submenu.ts +++ b/src/config/schema/elements/custom/menu/submenu.ts @@ -13,7 +13,7 @@ export const menuSubmenuItemSchema = elementsBaseSchema.extend({ }); export type MenuSubmenuItem = z.infer; -export const menuSubmenuSchema = menuBaseSchema.merge(iconSchema).extend({ +export const menuSubmenuSchema = menuBaseSchema.extend(iconSchema.shape).extend({ type: z.literal('custom:advanced-camera-card-menu-submenu'), items: menuSubmenuItemSchema.array(), }); diff --git a/src/config/schema/elements/stock/custom.ts b/src/config/schema/elements/stock/custom.ts index 71f97d23..11af002d 100644 --- a/src/config/schema/elements/stock/custom.ts +++ b/src/config/schema/elements/stock/custom.ts @@ -7,11 +7,11 @@ export const customSchema = z type: z.string().superRefine((val, ctx) => { if (!val.match(/^custom:(?!advanced-camera-card).+/)) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', message: 'advanced-camera-card custom elements must match specific schemas', fatal: true, }); } }), }) - .passthrough(); + .loose(); diff --git a/src/config/schema/elements/stock/image.ts b/src/config/schema/elements/stock/image.ts index 073503fa..0cf88456 100644 --- a/src/config/schema/elements/stock/image.ts +++ b/src/config/schema/elements/stock/image.ts @@ -8,8 +8,8 @@ export const imageSchema = elementsBaseSchema.extend({ image: z.string().optional(), camera_image: z.string().optional(), camera_view: z.string().optional(), - state_image: z.object({}).passthrough().optional(), + state_image: z.looseObject({}).optional(), filter: z.string().optional(), - state_filter: z.object({}).passthrough().optional(), + state_filter: z.looseObject({}).optional(), aspect_ratio: z.string().optional(), }); diff --git a/src/config/schema/elements/stock/service-call.ts b/src/config/schema/elements/stock/service-call.ts index 7f6743d3..9f69c98b 100644 --- a/src/config/schema/elements/stock/service-call.ts +++ b/src/config/schema/elements/stock/service-call.ts @@ -8,5 +8,5 @@ export const serviceCallButtonSchema = elementsBaseSchema.extend({ // Title is required for service button. title: z.string(), service: z.string(), - service_data: z.object({}).passthrough().optional(), + service_data: z.looseObject({}).optional(), }); diff --git a/src/config/schema/elements/types.ts b/src/config/schema/elements/types.ts index ac5e0db2..9b224a25 100644 --- a/src/config/schema/elements/types.ts +++ b/src/config/schema/elements/types.ts @@ -4,11 +4,8 @@ import { statusBarImageItemSchema, statusBarStringItemSchema, } from '../actions/types'; -import { StockCondition, stockConditionSchema } from '../conditions/stock/types'; -import { - AdvancedCameraCardCondition, - advancedCameraCardConditionSchema, -} from '../conditions/types'; +import { stockConditionSchema } from '../conditions/stock/types'; +import { advancedCameraCardConditionSchema } from '../conditions/types'; import { menuIconSchema } from './custom/menu/icon'; import { menuStateIconSchema } from './custom/menu/state-icon'; import { menuSubmenuSchema } from './custom/menu/submenu'; @@ -25,36 +22,30 @@ import { stateLabelSchema } from './stock/state-label'; // include other elements. Putting these elements elsewhere would cause // typescript circular dependency errors as the types need to be both included // in the master pictureElementSchema, but also refer to it internally. -// -// Provide a manual type definition to avoid the `any` that would be created by -// the lazy() evaluation below. -// See: https://zod.dev/?id=recursive-types // https://www.home-assistant.io/lovelace/picture-elements/#image-element -type Conditional = { - type: 'conditional'; - conditions: StockCondition[]; - elements?: PictureElements; -}; -export const conditionalSchema: z.ZodSchema = z.object({ +export const conditionalSchema = z.object({ type: z.literal('conditional'), conditions: stockConditionSchema.array(), - elements: z.lazy(() => pictureElementsSchema), + + get elements() { + // Recursive schema. + return pictureElementsSchema; + }, }); -export type AdvancedCameraCardConditional = { - type: 'custom:advanced-camera-card-conditional'; - conditions: AdvancedCameraCardCondition[]; - elements?: PictureElements; -}; -const advancedCameraCardConditionalSchema: z.ZodSchema< - AdvancedCameraCardConditional, - z.ZodTypeDef -> = z.object({ +const advancedCameraCardConditionalSchema = z.object({ type: z.literal('custom:advanced-camera-card-conditional'), conditions: advancedCameraCardConditionSchema.array(), - elements: z.lazy(() => pictureElementsSchema), + + get elements() { + // Recursive schema. + return pictureElementsSchema; + }, }); +export type AdvancedCameraCardConditional = z.infer< + typeof advancedCameraCardConditionalSchema +>; // Cannot use discriminatedUnion since customSchema uses a superRefine, which // causes false rejections. @@ -77,5 +68,5 @@ const pictureElementSchema = z.union([ statusBarStringItemSchema, ]); -export const pictureElementsSchema = pictureElementSchema.array().optional(); +export const pictureElementsSchema = pictureElementSchema.array().optional().default([]); export type PictureElements = z.infer; diff --git a/src/config/schema/folders.ts b/src/config/schema/folders.ts index 68c434aa..40a38cac 100644 --- a/src/config/schema/folders.ts +++ b/src/config/schema/folders.ts @@ -60,14 +60,15 @@ const titleMatcherSchema = z.object({ }); export type TitleMatcher = z.infer; -type OrMatcher = { - type: 'or'; - matchers: Matcher[]; -}; -const orMatcherSchema: z.ZodSchema = z.object({ +const orMatcherSchema = z.object({ type: z.literal('or'), - matchers: z.array(z.lazy(() => matcherSchema)), + + get matchers() { + // Recursive schema. + return z.array(matcherSchema); + }, }); + export const matcherSchema = z.union([ dateMatcherSchema, orMatcherSchema, diff --git a/src/config/schema/image.ts b/src/config/schema/image.ts index 6b752efc..5140a89e 100644 --- a/src/config/schema/image.ts +++ b/src/config/schema/image.ts @@ -6,7 +6,7 @@ export const imageConfigSchema = imageBaseConfigSchema .extend({ zoomable: z.boolean().default(imageConfigDefault.zoomable), }) - .merge(actionsSchema) + .extend(actionsSchema.shape) .default(imageConfigDefault); export type ImageViewConfig = z.infer; diff --git a/src/config/schema/live.ts b/src/config/schema/live.ts index 49fabe34..8d767301 100644 --- a/src/config/schema/live.ts +++ b/src/config/schema/live.ts @@ -52,7 +52,6 @@ export const liveConfigDefault = { zoomable: true, transition_effect: 'slide' as const, show_image_during_load: true, - mode: 'single' as const, controls: { builtin: true, next_previous: { @@ -126,6 +125,6 @@ export const liveConfigSchema = z ), zoomable: z.boolean().default(liveConfigDefault.zoomable), }) - .merge(actionsSchema) + .extend(actionsSchema.shape) .default(liveConfigDefault); export type LiveConfig = z.infer; diff --git a/src/config/schema/media-gallery.ts b/src/config/schema/media-gallery.ts index 069a87f8..d73ab5c0 100644 --- a/src/config/schema/media-gallery.ts +++ b/src/config/schema/media-gallery.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; import { actionsSchema } from './actions/types'; import { - thumbnailControlsDefaults, + thumbnailControlsBaseDefaults, thumbnailsControlBaseSchema, } from './common/controls/thumbnails'; const mediaGalleryThumbnailControlsDefaults = { - ...thumbnailControlsDefaults, + ...thumbnailControlsBaseDefaults, show_details: false, }; @@ -43,6 +43,6 @@ export const mediaGalleryConfigSchema = z }) .default(mediaGalleryConfigDefault.controls), }) - .merge(actionsSchema) + .extend(actionsSchema.shape) .default(mediaGalleryConfigDefault); export type MediaGalleryConfig = z.infer; diff --git a/src/config/schema/menu.ts b/src/config/schema/menu.ts index 25eb7358..90c2a701 100644 --- a/src/config/schema/menu.ts +++ b/src/config/schema/menu.ts @@ -13,13 +13,20 @@ const MENU_STYLES = [ const MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const; const MENU_ALIGNMENTS = MENU_POSITIONS; -const visibleButtonDefault = { +const baseButtonDefault = { + alignment: 'matching' as const, + state_color: true, + permanent: false, priority: MENU_PRIORITY_DEFAULT, +}; + +const visibleButtonDefault = { + ...baseButtonDefault, enabled: true, }; const hiddenButtonDefault = { - priority: MENU_PRIORITY_DEFAULT, + ...baseButtonDefault, enabled: false, }; @@ -27,35 +34,37 @@ export const menuConfigDefault = { alignment: 'left' as const, button_size: 40, buttons: { - camera_ui: visibleButtonDefault, - cameras: visibleButtonDefault, - clips: hiddenButtonDefault, - ptz_home: hiddenButtonDefault, - display_mode: visibleButtonDefault, - download: visibleButtonDefault, - expand: hiddenButtonDefault, - folders: visibleButtonDefault, - iris: visibleButtonDefault, - fullscreen: visibleButtonDefault, - image: hiddenButtonDefault, - info: visibleButtonDefault, - gallery: visibleButtonDefault, - live: visibleButtonDefault, - media_player: visibleButtonDefault, + // Clone per key so each button has its own default object. This avoids + // shared nested default references between keys. + camera_ui: { ...visibleButtonDefault }, + cameras: { ...visibleButtonDefault }, + clips: { ...hiddenButtonDefault }, + ptz_home: { ...hiddenButtonDefault }, + display_mode: { ...visibleButtonDefault }, + download: { ...visibleButtonDefault }, + expand: { ...hiddenButtonDefault }, + folders: { ...visibleButtonDefault }, + iris: { ...visibleButtonDefault }, + fullscreen: { ...visibleButtonDefault }, + image: { ...hiddenButtonDefault }, + info: { ...visibleButtonDefault }, + gallery: { ...visibleButtonDefault }, + live: { ...visibleButtonDefault }, + media_player: { ...visibleButtonDefault }, microphone: { ...hiddenButtonDefault, type: 'momentary' as const, }, - mute: hiddenButtonDefault, - play: hiddenButtonDefault, - ptz_controls: hiddenButtonDefault, - recordings: hiddenButtonDefault, - reviews: hiddenButtonDefault, - set_review: visibleButtonDefault, - screenshot: hiddenButtonDefault, - snapshots: hiddenButtonDefault, - substreams: visibleButtonDefault, - timeline: visibleButtonDefault, + mute: { ...hiddenButtonDefault }, + play: { ...hiddenButtonDefault }, + ptz_controls: { ...hiddenButtonDefault }, + recordings: { ...hiddenButtonDefault }, + reviews: { ...hiddenButtonDefault }, + set_review: { ...visibleButtonDefault }, + screenshot: { ...hiddenButtonDefault }, + snapshots: { ...hiddenButtonDefault }, + substreams: { ...visibleButtonDefault }, + timeline: { ...visibleButtonDefault }, }, position: 'top' as const, style: 'hidden' as const, diff --git a/src/config/schema/overrides.ts b/src/config/schema/overrides.ts index 2e2c12b5..cecb96a2 100644 --- a/src/config/schema/overrides.ts +++ b/src/config/schema/overrides.ts @@ -3,8 +3,8 @@ import { advancedCameraCardConditionSchema } from './conditions/types'; const overrideSchema = z.object({ conditions: advancedCameraCardConditionSchema.array(), - merge: z.object({}).passthrough().optional(), - set: z.object({}).passthrough().optional(), + merge: z.looseObject({}).optional(), + set: z.looseObject({}).optional(), delete: z.string().array().optional(), }); export type Override = z.infer; diff --git a/src/config/schema/types.ts b/src/config/schema/types.ts index 0c50a218..df7ed2f6 100644 --- a/src/config/schema/types.ts +++ b/src/config/schema/types.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { deepRemoveDefaults } from '../../utils/zod'; +import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults'; import { automationsSchema } from './automations'; import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras'; import { cardIDRegex } from './common/const'; diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index ea865790..31f28c04 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -45,6 +45,8 @@ export type PTZKeyboardShortcutName = | 'ptz_zoom_in' | 'ptz_zoom_out'; +const interactionModeDefault = 'inactive' as const; + export const viewConfigDefault = { default: 'auto' as const, camera_select: 'current' as const, @@ -53,7 +55,7 @@ export const viewConfigDefault = { every_seconds: 0, after_interaction: false, entities: [], - interaction_mode: 'inactive' as const, + interaction_mode: interactionModeDefault, }, default_cycle_camera: false, dim: false, @@ -64,6 +66,7 @@ export const viewConfigDefault = { show_trigger_status: false, filter_selected_camera: true, actions: { + interaction_mode: interactionModeDefault, trigger: 'update' as const, untrigger: 'none' as const, }, @@ -73,7 +76,9 @@ export const viewConfigDefault = { keyboard_shortcuts: keyboardShortcutsDefault, }; -const interactionModeSchema = z.enum(['all', 'inactive', 'active']).default('inactive'); +const interactionModeSchema = z + .enum(['all', 'inactive', 'active']) + .default(interactionModeDefault); export type InteractionMode = z.infer; export const triggersSchema = z.object({ @@ -108,7 +113,7 @@ export type ThemeName = z.infer; const themeConfigSchema = z.object({ themes: themeName.array().default(viewConfigDefault.theme.themes), - overrides: z.record(z.string()).optional(), + overrides: z.record(z.string(), z.string()).optional(), }); export type ThemeConfig = z.infer; @@ -128,9 +133,7 @@ export const viewConfigSchema = z .default(viewConfigDefault.default_reset.after_interaction), every_seconds: z.number().default(viewConfigDefault.default_reset.every_seconds), entities: z.string().array().default(viewConfigDefault.default_reset.entities), - interaction_mode: interactionModeSchema.default( - viewConfigDefault.default_reset.interaction_mode, - ), + interaction_mode: interactionModeSchema, }) .default(viewConfigDefault.default_reset), @@ -144,5 +147,5 @@ export const viewConfigSchema = z viewConfigDefault.keyboard_shortcuts, ), }) - .merge(actionsSchema) + .extend(actionsSchema.shape) .default(viewConfigDefault); diff --git a/src/config/schema/viewer.ts b/src/config/schema/viewer.ts index 4b6b7a4d..349e970d 100644 --- a/src/config/schema/viewer.ts +++ b/src/config/schema/viewer.ts @@ -27,7 +27,6 @@ export const viewerConfigDefault = { zoomable: true, transition_effect: 'slide' as const, snapshot_click_plays_clip: true, - display_mode: 'single' as const, controls: { builtin: true, next_previous: { @@ -106,6 +105,6 @@ export const viewerConfigSchema = z }) .default(viewerConfigDefault.controls), }) - .merge(actionsSchema) + .extend(actionsSchema.shape) .default(viewerConfigDefault); export type ViewerConfig = z.infer; diff --git a/src/ha/browse-media/types.ts b/src/ha/browse-media/types.ts index 815f9748..8b099779 100644 --- a/src/ha/browse-media/types.ts +++ b/src/ha/browse-media/types.ts @@ -7,36 +7,24 @@ export interface BrowseMediaMetadata { endDate?: Date; what?: string[]; } -// Recursive type, cannot use type interference: -// See: https://github.com/colinhacks/zod#recursive-types -// + // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L90 +export const browseMediaSchema = z.object({ + title: z.string(), + media_class: z.string(), + media_content_type: z.string(), + media_content_id: z.string(), + can_play: z.boolean(), + can_expand: z.boolean(), + children_media_class: z.string().nullable().optional(), + thumbnail: z.string().nullable(), -export interface BrowseMedia { - title: string; - media_class: string; - media_content_type: string; - media_content_id: string; - can_play: boolean; - can_expand: boolean; - children_media_class?: string | null; - thumbnail: string | null; - children?: BrowseMedia[] | null; -} - -export const browseMediaSchema: z.ZodSchema = z.lazy(() => - z.object({ - title: z.string(), - media_class: z.string(), - media_content_type: z.string(), - media_content_id: z.string(), - can_play: z.boolean(), - can_expand: z.boolean(), - children_media_class: z.string().nullable().optional(), - thumbnail: z.string().nullable(), - children: z.array(browseMediaSchema).nullable().optional(), - }), -); + get children() { + // Recursive schema. + return z.array(browseMediaSchema).nullable().optional(); + }, +}); +export type BrowseMedia = z.infer; export interface RichBrowseMedia extends BrowseMedia { _metadata?: M; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 1c40f559..124b19be 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -247,16 +247,16 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => { return children.filter(isHTMLElement); }; -export const recursivelyMergeObjectsNotArrays = (target: T, src1: T, src2: T): T => { - return mergeWith(target, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined)); +export const recursivelyMergeObjectsNotArrays = ( + ...srcs: (Partial | undefined | null)[] +): T => { + return mergeWith({}, ...srcs, (_a, b) => (Array.isArray(b) ? b : undefined)); }; export const recursivelyMergeObjectsConcatenatingArraysUniquely = ( - target: T, - src1: T, - src2: T, + ...srcs: (Partial | undefined | null)[] ): T => { - return mergeWith(target, src1, src2, (a, b) => + return mergeWith({}, ...srcs, (a, b) => Array.isArray(a) ? uniq(a.concat(b)) : undefined, ); }; diff --git a/src/utils/zod.ts b/src/utils/zod.ts deleted file mode 100644 index 2afdc90c..00000000 --- a/src/utils/zod.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { z } from 'zod'; - -/** - * Recursively remove defaults from a zod schema. - * - * See: https://github.com/colinhacks/zod/discussions/845#discussioncomment-1936943 - * - * @param schema The Zod schema. - * @returns A new Zod schema. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function deepRemoveDefaults(schema: T): any { - if (schema instanceof z.ZodDefault) { - return deepRemoveDefaults(schema.removeDefault()); - } - - if (schema instanceof z.ZodObject) { - const newShape = {}; - - for (const key in schema.shape) { - const fieldSchema = schema.shape[key]; - newShape[key] = z.ZodOptional.create(deepRemoveDefaults(fieldSchema)); - } - return new z.ZodObject({ - ...schema._def, - shape: () => newShape, - }); - } - - if (schema instanceof z.ZodArray) { - return z.ZodArray.create(deepRemoveDefaults(schema.element)) - .min(schema._def.minLength?.value, schema._def.minLength?.message) - .max(schema._def.maxLength?.value, schema._def.maxLength?.message) - .length(schema._def.exactLength?.value, schema._def.exactLength?.message); - } - - if (schema instanceof z.ZodOptional) { - return z.ZodOptional.create(deepRemoveDefaults(schema.unwrap())); - } - - if (schema instanceof z.ZodNullable) { - return z.ZodNullable.create(deepRemoveDefaults(schema.unwrap())); - } - - if (schema instanceof z.ZodTuple) { - return z.ZodTuple.create( - schema.items.map((item: z.ZodTypeAny) => deepRemoveDefaults(item)), - ); - } - return schema; -} - -/** - * Get the keys that didn't parse from a ZodError. - * @param error The zoderror to extract the keys from. - * @returns An array of error keys. - */ -export function getParseErrorKeys(error: z.ZodError): string[] { - const errors = error.format(); - return Object.keys(errors).filter((v) => !v.startsWith('_')); -} - -/** - * Get configuration parse errors. - * @param error The ZodError object from parsing. - * @returns An array of string error paths. - */ -export const getParseErrorPaths = (error: z.ZodError): Set => { - /* Zod errors involving unions are complex, as Zod may not be able to tell - * where the 'real' error is vs simply a union option not matching. This - * function finds all ZodError "issues" that don't have an error with 'type' - * in that object ('type' is the union discriminator for picture elements, - * the major union in the schema). An array of user-readable error - * locations is returned, or an empty list if none is available. None being - * available suggests the configuration has an error, but we can't tell - * exactly why (or rather Zod simply says it doesn't match any of the - * available unions). This usually suggests the user specified an incorrect - * type name entirely. */ - const contenders = new Set(); - if (error.issues.length) { - for (const issue of error.issues) { - if (issue.code === 'invalid_union') { - const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; - for (const unionError of unionErrors) { - getParseErrorPaths(unionError).forEach(contenders.add, contenders); - } - } else { - contenders.add(getParseErrorPathString(issue.path)); - } - } - } - return contenders; -}; - -/** - * Convert an array of strings and indices into a more user readable string, - * e.g. [a, 1, b, 2] => 'a[1] -> b[2]' - * @param path An array of strings and numbers. - * @returns A single string. - */ -const getParseErrorPathString = (path: (string | number)[]): string => { - let out = ''; - for (let i = 0; i < path.length; i++) { - const item = path[i]; - if (typeof item == 'number') { - out += '[' + item + ']'; - } else if (out) { - out += ' -> ' + item; - } else { - out = item; - } - } - return out; -}; diff --git a/src/utils/zod/deep-remove-defaults.ts b/src/utils/zod/deep-remove-defaults.ts new file mode 100644 index 00000000..f66466a4 --- /dev/null +++ b/src/utils/zod/deep-remove-defaults.ts @@ -0,0 +1,147 @@ +import { z } from 'zod'; + +/** + * This utility intentionally supports classic/full Zod schemas only + * (i.e. schemas created via `import { z } from 'zod'`). + * It does not target `zod/mini` schema instances. + * + * In Zod 4, internal accessors (.shape values, .unwrap() results, etc.) + * return core.$ZodType instead of the classic ZodType. + */ +const toClassic = (schema: z.ZodType | z.core.$ZodType): z.ZodType => { + if (schema instanceof z.ZodType) { + return schema; + } + throw new TypeError('deepRemoveDefaults supports full zod schemas only'); +}; + +/** + * Check whether an object field originally had a default/prefault wrapper, + * meaning it should become optional after stripping. Walks through + * transparent wrappers (nullable, readonly, etc.) to find defaults. + */ +function fieldWasDefaulted(schema: z.ZodType, seen = new Set()): boolean { + if (seen.has(schema)) { + return false; + } + seen.add(schema); + + if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) { + return true; + } + if (schema instanceof z.ZodOptional) { + return false; + } + + // Walk through transparent wrappers. + if (schema instanceof z.ZodNullable) { + return fieldWasDefaulted(toClassic(schema.unwrap()), seen); + } + if (schema instanceof z.ZodReadonly) { + return fieldWasDefaulted(toClassic(schema.unwrap()), seen); + } + if (schema instanceof z.ZodNonOptional) { + return fieldWasDefaulted(toClassic(schema.unwrap()), seen); + } + if (schema instanceof z.ZodLazy) { + return fieldWasDefaulted(toClassic(schema.unwrap()), seen); + } + if (schema instanceof z.ZodPipe) { + return fieldWasDefaulted(toClassic(schema.in), seen); + } + if (schema instanceof z.ZodUnion) { + return [...schema.options].some((option) => + fieldWasDefaulted(toClassic(option), seen), + ); + } + return false; +} + +/** + * Core recursive implementation. Strips all default/prefault wrappers + * and makes previously-defaulted object fields optional instead. + */ +function strip(schema: z.ZodType, cache: Map): z.ZodType { + const cached = cache.get(schema); + if (cached) { + return cached; + } + + // Seed the cache with a forward reference before recursing so cycles + // (including getter-based recursive objects) do not overflow the stack. + const reference: { schema: z.ZodType } = { schema }; + const forward = z.lazy(() => reference.schema); + cache.set(schema, forward); + + let result: z.ZodType; + + if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) { + // Unwrap the default — don't cache the wrapper itself. + result = strip(toClassic(schema.unwrap()), cache); + } else if (schema instanceof z.ZodObject) { + const newShape: Record = {}; + for (const [key, field] of Object.entries(schema.shape)) { + const classicField = toClassic(field); + const stripped = strip(classicField, cache); + const makeOptional = + fieldWasDefaulted(classicField) && !(stripped instanceof z.ZodOptional); + newShape[key] = makeOptional ? stripped.optional() : stripped; + } + result = z.clone(schema, { ...schema.def, shape: newShape }); + } else if (schema instanceof z.ZodArray) { + result = z.clone(schema, { + ...schema.def, + element: strip(toClassic(schema.element), cache), + }); + } else if (schema instanceof z.ZodTuple) { + result = z.clone(schema, { + ...schema.def, + items: schema.def.items.map((item) => strip(toClassic(item), cache)), + rest: schema.def.rest ? strip(toClassic(schema.def.rest), cache) : null, + }); + } else if (schema instanceof z.ZodUnion) { + const options = [...schema.options].map((opt) => strip(toClassic(opt), cache)); + result = z.clone(schema, { ...schema.def, options }); + } else if (schema instanceof z.ZodLazy) { + result = z.lazy(() => strip(toClassic(schema.unwrap()), cache)); + } else if (schema instanceof z.ZodPipe) { + result = z.clone(schema, { + ...schema.def, + in: strip(toClassic(schema.in), cache), + out: strip(toClassic(schema.out), cache), + }); + } else if ( + schema instanceof z.ZodOptional || + schema instanceof z.ZodNullable || + schema instanceof z.ZodReadonly || + schema instanceof z.ZodNonOptional || + schema instanceof z.ZodCatch || + schema instanceof z.ZodSuccess || + schema instanceof z.ZodPromise + ) { + // All of these are single-child wrappers with .unwrap(). + result = z.clone(schema, { + ...schema.def, + innerType: strip(toClassic(schema.unwrap()), cache), + }); + } else { + // Leaf types (string, number, boolean, enum, literal, etc.). + result = schema; + } + + reference.schema = result; + cache.set(schema, result); + return result; +} + +/** + * Recursively strips `z.default()` and `z.prefault()` wrappers from a schema. + * Object fields that had defaults become optional instead. + * Uses a cache to safely handle recursive (z.lazy) schemas. + */ +export function deepRemoveDefaults( + schema: T, + cache = new Map(), +): T { + return strip(schema, cache) as T; +} diff --git a/src/utils/zod/parse-errors.ts b/src/utils/zod/parse-errors.ts new file mode 100644 index 00000000..782c5954 --- /dev/null +++ b/src/utils/zod/parse-errors.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +/** + * Get configuration parse errors. + * @param error The ZodError object from parsing. + * @returns A set of string error paths. + */ +export const getParseErrorPaths = (error: z.ZodError): Set => { + /* Zod errors involving unions are complex, as Zod may not be able to tell + * where the 'real' error is vs simply a union option not matching. This + * function recursively extracts all error paths from all branches of a union. + * It returns a Set of dot-notation strings. If no paths are found, it suggests + * the configuration has an error but Zod cannot tell exactly why (usually an + * entirely incorrect type name). */ + const contenders = new Set(); + if (error.issues.length) { + for (const issue of error.issues) { + if (issue.code === 'invalid_union') { + const unionErrors = (issue as z.core.$ZodIssueInvalidUnion).errors; + for (const issues of unionErrors) { + const nestedPaths = getParseErrorPaths(new z.ZodError(issues)); + const prefix = z.core.toDotPath(issue.path); + nestedPaths.forEach((path) => { + contenders.add(prefix ? `${prefix}.${path}` : path); + }); + } + } else { + contenders.add(z.core.toDotPath(issue.path)); + } + } + } + return contenders; +}; + +/** + * Get configuration parse errors. + * @param error The ZodError object from parsing. + * @returns A string error message or null. + */ +export const getParseError = (error: z.ZodError): string | null => { + const paths = getParseErrorPaths(error); + return paths.size === 0 ? null : JSON.stringify([...paths], null, ' '); +}; diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 9afb9e0c..69412377 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ZodError } from 'zod'; import { AutomationsManager } from '../../../src/card-controller/automations-manager'; import { ConfigManager } from '../../../src/card-controller/config/config-manager'; import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; @@ -9,6 +8,7 @@ import { AdvancedCameraCardCondition } from '../../../src/config/schema/conditio import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; import { createGeneralAction } from '../../../src/utils/action'; import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; +import { ZodError, z } from 'zod'; /** * Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager. @@ -63,6 +63,7 @@ const TEST_CONDITIONS = { /** Profile settings for testing */ const TEST_PROFILES = { + CASTING: 'casting' as const, LOW_PERFORMANCE: 'low-performance' as const, } as const; @@ -78,10 +79,10 @@ describe('ConfigManager', () => { }); it('invalid configuration', () => { - const spy = vi.spyOn(advancedCameraCardConfigSchema, 'safeParse').mockReturnValue({ - success: false, - error: new ZodError([]), - }); + const schemaForMock: z.ZodType = advancedCameraCardConfigSchema; + const spy = vi + .spyOn(schemaForMock, 'safeParse') + .mockReturnValue({ success: false, error: new ZodError([]) }); const manager = new ConfigManager(createCardAPI()); expect(() => manager.setConfig({})).toThrowError( @@ -136,7 +137,7 @@ describe('ConfigManager', () => { expect(manager.getRawConfig()).toBe(config); // Verify at least the camera is set. - expect(manager.getConfig()?.cameras[0].camera_entity).toBe('camera.office'); + expect(manager.getConfig()?.cameras?.[0].camera_entity).toBe('camera.office'); // Verify at least one default was set. expect(manager.getConfig()?.menu.alignment).toBe('left'); @@ -169,6 +170,23 @@ describe('ConfigManager', () => { expect(manager.getConfig()?.live.draggable).toBeFalsy(); }); + it('should apply casting profile menu changes without affecting unrelated hidden buttons', () => { + const manager = new ConfigManager(createCardAPI()); + const config = { + type: 'custom:advanced-camera-card', + cameras: [TEST_CAMERAS.OFFICE], + profiles: [TEST_PROFILES.CASTING], + }; + + manager.setConfig(config); + + expect(manager.getConfig()?.menu.buttons.play.enabled).toBeTruthy(); + expect(manager.getConfig()?.menu.buttons.mute.enabled).toBeTruthy(); + expect(manager.getConfig()?.menu.buttons.fullscreen.enabled).toBeFalsy(); + expect(manager.getConfig()?.menu.buttons.media_player.enabled).toBeFalsy(); + expect(manager.getConfig()?.menu.buttons.clips.enabled).toBeFalsy(); + }); + it('should skip identical configs', () => { const api = createCardAPI(); const manager = new ConfigManager(api); @@ -234,6 +252,10 @@ describe('ConfigManager', () => { const config = { type: 'custom:advanced-camera-card', cameras: cameras, + menu: { + style: 'hidden', + position: 'top', + }, overrides: [ { conditions: [TEST_CONDITIONS.FULLSCREEN_ON], @@ -247,10 +269,11 @@ describe('ConfigManager', () => { manager.setConfig(config); - expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); - + const configBefore = manager.getConfig(); stateManager.setState({ fullscreen: true }); + const configAfter = manager.getConfig(); + expect(configAfter).toEqual(configBefore); expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); }); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 6a725244..22107f8f 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -98,6 +98,8 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, icon: 'iris', enabled: true, permanent: true, @@ -115,6 +117,8 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, icon: 'iris', enabled: true, permanent: true, @@ -146,6 +150,9 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:video-switch', enabled: true, priority: 50, @@ -258,6 +265,9 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:video-input-component', style: {}, title: 'Substream(s)', @@ -299,6 +309,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:video-input-component', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, title: 'Substream(s)', @@ -360,6 +373,9 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:video-input-component', title: 'Substream(s)', style: {}, @@ -452,6 +468,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:video-input-component', title: 'Substream(s)', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, @@ -515,6 +534,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:cctv', enabled: true, priority: 50, @@ -534,6 +556,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:cctv', enabled: true, priority: 50, @@ -569,6 +594,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:filmstrip', enabled: false, priority: 50, @@ -588,6 +616,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:filmstrip', enabled: false, priority: 50, @@ -622,6 +653,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:camera', enabled: false, priority: 50, @@ -647,6 +681,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:camera', enabled: false, priority: 50, @@ -689,6 +726,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:play-box-edit-outline', enabled: false, priority: 50, @@ -714,6 +754,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:play-box-edit-outline', enabled: false, priority: 50, @@ -754,6 +797,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:play-box-multiple', enabled: true, priority: 50, @@ -779,6 +825,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:play-box-multiple', enabled: true, priority: 50, @@ -819,6 +868,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:album', enabled: false, priority: 50, @@ -844,6 +896,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:album', enabled: false, priority: 50, @@ -887,6 +942,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:image', enabled: false, priority: 50, @@ -907,6 +965,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:image', enabled: false, priority: 50, @@ -942,6 +1003,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:chart-gantt', enabled: true, priority: 50, @@ -964,6 +1028,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:chart-gantt', enabled: true, priority: 50, @@ -1016,6 +1083,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:download', enabled: true, priority: 50, @@ -1084,6 +1154,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:web', enabled: true, priority: 50, @@ -1114,6 +1187,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:microphone', enabled: false, priority: 50, @@ -1171,6 +1247,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:microphone-message-off', enabled: false, priority: 50, @@ -1200,6 +1279,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:microphone-off', enabled: false, priority: 50, @@ -1237,6 +1319,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:microphone-message-off', enabled: false, priority: 50, @@ -1269,6 +1354,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:microphone-off', enabled: false, priority: 50, @@ -1305,6 +1393,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:microphone', enabled: false, priority: 50, @@ -1338,6 +1429,9 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { fullscreenManager }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:fullscreen', enabled: true, priority: 50, @@ -1360,6 +1454,9 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { fullscreenManager }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:fullscreen-exit', enabled: true, priority: 50, @@ -1392,6 +1489,9 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { inExpandedMode: false }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:arrow-expand-all', enabled: false, priority: 50, @@ -1406,6 +1506,9 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { inExpandedMode: true }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:arrow-collapse-all', enabled: false, priority: 50, @@ -1444,6 +1547,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:cast', enabled: true, priority: 50, @@ -1497,6 +1603,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:cast', enabled: true, priority: 50, @@ -1528,6 +1637,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:pause', enabled: false, priority: 50, @@ -1550,6 +1662,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:play', enabled: false, priority: 50, @@ -1571,6 +1686,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:volume-high', enabled: false, priority: 50, @@ -1593,6 +1711,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:volume-off', enabled: false, priority: 50, @@ -1610,6 +1731,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:monitor-screenshot', enabled: false, priority: 50, @@ -1646,6 +1770,9 @@ describe('MenuButtonController', () => { expect( calculateButtons(controller, { cameraManager: cameraManager, view: view }), ).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off', enabled: true, priority: 50, @@ -1727,6 +1854,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, @@ -1761,6 +1891,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, @@ -1793,6 +1926,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, @@ -1834,6 +1970,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, @@ -1904,6 +2043,9 @@ describe('MenuButtonController', () => { if (expectedResult) { expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, enabled: false, icon: 'mdi:home', priority: 50, @@ -1955,6 +2097,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:folder', enabled: true, priority: 50, @@ -1988,6 +2133,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:folder', enabled: true, priority: 50, @@ -2027,6 +2175,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:folder-multiple', enabled: true, priority: 50, @@ -2090,6 +2241,9 @@ describe('MenuButtonController', () => { }); expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, icon: 'mdi:folder-multiple', enabled: true, priority: 50, diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index c15b8203..3ae236c0 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -23,7 +23,7 @@ import { PerformActionActionConfig } from '../../src/config/schema/actions/stock import { Actions } from '../../src/config/schema/actions/types.js'; import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js'; import { RawAdvancedCameraCardConfig } from '../../src/config/types.js'; -import { getParseErrorPaths } from '../../src/utils/zod.js'; +import { getParseErrorPaths } from '../../src/utils/zod/parse-errors.js'; describe('general functions', () => { it('should set value', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 2aa35571..8bc9930e 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -45,8 +45,8 @@ describe('config defaults', () => { dynamic: true, live: 'auto', media: 'auto', - ssl_verification: 'auto', ssl_ciphers: 'auto', + ssl_verification: 'auto', }, ptz: { c2r_delay_between_calls_seconds: 0.2, @@ -56,13 +56,13 @@ describe('config defaults', () => { media_resolution: 'low', }, triggers: { - events: [], entities: [], + events: [], motion: false, occupancy: false, reviews: { - severities: ['high'], description: true, + severities: ['high'], }, }, }, @@ -74,10 +74,11 @@ describe('config defaults', () => { aspect_ratio_mode: 'dynamic', height: 'auto', }, + elements: [], image: { - zoomable: true, mode: 'auto', refresh_seconds: 1, + zoomable: true, }, live: { auto_mute: ['unselected', 'hidden', 'microphone'], @@ -203,109 +204,187 @@ describe('config defaults', () => { button_size: 40, buttons: { camera_ui: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, cameras: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, clips: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, display_mode: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, download: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, expand: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, folders: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, fullscreen: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, gallery: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, image: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, info: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, iris: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, live: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, media_player: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, microphone: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, type: 'momentary', }, mute: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, play: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, ptz_controls: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, ptz_home: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, recordings: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, reviews: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, - }, - set_review: { - enabled: true, - priority: 50, + state_color: true, }, screenshot: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, + }, + set_review: { + alignment: 'matching', + enabled: true, + permanent: false, + priority: 50, + state_color: true, }, snapshots: { + alignment: 'matching', enabled: false, + permanent: false, priority: 50, + state_color: true, }, substreams: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, timeline: { + alignment: 'matching', enabled: true, + permanent: false, priority: 50, + state_color: true, }, }, position: 'top', @@ -382,6 +461,15 @@ describe('config defaults', () => { view: { camera_select: 'current', default: 'auto', + default_cycle_camera: false, + default_reset: { + after_interaction: false, + entities: [], + every_seconds: 0, + interaction_mode: 'inactive', + }, + dim: false, + interaction_seconds: 300, keyboard_shortcuts: { enabled: true, ptz_down: { @@ -410,24 +498,15 @@ describe('config defaults', () => { themes: ['traditional'], }, triggers: { + actions: { + interaction_mode: 'inactive', + trigger: 'update', + untrigger: 'none', + }, + filter_selected_camera: true, show_trigger_status: false, untrigger_delay_seconds: 0, untrigger_force_seconds: 0, - actions: { - trigger: 'update', - untrigger: 'none', - interaction_mode: 'inactive', - }, - filter_selected_camera: true, - }, - interaction_seconds: 300, - dim: false, - default_cycle_camera: false, - default_reset: { - after_interaction: false, - every_seconds: 0, - entities: [], - interaction_mode: 'inactive', }, }, }); @@ -457,13 +536,22 @@ describe('config defaults', () => { }, { type: 'custom:advanced-camera-card-menu-icon', - icon: 'mdi:cat', + alignment: 'matching', + enabled: true, entity: 'camera.kitchen', + icon: 'mdi:cat', + permanent: false, + priority: 50, + state_color: true, }, { type: 'custom:advanced-camera-card-menu-state-icon', + alignment: 'matching', + enabled: true, entity: 'camera.kitchen', icon: 'mdi:sheep', + permanent: false, + priority: 50, state_color: false, }, { @@ -589,6 +677,7 @@ describe('config defaults', () => { icon: 'mdi:car', permanent: false, priority: 50, + state_color: true, style: { color: 'white', }, @@ -646,6 +735,7 @@ describe('config defaults', () => { ], permanent: false, priority: 50, + state_color: true, style: { color: 'white', }, @@ -669,7 +759,10 @@ describe('config defaults', () => { title: 'Cooking time!', }, 'scene.kitchen_tv_scene': { + enabled: true, icon: 'mdi:television', + selected: false, + state_color: true, title: 'TV!', }, }, @@ -1369,6 +1462,11 @@ describe('should lazy evaluate schemas', () => { status_bar_action: 'reset', items: [ { + enabled: true, + exclusive: false, + expand: false, + priority: 50, + sufficient: false, type: 'custom:advanced-camera-card-status-bar-string', string: 'Item', }, @@ -1379,13 +1477,20 @@ describe('should lazy evaluate schemas', () => { }); describe('should handle custom advanced camera card elements', () => { + it('should reject non-custom element types', () => { + const result = customSchema.safeParse({ + type: 'foo', + }); + expect(result.success).toBeFalsy(); + }); + it('should add custom error on advanced camera card element', () => { const result = customSchema.safeParse({ type: 'custom:advanced-camera-card-foo', }); expect(result.success).toBeFalsy(); if (!result.success) { - expect(result.error.errors[0]).toEqual({ + expect(result.error.issues[0]).toEqual({ code: 'custom', message: 'advanced-camera-card custom elements must match specific schemas', fatal: true, @@ -1429,7 +1534,7 @@ it('should strip trailing slashes from go2rtc url', () => { ], }); expect(config).toBeTruthy(); - expect(config.cameras[0].go2rtc.url).toBe('https://my-custom-go2rtc'); + expect(config.cameras?.[0].go2rtc.url).toBe('https://my-custom-go2rtc'); }); it('media viewer should not support microphone based conditions', () => { diff --git a/tests/utils/zod.test.ts b/tests/utils/zod.test.ts deleted file mode 100644 index ef69193f..00000000 --- a/tests/utils/zod.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { z, ZodError } from 'zod'; -import { - deepRemoveDefaults, - getParseErrorKeys, - getParseErrorPaths, -} from '../../src/utils/zod'; - -describe('deepRemoveDefaults', () => { - it('should remove string defaults', () => { - const schema = z.object({ - string: z.string().default('foo'), - }); - const result = deepRemoveDefaults(schema).parse({}); - expect(result.string).toBeUndefined(); - }); - it('should remove array defaults', () => { - const schema = z.object({ - array: z.string().array().default(['foo']), - }); - const result = deepRemoveDefaults(schema).parse({}); - expect(result.array).toBeUndefined(); - }); - it('should remove optional defaults', () => { - const schema = z.object({ - string: z.string().default('foo').optional(), - }); - const result = deepRemoveDefaults(schema).parse({}); - expect(result.string).toBeUndefined(); - }); - it('should remove null defaults', () => { - const schema = z.object({ - null: z.string().default('foo').nullable(), - }); - const result = deepRemoveDefaults(schema).parse({}); - expect(result.null).toBeUndefined(); - }); - it('should remove null defaults', () => { - const schema = z.object({ - tuple: z.tuple([z.string()]).default(['foo']), - }); - const result = deepRemoveDefaults(schema).parse({}); - expect(result.tuple).toBeUndefined(); - }); - it('should not interfere with parsing', () => { - const schema = z.object({ - string: z.string().default('foo'), - }); - const result = deepRemoveDefaults(schema).parse({ string: 'moo' }); - expect(result.string).toBe('moo'); - }); - describe('should still enforce array length', () => { - it('min', () => { - const schema = z.number().array().min(1); - const result = deepRemoveDefaults(schema).safeParse([]); - expect(result.success).toBeFalsy(); - }); - it('max', () => { - const schema = z.number().array().max(1); - const result = deepRemoveDefaults(schema).safeParse([1, 2]); - expect(result.success).toBeFalsy(); - }); - it('exact', () => { - const schema = z.number().array().length(1); - const result = deepRemoveDefaults(schema).safeParse([]); - expect(result.success).toBeFalsy(); - }); - }); -}); - -describe('getParseErrorKeys', () => { - it('should get error keys', () => { - const result = z.object({ required: z.string() }).safeParse({}); - expect(result.success).toBeFalsy(); - if (result.success) { - return; - } - expect(getParseErrorKeys(result.error)).toEqual(['required']); - }); -}); - -describe('getParseErrorPaths', () => { - it('should get simple error paths', () => { - const result = z.object({ required: z.string() }).safeParse({}); - expect(result.success).toBeFalsy(); - if (result.success) { - return; - } - expect(getParseErrorPaths(result.error)).toEqual(new Set(['required'])); - }); - it('should get union error paths', () => { - const type_one = z.object({ type: z.string(), data: z.string() }); - const type_two = z.object({ type: z.literal('two'), data: z.string() }); - - const schema = z.object({ - array: type_one.or(type_two).array(), - }); - - const result = schema.safeParse({ array: [{}] }); - expect(result.success).toBeFalsy(); - if (result.success) { - return; - } - expect(getParseErrorPaths(result.error)).toEqual( - new Set(['array[0] -> type', 'array[0] -> data']), - ); - }); - it('should get no paths for empty error', () => { - expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set()); - }); -}); diff --git a/tests/utils/zod/deep-remove-defaults.test.ts b/tests/utils/zod/deep-remove-defaults.test.ts new file mode 100644 index 00000000..e760e9c4 --- /dev/null +++ b/tests/utils/zod/deep-remove-defaults.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { deepRemoveDefaults } from '../../../src/utils/zod/deep-remove-defaults'; + +describe('deepNoDefaults', () => { + describe('object field behavior', () => { + it('strips defaulted object fields', () => { + const schema = z.object({ + string: z.string().default('foo'), + }); + const result = deepRemoveDefaults(schema).parse({}); + expect(result.string).toBeUndefined(); + }); + + it('strips prefaulted object fields', () => { + const schema = z.object({ + string: z.string().prefault('foo'), + }); + const result = deepRemoveDefaults(schema).parse({}); + expect(result.string).toBeUndefined(); + }); + + it('keeps non-defaulted object fields required', () => { + const schema = z.object({ + required: z.string(), + }); + const result = deepRemoveDefaults(schema).safeParse({}); + expect(result.success).toBe(false); + }); + + it('keeps explicitly optional fields optional', () => { + const schema = z.object({ + maybe: z.string().optional(), + }); + const result = deepRemoveDefaults(schema).safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maybe).toBeUndefined(); + } + }); + + it('does not double-wrap an already optional field when stripping', () => { + const schema = z.object({ + value: z.string().optional().default('x'), + }); + const stripped = deepRemoveDefaults(schema); + const field = stripped.shape.value; + expect(field).toBeInstanceOf(z.ZodOptional); + expect(field.unwrap()).toBeInstanceOf(z.ZodString); + expect(stripped.parse({}).value).toBeUndefined(); + }); + + it('makes union-defaulted fields optional', () => { + const schema = z.object({ + x: z.union([z.string().default(''), z.number().default(0)]), + }); + const parsed = deepRemoveDefaults(schema).safeParse({}); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.x).toBeUndefined(); + } + }); + + it('keeps union fields required when no union option has defaults', () => { + const schema = z.object({ + x: z.union([z.string(), z.number()]), + }); + const parsed = deepRemoveDefaults(schema).safeParse({}); + expect(parsed.success).toBe(false); + }); + + it('detects defaults under nullable wrappers for optionalization', () => { + const schema = z.object({ + maybe: z.string().default('x').nullable(), + }); + const result = deepRemoveDefaults(schema).safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maybe).toBeUndefined(); + } + }); + + it('detects defaults under readonly wrappers for optionalization', () => { + const schema = z.object({ + readOnlyValue: z.string().default('x').readonly(), + }); + const result = deepRemoveDefaults(schema).safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.readOnlyValue).toBeUndefined(); + } + }); + + it('detects defaults under nonoptional wrappers for optionalization', () => { + const schema = z.object({ + strictValue: z.string().default('x').nonoptional(), + }); + const result = deepRemoveDefaults(schema).safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.strictValue).toBeUndefined(); + } + }); + + it('detects defaults under pipe wrappers for optionalization', () => { + const schema = z.object({ + piped: z.string().default('x').pipe(z.string().min(1)), + }); + const result = deepRemoveDefaults(schema).safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.piped).toBeUndefined(); + } + }); + }); + + describe('root wrapper stripping', () => { + it('strips a root default wrapper', () => { + const schema = z.string().default('foo'); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(undefined).success).toBe(false); + expect(stripped.parse('bar')).toBe('bar'); + }); + + it('strips a root prefault wrapper', () => { + const schema = z.string().prefault('foo'); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(undefined).success).toBe(false); + expect(stripped.parse('bar')).toBe('bar'); + }); + + it('returns leaf schemas as-is when no changes are needed', () => { + const schema = z.string().min(1); + const stripped = deepRemoveDefaults(schema); + expect(stripped).toBe(schema); + expect(stripped.safeParse('').success).toBe(false); + }); + }); + + describe('container schemas', () => { + it('strips defaults from array element schemas', () => { + const schema = z.array(z.string().default('foo')); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse([undefined]).success).toBe(false); + expect(stripped.safeParse(['ok']).success).toBe(true); + }); + + it('still enforces array length constraints', () => { + expect(deepRemoveDefaults(z.number().array().min(1)).safeParse([]).success).toBe( + false, + ); + expect( + deepRemoveDefaults(z.number().array().max(1)).safeParse([1, 2]).success, + ).toBe(false); + expect( + deepRemoveDefaults(z.number().array().length(1)).safeParse([]).success, + ).toBe(false); + }); + + it('strips defaults from tuple items when tuple has no rest', () => { + const schema = z.tuple([z.string().default('foo')]); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse([]).success).toBe(false); + expect(stripped.safeParse([undefined]).success).toBe(false); + expect(stripped.safeParse(['ok']).success).toBe(true); + }); + + it('strips defaults from tuple items and tuple rest', () => { + const schema = z.tuple([z.string().default('foo')], z.number().default(1)); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse([undefined]).success).toBe(false); + expect(stripped.safeParse(['ok', undefined]).success).toBe(false); + expect(stripped.safeParse(['ok', 2]).success).toBe(true); + }); + + it('strips defaults from all union options', () => { + const schema = z.union([z.string().default('foo'), z.number().default(1)]); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(undefined).success).toBe(false); + expect(stripped.safeParse('ok').success).toBe(true); + expect(stripped.safeParse(2).success).toBe(true); + }); + }); + + describe('recursive and cache behavior', () => { + it('handles lazy schemas while stripping nested defaults', () => { + const schema = z.lazy(() => + z.object({ + value: z.string().default('foo'), + }), + ); + const stripped = deepRemoveDefaults(schema); + const parsed = stripped.safeParse({}); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.value).toBeUndefined(); + } + }); + + it('handles getter-based recursive objects without stack overflow', () => { + const categorySchema = z.object({ + name: z.string().default('root'), + get children() { + return z.array(categorySchema).default([]); + }, + }); + + const stripped = deepRemoveDefaults(categorySchema); + const result = stripped.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBeUndefined(); + expect(result.data.children).toBeUndefined(); + } + + // Force recursive traversal so the forward-reference lazy callback is executed. + const nested = stripped.safeParse({ + children: [ + { + children: [{}], + }, + ], + }); + expect(nested.success).toBe(true); + }); + + it('handles self-referential lazy schemas in default detection', () => { + const self: z.ZodType = z.lazy(() => self); + const schema = z.object({ + node: self, + }); + + const stripped = deepRemoveDefaults(schema); + expect(stripped.shape.node).toBeInstanceOf(z.ZodLazy); + expect(stripped.shape.node).not.toBeInstanceOf(z.ZodOptional); + }); + + it('reuses cached child transforms for shared schema instances', () => { + const shared = z.string().default('foo'); + const schema = z.object({ + a: shared, + b: shared, + }); + + const stripped = deepRemoveDefaults(schema); + const a = stripped.shape.a; + const b = stripped.shape.b; + expect(a).toBeInstanceOf(z.ZodOptional); + expect(b).toBeInstanceOf(z.ZodOptional); + expect(a.unwrap()).toBe(b.unwrap()); + }); + }); + + describe('pipe and single-child wrappers', () => { + it('strips defaults from pipe output schemas', () => { + const schema = z.string().optional().pipe(z.string().default('bar')); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(undefined).success).toBe(false); + expect(stripped.safeParse('ok').success).toBe(true); + }); + + it('strips defaults inside optional wrappers', () => { + const schema = z.string().default('foo').optional(); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(undefined).success).toBe(true); + expect(stripped.safeParse('ok').success).toBe(true); + expect(stripped.safeParse(1).success).toBe(false); + }); + + it('strips defaults inside nullable wrappers', () => { + const schema = z.string().default('foo').nullable(); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(null).success).toBe(true); + expect(stripped.safeParse(undefined).success).toBe(false); + }); + + it('strips defaults inside readonly wrappers', () => { + const schema = z.string().default('foo').readonly(); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse('ok').success).toBe(true); + expect(stripped.safeParse(undefined).success).toBe(false); + }); + + it('strips defaults inside nonoptional wrappers', () => { + const schema = z.string().optional().default('foo').nonoptional(); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(undefined).success).toBe(false); + expect(stripped.safeParse('ok').success).toBe(true); + }); + + it('strips defaults inside catch wrappers', () => { + const schema = z.string().default('foo').catch('fallback'); + const stripped = deepRemoveDefaults(schema); + const parsed = stripped.safeParse(undefined); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data).toBe('fallback'); + } + }); + + it('strips defaults inside success wrappers', () => { + const schema = z.success(z.string().default('foo')); + const stripped = deepRemoveDefaults(schema); + expect(stripped.safeParse(undefined).success).toBe(false); + expect(stripped.parse('ok')).toBe(true); + }); + + it('strips defaults inside promise wrappers', async () => { + const schema = z.promise(z.string().default('foo')); + const stripped = deepRemoveDefaults(schema); + + const missing = await stripped.safeParseAsync(Promise.resolve(undefined)); + expect(missing.success).toBe(false); + + const present = await stripped.safeParseAsync(Promise.resolve('ok')); + expect(present.success).toBe(true); + if (present.success) { + expect(present.data).toBe('ok'); + } + }); + + it('throws when a wrapper unwraps to a non-classic schema value', () => { + const schema = z.string().nullable(); + Object.defineProperty(schema, 'unwrap', { + value: () => ({}) as unknown as z.core.$ZodType, + }); + + expect(() => deepRemoveDefaults(schema)).toThrowError( + 'deepRemoveDefaults supports full zod schemas only', + ); + }); + }); +}); diff --git a/tests/utils/zod/parse-errors.test.ts b/tests/utils/zod/parse-errors.test.ts new file mode 100644 index 00000000..f334ec77 --- /dev/null +++ b/tests/utils/zod/parse-errors.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { z, ZodError } from 'zod'; +import { getParseError, getParseErrorPaths } from '../../../src/utils/zod/parse-errors'; + +describe('getParseErrorPaths', () => { + it('should get error paths', () => { + const result = z + .object({ a: z.string(), b: z.number() }) + .safeParse({ a: 1, b: 'a' }); + if (result.success) return; + expect(getParseErrorPaths(result.error)).toEqual(new Set(['a', 'b'])); + }); + + it('should get nested error paths', () => { + const result = z + .object({ a: z.object({ b: z.string() }) }) + .safeParse({ a: { b: 1 } }); + if (result.success) return; + expect(getParseErrorPaths(result.error)).toEqual(new Set(['a.b'])); + }); + + it('should get array error paths', () => { + const result = z.array(z.string()).safeParse([1, 'a', 2]); + if (result.success) return; + expect(getParseErrorPaths(result.error)).toEqual(new Set(['[0]', '[2]'])); + }); + + it('should get complex nested error paths', () => { + const result = z + .object({ a: z.array(z.object({ b: z.string() })) }) + .safeParse({ a: [{ b: 1 }, { b: 'a' }, { b: 2 }] }); + if (result.success) return; + expect(getParseErrorPaths(result.error)).toEqual(new Set(['a[0].b', 'a[2].b'])); + }); +}); + +describe('getParseError', () => { + it('should get simple error paths', () => { + const result = z.object({ required: z.string() }).safeParse({}); + expect(result.success).toBeFalsy(); + if (result.success) { + return; + } + expect(getParseError(result.error)).toBe('[\n "required"\n]'); + }); + + it('should get union error paths', () => { + const type_one = z.object({ type: z.string(), data: z.string() }); + const type_two = z.object({ type: z.literal('two'), data: z.string() }); + + const schema = z.object({ + array: type_one.or(type_two).array(), + }); + + const result = schema.safeParse({ array: [{}] }); + expect(result.success).toBeFalsy(); + if (result.success) { + return; + } + expect(getParseError(result.error)).toBe( + '[\n "array[0].type",\n "array[0].data"\n]', + ); + }); + + it('should get root union error paths', () => { + const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]); + + const result = schema.safeParse({}); + expect(result.success).toBeFalsy(); + if (result.success) { + return; + } + + expect(getParseError(result.error)).toBe('[\n "a",\n "b"\n]'); + }); + + it('should get no paths for empty error', () => { + expect(getParseError(new ZodError([]))).toBeNull(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 60b45d3d..5a02454d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2550,7 +2550,7 @@ __metadata: vitest-mock-extended: "npm:^1.3.1" web-dialog: "npm:^0.0.11" xss: "npm:^1.0.15" - zod: "npm:^3.23.8" + zod: "npm:^4.3.6" languageName: unknown linkType: soft @@ -12227,9 +12227,9 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.23.8": - version: 3.23.8 - resolution: "zod@npm:3.23.8" - checksum: 10c0/8f14c87d6b1b53c944c25ce7a28616896319d95bc46a9660fe441adc0ed0a81253b02b5abdaeffedbeb23bdd25a0bf1c29d2c12dd919aef6447652dd295e3e69 +"zod@npm:^4.3.6": + version: 4.3.6 + resolution: "zod@npm:4.3.6" + checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307 languageName: node linkType: hard