refactor: Migrate config schema from Zod v3 to Zod v4 (#2357)
This commit is contained in:
+1
-1
@@ -52,7 +52,7 @@
|
|||||||
"vis-util": "^5.0.7",
|
"vis-util": "^5.0.7",
|
||||||
"web-dialog": "^0.0.11",
|
"web-dialog": "^0.0.11",
|
||||||
"xss": "^1.0.15",
|
"xss": "^1.0.15",
|
||||||
"zod": "^3.23.8"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.7",
|
"@babel/core": "^7.24.7",
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ const outputEntryTemplate = {
|
|||||||
sourcemap: dev,
|
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}
|
* @type {import('rollup').RollupOptions}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export class CameraManager {
|
|||||||
// order, to ensure that the defaults in the cameras global config do not
|
// order, to ensure that the defaults in the cameras global config do not
|
||||||
// override the values specified in the per-camera config.
|
// override the values specified in the per-camera config.
|
||||||
const cameras = (config.cameras ?? []).map((camera) =>
|
const cameras = (config.cameras ?? []).map((camera) =>
|
||||||
recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
|
recursivelyMergeObjectsNotArrays(config?.cameras_global, camera),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isEqual } from 'lodash-es';
|
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 { setProfiles } from '../../config/profiles/set-profiles.js';
|
||||||
import {
|
import {
|
||||||
AdvancedCameraCardConfig,
|
AdvancedCameraCardConfig,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '../../config/schema/types.js';
|
} from '../../config/schema/types.js';
|
||||||
import { RawAdvancedCameraCardConfig } from '../../config/types.js';
|
import { RawAdvancedCameraCardConfig } from '../../config/types.js';
|
||||||
import { localize } from '../../localize/localize.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 { InitializationAspect } from '../initialization-manager.js';
|
||||||
import { CardConfigAPI } from '../types.js';
|
import { CardConfigAPI } from '../types.js';
|
||||||
import { setAutomationsFromConfig } from './load-automations.js';
|
import { setAutomationsFromConfig } from './load-automations.js';
|
||||||
@@ -64,7 +64,7 @@ export class ConfigManager {
|
|||||||
const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig);
|
const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
const configUpgradeable = isConfigUpgradeable(inputConfig);
|
const configUpgradeable = isConfigUpgradeable(inputConfig);
|
||||||
const hint = getParseErrorPaths(parseResult.error);
|
const hint = getParseError(parseResult.error);
|
||||||
let upgradeMessage = '';
|
let upgradeMessage = '';
|
||||||
if (configUpgradeable) {
|
if (configUpgradeable) {
|
||||||
upgradeMessage = `${localize('error.upgrade_available')}. `;
|
upgradeMessage = `${localize('error.upgrade_available')}. `;
|
||||||
@@ -72,12 +72,22 @@ export class ConfigManager {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
upgradeMessage +
|
upgradeMessage +
|
||||||
`${localize('error.invalid_configuration')}: ` +
|
`${localize('error.invalid_configuration')}: ` +
|
||||||
(hint && hint.size
|
(hint ?? localize('error.invalid_configuration_no_hint')),
|
||||||
? JSON.stringify([...hint], null, ' ')
|
|
||||||
: 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;
|
this._rawConfig = inputConfig;
|
||||||
if (isEqual(this._config, config)) {
|
if (isEqual(this._config, config)) {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class OverridesManager {
|
|||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
throw new OverrideConfigurationError(
|
throw new OverrideConfigurationError(
|
||||||
localize('error.invalid_configuration_override'),
|
localize('error.invalid_configuration_override'),
|
||||||
[parseResult.error.errors, output],
|
[parseResult.error.issues, output],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return parseResult.data;
|
return parseResult.data;
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity
|
|||||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||||
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
||||||
import { CameraConfig } from '../config/schema/cameras.js';
|
import { CameraConfig } from '../config/schema/cameras.js';
|
||||||
import { ImageMode } from '../config/schema/common/image.js';
|
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
|
||||||
import { ImageViewConfig } from '../config/schema/image.js';
|
|
||||||
import { isHassDifferent } from '../ha/is-hass-different.js';
|
import { isHassDifferent } from '../ha/is-hass-different.js';
|
||||||
import { HomeAssistant } from '../ha/types.js';
|
import { HomeAssistant } from '../ha/types.js';
|
||||||
import defaultImage from '../images/iris-screensaver.jpg';
|
import defaultImage from '../images/iris-screensaver.jpg';
|
||||||
@@ -43,7 +42,7 @@ import { renderMessage } from './message.js';
|
|||||||
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
export const resolveImageMode = (options?: {
|
export const resolveImageMode = (options?: {
|
||||||
imageConfig?: ImageViewConfig;
|
imageConfig?: ImageBaseConfig;
|
||||||
cameraConfig?: CameraConfig;
|
cameraConfig?: CameraConfig;
|
||||||
}): Exclude<ImageMode, 'auto'> => {
|
}): Exclude<ImageMode, 'auto'> => {
|
||||||
if (!options?.imageConfig?.mode) {
|
if (!options?.imageConfig?.mode) {
|
||||||
@@ -87,7 +86,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
// 'show_image_during_load' option is true for live views, an overridden
|
// 'show_image_during_load' option is true for live views, an overridden
|
||||||
// config may be used here).
|
// config may be used here).
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public imageConfig?: ImageViewConfig;
|
public imageConfig?: ImageBaseConfig;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _message: Message | null = null;
|
protected _message: Message | null = null;
|
||||||
|
|||||||
@@ -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 { getConfigValue, setConfigValue } from '../management.js';
|
||||||
import { ProfileType } from '../schema/profiles.js';
|
import { ProfileType } from '../schema/profiles.js';
|
||||||
import { advancedCameraCardConfigSchema } from '../schema/types.js';
|
import { advancedCameraCardConfigSchema } from '../schema/types.js';
|
||||||
@@ -31,7 +31,7 @@ export const setProfiles = <T extends RawAdvancedCameraCardConfig>(
|
|||||||
if (!defaultLessParseResult.success) {
|
if (!defaultLessParseResult.success) {
|
||||||
return outputConfig;
|
return outputConfig;
|
||||||
}
|
}
|
||||||
const defaultLessConfig = defaultLessParseResult.data;
|
const defaultLessConfig = defaultLessParseResult.data as RawAdvancedCameraCardConfig;
|
||||||
|
|
||||||
const setIfNotSpecified = (key: string, value: unknown) => {
|
const setIfNotSpecified = (key: string, value: unknown) => {
|
||||||
if (getConfigValue(defaultLessConfig, key) === undefined) {
|
if (getConfigValue(defaultLessConfig, key) === undefined) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const internalCallbackActionConfigSchema =
|
|||||||
advanced_camera_card_action: z.literal(INTERNAL_CALLBACK_ACTION),
|
advanced_camera_card_action: z.literal(INTERNAL_CALLBACK_ACTION),
|
||||||
|
|
||||||
// The callback is expected to be called with a CardController API object.
|
// 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<
|
export type InternalCallbackActionConfig = z.infer<
|
||||||
typeof internalCallbackActionConfigSchema
|
typeof internalCallbackActionConfigSchema
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { targetSchema } from './target';
|
|||||||
export const callServiceActionSchema = actionBaseSchema.extend({
|
export const callServiceActionSchema = actionBaseSchema.extend({
|
||||||
action: z.literal('call-service'),
|
action: z.literal('call-service'),
|
||||||
service: z.string(),
|
service: z.string(),
|
||||||
data: z.object({}).passthrough().optional(),
|
data: z.looseObject({}).optional(),
|
||||||
target: targetSchema.optional(),
|
target: targetSchema.optional(),
|
||||||
});
|
});
|
||||||
export type CallServiceActionConfig = z.infer<typeof callServiceActionSchema>;
|
export type CallServiceActionConfig = z.infer<typeof callServiceActionSchema>;
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export const customActionSchema = actionBaseSchema
|
|||||||
.extend({
|
.extend({
|
||||||
action: z.literal('fire-dom-event'),
|
action: z.literal('fire-dom-event'),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.loose();
|
||||||
export type CustomActionConfig = z.infer<typeof customActionSchema>;
|
export type CustomActionConfig = z.infer<typeof customActionSchema>;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { targetSchema } from './target';
|
|||||||
export const performActionActionSchema = actionBaseSchema.extend({
|
export const performActionActionSchema = actionBaseSchema.extend({
|
||||||
action: z.literal('perform-action'),
|
action: z.literal('perform-action'),
|
||||||
perform_action: z.string(),
|
perform_action: z.string(),
|
||||||
data: z.object({}).passthrough().optional(),
|
data: z.looseObject({}).optional(),
|
||||||
target: targetSchema.optional(),
|
target: targetSchema.optional(),
|
||||||
});
|
});
|
||||||
export type PerformActionActionConfig = z.infer<typeof performActionActionSchema>;
|
export type PerformActionActionConfig = z.infer<typeof performActionActionSchema>;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { HassServiceTarget } from 'home-assistant-js-websocket';
|
import { HassServiceTarget } from 'home-assistant-js-websocket';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const targetSchema: z.ZodSchema<HassServiceTarget, z.ZodTypeDef> = z.object({
|
export const targetSchema: z.ZodSchema<HassServiceTarget> = z.object({
|
||||||
entity_id: z.string().or(z.string().array()).optional(),
|
entity_id: z.union([z.string(), z.string().array()]).optional(),
|
||||||
device_id: z.string().or(z.string().array()).optional(),
|
device_id: z.union([z.string(), z.string().array()]).optional(),
|
||||||
area_id: z.string().or(z.string().array()).optional(),
|
area_id: z.union([z.string(), z.string().array()]).optional(),
|
||||||
floor_id: z.string().or(z.string().array()).optional(),
|
label_id: z.union([z.string(), z.string().array()]).optional(),
|
||||||
label_id: z.string().or(z.string().array()).optional(),
|
floor_id: z.union([z.string(), z.string().array()]).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,18 +29,15 @@ export type StatusBarActionConfig = z.infer<
|
|||||||
status_bar_action: 'add' | 'remove' | 'reset';
|
status_bar_action: 'add' | 'remove' | 'reset';
|
||||||
items?: StatusBarItem[];
|
items?: StatusBarItem[];
|
||||||
};
|
};
|
||||||
export const statusBarActionConfigSchema: z.ZodSchema<
|
export const statusBarActionConfigSchema: z.ZodSchema<StatusBarActionConfig> =
|
||||||
StatusBarActionConfig,
|
advancedCameraCardCustomActionsBaseSchema.extend({
|
||||||
z.ZodTypeDef,
|
advanced_camera_card_action: z.literal('status_bar'),
|
||||||
unknown
|
status_bar_action: z.enum(['add', 'remove', 'reset']),
|
||||||
> = advancedCameraCardCustomActionsBaseSchema.extend({
|
items: z
|
||||||
advanced_camera_card_action: z.literal('status_bar'),
|
.lazy(() => statusBarItemSchema)
|
||||||
status_bar_action: z.enum(['add', 'remove', 'reset']),
|
.array()
|
||||||
items: z
|
.optional(),
|
||||||
.lazy(() => statusBarItemSchema)
|
});
|
||||||
.array()
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const advancedCameraCardCustomActionSchema = z.union([
|
const advancedCameraCardCustomActionSchema = z.union([
|
||||||
cameraSelectActionConfigSchema,
|
cameraSelectActionConfigSchema,
|
||||||
@@ -69,7 +66,6 @@ export const actionConfigSchema = z.union([
|
|||||||
advancedCameraCardCustomActionSchema,
|
advancedCameraCardCustomActionSchema,
|
||||||
]);
|
]);
|
||||||
export type ActionConfig = z.infer<typeof actionConfigSchema>;
|
export type ActionConfig = z.infer<typeof actionConfigSchema>;
|
||||||
|
|
||||||
export const actionsBaseSchema = z
|
export const actionsBaseSchema = z
|
||||||
.object({
|
.object({
|
||||||
tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
|
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
|
// Passthrough to allow (at least) entity/camera_image to go through. This
|
||||||
// card doesn't need these attributes, but handleAction() in
|
// card doesn't need these attributes, but handleAction() in
|
||||||
// custom_card_helpers may depending on how the action is configured.
|
// custom_card_helpers may depending on how the action is configured.
|
||||||
.passthrough();
|
.loose();
|
||||||
export type Actions = z.infer<typeof actionsBaseSchema>;
|
export type Actions = z.infer<typeof actionsBaseSchema>;
|
||||||
|
|
||||||
export interface AuxillaryActionConfig {
|
export interface AuxillaryActionConfig {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const ptzCameraConfigSchema = z.preprocess(
|
|||||||
.preprocess(
|
.preprocess(
|
||||||
dataPTZFormatToFullFormat(''),
|
dataPTZFormatToFullFormat(''),
|
||||||
z.union([
|
z.union([
|
||||||
z.record(performActionActionSchema),
|
z.record(z.string(), performActionActionSchema),
|
||||||
|
|
||||||
// This is used by the data_ style of action.
|
// This is used by the data_ style of action.
|
||||||
z.object({ service: z.string().optional() }),
|
z.object({ service: z.string().optional() }),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { mediaLayoutConfigSchema } from './camera/media-layout';
|
|||||||
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
|
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
|
||||||
import { aspectRatioSchema } from './common/aspect-ratio';
|
import { aspectRatioSchema } from './common/aspect-ratio';
|
||||||
import { eventsMediaTypeSchema } from './common/events-media';
|
import { eventsMediaTypeSchema } from './common/events-media';
|
||||||
|
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
|
||||||
import { severitySchema } from './common/severity';
|
import { severitySchema } from './common/severity';
|
||||||
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
|
|
||||||
|
|
||||||
const CAMERA_TRIGGER_EVENT_TYPES = [
|
const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||||
// An event whether or not it has any media yet associated with it.
|
// An event whether or not it has any media yet associated with it.
|
||||||
@@ -56,7 +56,7 @@ const webrtcCardConfigSchema = z
|
|||||||
entity: z.string().optional(),
|
entity: z.string().optional(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.loose();
|
||||||
|
|
||||||
const jsmpegConfigSchema = z.object({
|
const jsmpegConfigSchema = z.object({
|
||||||
options: z
|
options: z
|
||||||
@@ -150,6 +150,8 @@ export const cameraConfigDefault = {
|
|||||||
ssl_ciphers: 'auto' as const,
|
ssl_ciphers: 'auto' as const,
|
||||||
ssl_verification: 'auto' as const,
|
ssl_verification: 'auto' as const,
|
||||||
},
|
},
|
||||||
|
go2rtc: go2rtcConfigDefault,
|
||||||
|
image: imageBaseConfigDefault,
|
||||||
always_error_if_entity_unavailable: false,
|
always_error_if_entity_unavailable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,7 +219,7 @@ const cameraMediaConfigSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const cameraConfigSchema = z
|
export const cameraConfigSchema = z
|
||||||
.object({
|
.looseObject({
|
||||||
camera_entity: z.string().optional(),
|
camera_entity: z.string().optional(),
|
||||||
|
|
||||||
// Used for presentation in the UI (autodetected from the entity if
|
// Used for presentation in the UI (autodetected from the entity if
|
||||||
@@ -318,7 +320,7 @@ export const cameraConfigSchema = z
|
|||||||
// Live provider options.
|
// Live provider options.
|
||||||
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
||||||
go2rtc: go2rtcConfigSchema.optional().default(go2rtcConfigDefault),
|
go2rtc: go2rtcConfigSchema.optional().default(go2rtcConfigDefault),
|
||||||
image: imageBaseConfigSchema.optional().default(imageConfigDefault),
|
image: imageBaseConfigSchema.optional().default(imageBaseConfigDefault),
|
||||||
jsmpeg: jsmpegConfigSchema.optional(),
|
jsmpeg: jsmpegConfigSchema.optional(),
|
||||||
webrtc_card: webrtcCardConfigSchema.optional(),
|
webrtc_card: webrtcCardConfigSchema.optional(),
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ export const ptzControlsConfigSchema = z.object({
|
|||||||
hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom),
|
hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom),
|
||||||
hide_home: z.boolean().default(ptzControlsDefaults.hide_home),
|
hide_home: z.boolean().default(ptzControlsDefaults.hide_home),
|
||||||
|
|
||||||
style: z.object({}).passthrough().optional(),
|
style: z.looseObject({}).optional(),
|
||||||
});
|
});
|
||||||
export type PTZControlsConfig = z.infer<typeof ptzControlsConfigSchema>;
|
export type PTZControlsConfig = z.infer<typeof ptzControlsConfigSchema>;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const THUMBNAIL_WIDTH_MIN = 75;
|
|||||||
export const THUMBNAIL_WIDTH_DEFAULT = 100;
|
export const THUMBNAIL_WIDTH_DEFAULT = 100;
|
||||||
export const THUMBNAIL_WIDTH_MAX = 300;
|
export const THUMBNAIL_WIDTH_MAX = 300;
|
||||||
|
|
||||||
const thumbnailControlsBaseDefaults = {
|
export const thumbnailControlsBaseDefaults = {
|
||||||
size: THUMBNAIL_WIDTH_DEFAULT,
|
size: THUMBNAIL_WIDTH_DEFAULT,
|
||||||
show_details: true,
|
show_details: true,
|
||||||
show_favorite_control: true,
|
show_favorite_control: true,
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const imageConfigDefault = {
|
export const imageBaseConfigDefault = {
|
||||||
mode: 'auto' as const,
|
mode: 'auto' as const,
|
||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const imageConfigDefault = {
|
||||||
|
...imageBaseConfigDefault,
|
||||||
zoomable: true,
|
zoomable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -10,11 +14,13 @@ const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const;
|
|||||||
export type ImageMode = (typeof IMAGE_MODES)[number];
|
export type ImageMode = (typeof IMAGE_MODES)[number];
|
||||||
|
|
||||||
export const imageBaseConfigSchema = z.object({
|
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(),
|
url: z.string().optional(),
|
||||||
entity: z.string().optional(),
|
entity: z.string().optional(),
|
||||||
entity_parameters: z.string().optional(),
|
entity_parameters: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type ImageBaseConfig = z.infer<typeof imageBaseConfigSchema>;
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ export const stockConditionSchema = z.discriminatedUnion('condition', [
|
|||||||
screenConditionSchema,
|
screenConditionSchema,
|
||||||
usersConditionSchema,
|
usersConditionSchema,
|
||||||
]);
|
]);
|
||||||
export type StockCondition = z.infer<typeof stockConditionSchema>;
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from 'zod';
|
|||||||
import { actionsBaseSchema } from '../actions/types';
|
import { actionsBaseSchema } from '../actions/types';
|
||||||
|
|
||||||
export const elementsBaseSchema = actionsBaseSchema.extend({
|
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(),
|
title: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ export const menuBaseSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
alignment: z.enum(['matching', 'opposing']).default('matching').optional(),
|
alignment: z.enum(['matching', 'opposing']).default('matching').optional(),
|
||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
|
state_color: z.boolean().default(true).optional(),
|
||||||
permanent: z.boolean().default(false).optional(),
|
permanent: z.boolean().default(false).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||||||
import { iconSchema } from '../../stock/icon';
|
import { iconSchema } from '../../stock/icon';
|
||||||
import { menuBaseSchema } from './base';
|
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'),
|
type: z.literal('custom:advanced-camera-card-menu-icon'),
|
||||||
});
|
});
|
||||||
export type MenuIcon = z.infer<typeof menuIconSchema>;
|
export type MenuIcon = z.infer<typeof menuIconSchema>;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { stateIconSchema } from '../../stock/state-icon';
|
|||||||
import { menuBaseSchema } from './base';
|
import { menuBaseSchema } from './base';
|
||||||
|
|
||||||
export const menuStateIconSchema = menuBaseSchema
|
export const menuStateIconSchema = menuBaseSchema
|
||||||
.merge(stateIconSchema)
|
.extend(stateIconSchema.shape)
|
||||||
.extend({
|
.extend({
|
||||||
type: z.literal('custom:advanced-camera-card-menu-state-icon'),
|
type: z.literal('custom:advanced-camera-card-menu-state-icon'),
|
||||||
})
|
})
|
||||||
.merge(menuBaseSchema);
|
.extend(menuBaseSchema.shape);
|
||||||
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
|
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { stateIconSchema } from '../../stock/state-icon';
|
|||||||
import { menuBaseSchema } from './base';
|
import { menuBaseSchema } from './base';
|
||||||
import { menuSubmenuItemSchema } from './submenu';
|
import { menuSubmenuItemSchema } from './submenu';
|
||||||
|
|
||||||
export const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({
|
export const menuSubmenuSelectSchema = menuBaseSchema
|
||||||
type: z.literal('custom:advanced-camera-card-menu-submenu-select'),
|
.extend(stateIconSchema.shape)
|
||||||
options: z.record(menuSubmenuItemSchema.deepPartial()).optional(),
|
.extend({
|
||||||
});
|
type: z.literal('custom:advanced-camera-card-menu-submenu-select'),
|
||||||
|
options: z.record(z.string(), menuSubmenuItemSchema.partial()).optional(),
|
||||||
|
});
|
||||||
export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
|
export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const menuSubmenuItemSchema = elementsBaseSchema.extend({
|
|||||||
});
|
});
|
||||||
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
|
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
|
||||||
|
|
||||||
export const menuSubmenuSchema = menuBaseSchema.merge(iconSchema).extend({
|
export const menuSubmenuSchema = menuBaseSchema.extend(iconSchema.shape).extend({
|
||||||
type: z.literal('custom:advanced-camera-card-menu-submenu'),
|
type: z.literal('custom:advanced-camera-card-menu-submenu'),
|
||||||
items: menuSubmenuItemSchema.array(),
|
items: menuSubmenuItemSchema.array(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ export const customSchema = z
|
|||||||
type: z.string().superRefine((val, ctx) => {
|
type: z.string().superRefine((val, ctx) => {
|
||||||
if (!val.match(/^custom:(?!advanced-camera-card).+/)) {
|
if (!val.match(/^custom:(?!advanced-camera-card).+/)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: 'custom',
|
||||||
message: 'advanced-camera-card custom elements must match specific schemas',
|
message: 'advanced-camera-card custom elements must match specific schemas',
|
||||||
fatal: true,
|
fatal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.loose();
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export const imageSchema = elementsBaseSchema.extend({
|
|||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
camera_image: z.string().optional(),
|
camera_image: z.string().optional(),
|
||||||
camera_view: z.string().optional(),
|
camera_view: z.string().optional(),
|
||||||
state_image: z.object({}).passthrough().optional(),
|
state_image: z.looseObject({}).optional(),
|
||||||
filter: z.string().optional(),
|
filter: z.string().optional(),
|
||||||
state_filter: z.object({}).passthrough().optional(),
|
state_filter: z.looseObject({}).optional(),
|
||||||
aspect_ratio: z.string().optional(),
|
aspect_ratio: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export const serviceCallButtonSchema = elementsBaseSchema.extend({
|
|||||||
// Title is required for service button.
|
// Title is required for service button.
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
service: z.string(),
|
service: z.string(),
|
||||||
service_data: z.object({}).passthrough().optional(),
|
service_data: z.looseObject({}).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import {
|
|||||||
statusBarImageItemSchema,
|
statusBarImageItemSchema,
|
||||||
statusBarStringItemSchema,
|
statusBarStringItemSchema,
|
||||||
} from '../actions/types';
|
} from '../actions/types';
|
||||||
import { StockCondition, stockConditionSchema } from '../conditions/stock/types';
|
import { stockConditionSchema } from '../conditions/stock/types';
|
||||||
import {
|
import { advancedCameraCardConditionSchema } from '../conditions/types';
|
||||||
AdvancedCameraCardCondition,
|
|
||||||
advancedCameraCardConditionSchema,
|
|
||||||
} from '../conditions/types';
|
|
||||||
import { menuIconSchema } from './custom/menu/icon';
|
import { menuIconSchema } from './custom/menu/icon';
|
||||||
import { menuStateIconSchema } from './custom/menu/state-icon';
|
import { menuStateIconSchema } from './custom/menu/state-icon';
|
||||||
import { menuSubmenuSchema } from './custom/menu/submenu';
|
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
|
// include other elements. Putting these elements elsewhere would cause
|
||||||
// typescript circular dependency errors as the types need to be both included
|
// typescript circular dependency errors as the types need to be both included
|
||||||
// in the master pictureElementSchema, but also refer to it internally.
|
// 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
|
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
|
||||||
type Conditional = {
|
export const conditionalSchema = z.object({
|
||||||
type: 'conditional';
|
|
||||||
conditions: StockCondition[];
|
|
||||||
elements?: PictureElements;
|
|
||||||
};
|
|
||||||
export const conditionalSchema: z.ZodSchema<Conditional, z.ZodTypeDef> = z.object({
|
|
||||||
type: z.literal('conditional'),
|
type: z.literal('conditional'),
|
||||||
conditions: stockConditionSchema.array(),
|
conditions: stockConditionSchema.array(),
|
||||||
elements: z.lazy(() => pictureElementsSchema),
|
|
||||||
|
get elements() {
|
||||||
|
// Recursive schema.
|
||||||
|
return pictureElementsSchema;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AdvancedCameraCardConditional = {
|
const advancedCameraCardConditionalSchema = z.object({
|
||||||
type: 'custom:advanced-camera-card-conditional';
|
|
||||||
conditions: AdvancedCameraCardCondition[];
|
|
||||||
elements?: PictureElements;
|
|
||||||
};
|
|
||||||
const advancedCameraCardConditionalSchema: z.ZodSchema<
|
|
||||||
AdvancedCameraCardConditional,
|
|
||||||
z.ZodTypeDef
|
|
||||||
> = z.object({
|
|
||||||
type: z.literal('custom:advanced-camera-card-conditional'),
|
type: z.literal('custom:advanced-camera-card-conditional'),
|
||||||
conditions: advancedCameraCardConditionSchema.array(),
|
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
|
// Cannot use discriminatedUnion since customSchema uses a superRefine, which
|
||||||
// causes false rejections.
|
// causes false rejections.
|
||||||
@@ -77,5 +68,5 @@ const pictureElementSchema = z.union([
|
|||||||
statusBarStringItemSchema,
|
statusBarStringItemSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const pictureElementsSchema = pictureElementSchema.array().optional();
|
export const pictureElementsSchema = pictureElementSchema.array().optional().default([]);
|
||||||
export type PictureElements = z.infer<typeof pictureElementsSchema>;
|
export type PictureElements = z.infer<typeof pictureElementsSchema>;
|
||||||
|
|||||||
@@ -60,14 +60,15 @@ const titleMatcherSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type TitleMatcher = z.infer<typeof titleMatcherSchema>;
|
export type TitleMatcher = z.infer<typeof titleMatcherSchema>;
|
||||||
|
|
||||||
type OrMatcher = {
|
const orMatcherSchema = z.object({
|
||||||
type: 'or';
|
|
||||||
matchers: Matcher[];
|
|
||||||
};
|
|
||||||
const orMatcherSchema: z.ZodSchema<OrMatcher, z.ZodTypeDef> = z.object({
|
|
||||||
type: z.literal('or'),
|
type: z.literal('or'),
|
||||||
matchers: z.array(z.lazy(() => matcherSchema)),
|
|
||||||
|
get matchers() {
|
||||||
|
// Recursive schema.
|
||||||
|
return z.array(matcherSchema);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const matcherSchema = z.union([
|
export const matcherSchema = z.union([
|
||||||
dateMatcherSchema,
|
dateMatcherSchema,
|
||||||
orMatcherSchema,
|
orMatcherSchema,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const imageConfigSchema = imageBaseConfigSchema
|
|||||||
.extend({
|
.extend({
|
||||||
zoomable: z.boolean().default(imageConfigDefault.zoomable),
|
zoomable: z.boolean().default(imageConfigDefault.zoomable),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema)
|
.extend(actionsSchema.shape)
|
||||||
.default(imageConfigDefault);
|
.default(imageConfigDefault);
|
||||||
|
|
||||||
export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export const liveConfigDefault = {
|
|||||||
zoomable: true,
|
zoomable: true,
|
||||||
transition_effect: 'slide' as const,
|
transition_effect: 'slide' as const,
|
||||||
show_image_during_load: true,
|
show_image_during_load: true,
|
||||||
mode: 'single' as const,
|
|
||||||
controls: {
|
controls: {
|
||||||
builtin: true,
|
builtin: true,
|
||||||
next_previous: {
|
next_previous: {
|
||||||
@@ -126,6 +125,6 @@ export const liveConfigSchema = z
|
|||||||
),
|
),
|
||||||
zoomable: z.boolean().default(liveConfigDefault.zoomable),
|
zoomable: z.boolean().default(liveConfigDefault.zoomable),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema)
|
.extend(actionsSchema.shape)
|
||||||
.default(liveConfigDefault);
|
.default(liveConfigDefault);
|
||||||
export type LiveConfig = z.infer<typeof liveConfigSchema>;
|
export type LiveConfig = z.infer<typeof liveConfigSchema>;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { actionsSchema } from './actions/types';
|
import { actionsSchema } from './actions/types';
|
||||||
import {
|
import {
|
||||||
thumbnailControlsDefaults,
|
thumbnailControlsBaseDefaults,
|
||||||
thumbnailsControlBaseSchema,
|
thumbnailsControlBaseSchema,
|
||||||
} from './common/controls/thumbnails';
|
} from './common/controls/thumbnails';
|
||||||
|
|
||||||
const mediaGalleryThumbnailControlsDefaults = {
|
const mediaGalleryThumbnailControlsDefaults = {
|
||||||
...thumbnailControlsDefaults,
|
...thumbnailControlsBaseDefaults,
|
||||||
show_details: false,
|
show_details: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +43,6 @@ export const mediaGalleryConfigSchema = z
|
|||||||
})
|
})
|
||||||
.default(mediaGalleryConfigDefault.controls),
|
.default(mediaGalleryConfigDefault.controls),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema)
|
.extend(actionsSchema.shape)
|
||||||
.default(mediaGalleryConfigDefault);
|
.default(mediaGalleryConfigDefault);
|
||||||
export type MediaGalleryConfig = z.infer<typeof mediaGalleryConfigSchema>;
|
export type MediaGalleryConfig = z.infer<typeof mediaGalleryConfigSchema>;
|
||||||
|
|||||||
+36
-27
@@ -13,13 +13,20 @@ const MENU_STYLES = [
|
|||||||
const MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const;
|
const MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const;
|
||||||
const MENU_ALIGNMENTS = MENU_POSITIONS;
|
const MENU_ALIGNMENTS = MENU_POSITIONS;
|
||||||
|
|
||||||
const visibleButtonDefault = {
|
const baseButtonDefault = {
|
||||||
|
alignment: 'matching' as const,
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
priority: MENU_PRIORITY_DEFAULT,
|
priority: MENU_PRIORITY_DEFAULT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleButtonDefault = {
|
||||||
|
...baseButtonDefault,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hiddenButtonDefault = {
|
const hiddenButtonDefault = {
|
||||||
priority: MENU_PRIORITY_DEFAULT,
|
...baseButtonDefault,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,35 +34,37 @@ export const menuConfigDefault = {
|
|||||||
alignment: 'left' as const,
|
alignment: 'left' as const,
|
||||||
button_size: 40,
|
button_size: 40,
|
||||||
buttons: {
|
buttons: {
|
||||||
camera_ui: visibleButtonDefault,
|
// Clone per key so each button has its own default object. This avoids
|
||||||
cameras: visibleButtonDefault,
|
// shared nested default references between keys.
|
||||||
clips: hiddenButtonDefault,
|
camera_ui: { ...visibleButtonDefault },
|
||||||
ptz_home: hiddenButtonDefault,
|
cameras: { ...visibleButtonDefault },
|
||||||
display_mode: visibleButtonDefault,
|
clips: { ...hiddenButtonDefault },
|
||||||
download: visibleButtonDefault,
|
ptz_home: { ...hiddenButtonDefault },
|
||||||
expand: hiddenButtonDefault,
|
display_mode: { ...visibleButtonDefault },
|
||||||
folders: visibleButtonDefault,
|
download: { ...visibleButtonDefault },
|
||||||
iris: visibleButtonDefault,
|
expand: { ...hiddenButtonDefault },
|
||||||
fullscreen: visibleButtonDefault,
|
folders: { ...visibleButtonDefault },
|
||||||
image: hiddenButtonDefault,
|
iris: { ...visibleButtonDefault },
|
||||||
info: visibleButtonDefault,
|
fullscreen: { ...visibleButtonDefault },
|
||||||
gallery: visibleButtonDefault,
|
image: { ...hiddenButtonDefault },
|
||||||
live: visibleButtonDefault,
|
info: { ...visibleButtonDefault },
|
||||||
media_player: visibleButtonDefault,
|
gallery: { ...visibleButtonDefault },
|
||||||
|
live: { ...visibleButtonDefault },
|
||||||
|
media_player: { ...visibleButtonDefault },
|
||||||
microphone: {
|
microphone: {
|
||||||
...hiddenButtonDefault,
|
...hiddenButtonDefault,
|
||||||
type: 'momentary' as const,
|
type: 'momentary' as const,
|
||||||
},
|
},
|
||||||
mute: hiddenButtonDefault,
|
mute: { ...hiddenButtonDefault },
|
||||||
play: hiddenButtonDefault,
|
play: { ...hiddenButtonDefault },
|
||||||
ptz_controls: hiddenButtonDefault,
|
ptz_controls: { ...hiddenButtonDefault },
|
||||||
recordings: hiddenButtonDefault,
|
recordings: { ...hiddenButtonDefault },
|
||||||
reviews: hiddenButtonDefault,
|
reviews: { ...hiddenButtonDefault },
|
||||||
set_review: visibleButtonDefault,
|
set_review: { ...visibleButtonDefault },
|
||||||
screenshot: hiddenButtonDefault,
|
screenshot: { ...hiddenButtonDefault },
|
||||||
snapshots: hiddenButtonDefault,
|
snapshots: { ...hiddenButtonDefault },
|
||||||
substreams: visibleButtonDefault,
|
substreams: { ...visibleButtonDefault },
|
||||||
timeline: visibleButtonDefault,
|
timeline: { ...visibleButtonDefault },
|
||||||
},
|
},
|
||||||
position: 'top' as const,
|
position: 'top' as const,
|
||||||
style: 'hidden' as const,
|
style: 'hidden' as const,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { advancedCameraCardConditionSchema } from './conditions/types';
|
|||||||
|
|
||||||
const overrideSchema = z.object({
|
const overrideSchema = z.object({
|
||||||
conditions: advancedCameraCardConditionSchema.array(),
|
conditions: advancedCameraCardConditionSchema.array(),
|
||||||
merge: z.object({}).passthrough().optional(),
|
merge: z.looseObject({}).optional(),
|
||||||
set: z.object({}).passthrough().optional(),
|
set: z.looseObject({}).optional(),
|
||||||
delete: z.string().array().optional(),
|
delete: z.string().array().optional(),
|
||||||
});
|
});
|
||||||
export type Override = z.infer<typeof overrideSchema>;
|
export type Override = z.infer<typeof overrideSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { deepRemoveDefaults } from '../../utils/zod';
|
import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults';
|
||||||
import { automationsSchema } from './automations';
|
import { automationsSchema } from './automations';
|
||||||
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
|
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
|
||||||
import { cardIDRegex } from './common/const';
|
import { cardIDRegex } from './common/const';
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export type PTZKeyboardShortcutName =
|
|||||||
| 'ptz_zoom_in'
|
| 'ptz_zoom_in'
|
||||||
| 'ptz_zoom_out';
|
| 'ptz_zoom_out';
|
||||||
|
|
||||||
|
const interactionModeDefault = 'inactive' as const;
|
||||||
|
|
||||||
export const viewConfigDefault = {
|
export const viewConfigDefault = {
|
||||||
default: 'auto' as const,
|
default: 'auto' as const,
|
||||||
camera_select: 'current' as const,
|
camera_select: 'current' as const,
|
||||||
@@ -53,7 +55,7 @@ export const viewConfigDefault = {
|
|||||||
every_seconds: 0,
|
every_seconds: 0,
|
||||||
after_interaction: false,
|
after_interaction: false,
|
||||||
entities: [],
|
entities: [],
|
||||||
interaction_mode: 'inactive' as const,
|
interaction_mode: interactionModeDefault,
|
||||||
},
|
},
|
||||||
default_cycle_camera: false,
|
default_cycle_camera: false,
|
||||||
dim: false,
|
dim: false,
|
||||||
@@ -64,6 +66,7 @@ export const viewConfigDefault = {
|
|||||||
show_trigger_status: false,
|
show_trigger_status: false,
|
||||||
filter_selected_camera: true,
|
filter_selected_camera: true,
|
||||||
actions: {
|
actions: {
|
||||||
|
interaction_mode: interactionModeDefault,
|
||||||
trigger: 'update' as const,
|
trigger: 'update' as const,
|
||||||
untrigger: 'none' as const,
|
untrigger: 'none' as const,
|
||||||
},
|
},
|
||||||
@@ -73,7 +76,9 @@ export const viewConfigDefault = {
|
|||||||
keyboard_shortcuts: keyboardShortcutsDefault,
|
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<typeof interactionModeSchema>;
|
export type InteractionMode = z.infer<typeof interactionModeSchema>;
|
||||||
|
|
||||||
export const triggersSchema = z.object({
|
export const triggersSchema = z.object({
|
||||||
@@ -108,7 +113,7 @@ export type ThemeName = z.infer<typeof themeName>;
|
|||||||
|
|
||||||
const themeConfigSchema = z.object({
|
const themeConfigSchema = z.object({
|
||||||
themes: themeName.array().default(viewConfigDefault.theme.themes),
|
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<typeof themeConfigSchema>;
|
export type ThemeConfig = z.infer<typeof themeConfigSchema>;
|
||||||
|
|
||||||
@@ -128,9 +133,7 @@ export const viewConfigSchema = z
|
|||||||
.default(viewConfigDefault.default_reset.after_interaction),
|
.default(viewConfigDefault.default_reset.after_interaction),
|
||||||
every_seconds: z.number().default(viewConfigDefault.default_reset.every_seconds),
|
every_seconds: z.number().default(viewConfigDefault.default_reset.every_seconds),
|
||||||
entities: z.string().array().default(viewConfigDefault.default_reset.entities),
|
entities: z.string().array().default(viewConfigDefault.default_reset.entities),
|
||||||
interaction_mode: interactionModeSchema.default(
|
interaction_mode: interactionModeSchema,
|
||||||
viewConfigDefault.default_reset.interaction_mode,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.default(viewConfigDefault.default_reset),
|
.default(viewConfigDefault.default_reset),
|
||||||
|
|
||||||
@@ -144,5 +147,5 @@ export const viewConfigSchema = z
|
|||||||
viewConfigDefault.keyboard_shortcuts,
|
viewConfigDefault.keyboard_shortcuts,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema)
|
.extend(actionsSchema.shape)
|
||||||
.default(viewConfigDefault);
|
.default(viewConfigDefault);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const viewerConfigDefault = {
|
|||||||
zoomable: true,
|
zoomable: true,
|
||||||
transition_effect: 'slide' as const,
|
transition_effect: 'slide' as const,
|
||||||
snapshot_click_plays_clip: true,
|
snapshot_click_plays_clip: true,
|
||||||
display_mode: 'single' as const,
|
|
||||||
controls: {
|
controls: {
|
||||||
builtin: true,
|
builtin: true,
|
||||||
next_previous: {
|
next_previous: {
|
||||||
@@ -106,6 +105,6 @@ export const viewerConfigSchema = z
|
|||||||
})
|
})
|
||||||
.default(viewerConfigDefault.controls),
|
.default(viewerConfigDefault.controls),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema)
|
.extend(actionsSchema.shape)
|
||||||
.default(viewerConfigDefault);
|
.default(viewerConfigDefault);
|
||||||
export type ViewerConfig = z.infer<typeof viewerConfigSchema>;
|
export type ViewerConfig = z.infer<typeof viewerConfigSchema>;
|
||||||
|
|||||||
@@ -7,36 +7,24 @@ export interface BrowseMediaMetadata {
|
|||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
what?: string[];
|
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
|
// 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 {
|
get children() {
|
||||||
title: string;
|
// Recursive schema.
|
||||||
media_class: string;
|
return z.array(browseMediaSchema).nullable().optional();
|
||||||
media_content_type: string;
|
},
|
||||||
media_content_id: string;
|
});
|
||||||
can_play: boolean;
|
export type BrowseMedia = z.infer<typeof browseMediaSchema>;
|
||||||
can_expand: boolean;
|
|
||||||
children_media_class?: string | null;
|
|
||||||
thumbnail: string | null;
|
|
||||||
children?: BrowseMedia[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const browseMediaSchema: z.ZodSchema<BrowseMedia> = 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(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface RichBrowseMedia<M> extends BrowseMedia {
|
export interface RichBrowseMedia<M> extends BrowseMedia {
|
||||||
_metadata?: M;
|
_metadata?: M;
|
||||||
|
|||||||
+6
-6
@@ -247,16 +247,16 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
|
|||||||
return children.filter(isHTMLElement);
|
return children.filter(isHTMLElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recursivelyMergeObjectsNotArrays = <T>(target: T, src1: T, src2: T): T => {
|
export const recursivelyMergeObjectsNotArrays = <T>(
|
||||||
return mergeWith(target, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined));
|
...srcs: (Partial<T> | undefined | null)[]
|
||||||
|
): T => {
|
||||||
|
return mergeWith({}, ...srcs, (_a, b) => (Array.isArray(b) ? b : undefined));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recursivelyMergeObjectsConcatenatingArraysUniquely = <T>(
|
export const recursivelyMergeObjectsConcatenatingArraysUniquely = <T>(
|
||||||
target: T,
|
...srcs: (Partial<T> | undefined | null)[]
|
||||||
src1: T,
|
|
||||||
src2: T,
|
|
||||||
): T => {
|
): T => {
|
||||||
return mergeWith(target, src1, src2, (a, b) =>
|
return mergeWith({}, ...srcs, (a, b) =>
|
||||||
Array.isArray(a) ? uniq(a.concat(b)) : undefined,
|
Array.isArray(a) ? uniq(a.concat(b)) : undefined,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<T extends z.ZodTypeAny>(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<T>(error: z.ZodError<T>): 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 = <T>(error: z.ZodError<T>): Set<string> => {
|
|
||||||
/* 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<string>();
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@@ -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<z.ZodType>()): 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, z.ZodType>): 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<string, z.core.$ZodType> = {};
|
||||||
|
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<T extends z.ZodType>(
|
||||||
|
schema: T,
|
||||||
|
cache = new Map<z.ZodType, z.ZodType>(),
|
||||||
|
): T {
|
||||||
|
return strip(schema, cache) as T;
|
||||||
|
}
|
||||||
@@ -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 = <T>(error: z.ZodError<T>): Set<string> => {
|
||||||
|
/* 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<string>();
|
||||||
|
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 = <T>(error: z.ZodError<T>): string | null => {
|
||||||
|
const paths = getParseErrorPaths(error);
|
||||||
|
return paths.size === 0 ? null : JSON.stringify([...paths], null, ' ');
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ZodError } from 'zod';
|
|
||||||
import { AutomationsManager } from '../../../src/card-controller/automations-manager';
|
import { AutomationsManager } from '../../../src/card-controller/automations-manager';
|
||||||
import { ConfigManager } from '../../../src/card-controller/config/config-manager';
|
import { ConfigManager } from '../../../src/card-controller/config/config-manager';
|
||||||
import { InitializationAspect } from '../../../src/card-controller/initialization-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 { advancedCameraCardConfigSchema } from '../../../src/config/schema/types';
|
||||||
import { createGeneralAction } from '../../../src/utils/action';
|
import { createGeneralAction } from '../../../src/utils/action';
|
||||||
import { createCardAPI, createConfig, flushPromises } from '../../test-utils';
|
import { createCardAPI, createConfig, flushPromises } from '../../test-utils';
|
||||||
|
import { ZodError, z } from 'zod';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager.
|
* Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager.
|
||||||
@@ -63,6 +63,7 @@ const TEST_CONDITIONS = {
|
|||||||
|
|
||||||
/** Profile settings for testing */
|
/** Profile settings for testing */
|
||||||
const TEST_PROFILES = {
|
const TEST_PROFILES = {
|
||||||
|
CASTING: 'casting' as const,
|
||||||
LOW_PERFORMANCE: 'low-performance' as const,
|
LOW_PERFORMANCE: 'low-performance' as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -78,10 +79,10 @@ describe('ConfigManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('invalid configuration', () => {
|
it('invalid configuration', () => {
|
||||||
const spy = vi.spyOn(advancedCameraCardConfigSchema, 'safeParse').mockReturnValue({
|
const schemaForMock: z.ZodType = advancedCameraCardConfigSchema;
|
||||||
success: false,
|
const spy = vi
|
||||||
error: new ZodError([]),
|
.spyOn(schemaForMock, 'safeParse')
|
||||||
});
|
.mockReturnValue({ success: false, error: new ZodError([]) });
|
||||||
|
|
||||||
const manager = new ConfigManager(createCardAPI());
|
const manager = new ConfigManager(createCardAPI());
|
||||||
expect(() => manager.setConfig({})).toThrowError(
|
expect(() => manager.setConfig({})).toThrowError(
|
||||||
@@ -136,7 +137,7 @@ describe('ConfigManager', () => {
|
|||||||
expect(manager.getRawConfig()).toBe(config);
|
expect(manager.getRawConfig()).toBe(config);
|
||||||
|
|
||||||
// Verify at least the camera is set.
|
// 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.
|
// Verify at least one default was set.
|
||||||
expect(manager.getConfig()?.menu.alignment).toBe('left');
|
expect(manager.getConfig()?.menu.alignment).toBe('left');
|
||||||
@@ -169,6 +170,23 @@ describe('ConfigManager', () => {
|
|||||||
expect(manager.getConfig()?.live.draggable).toBeFalsy();
|
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', () => {
|
it('should skip identical configs', () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const manager = new ConfigManager(api);
|
const manager = new ConfigManager(api);
|
||||||
@@ -234,6 +252,10 @@ describe('ConfigManager', () => {
|
|||||||
const config = {
|
const config = {
|
||||||
type: 'custom:advanced-camera-card',
|
type: 'custom:advanced-camera-card',
|
||||||
cameras: cameras,
|
cameras: cameras,
|
||||||
|
menu: {
|
||||||
|
style: 'hidden',
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
conditions: [TEST_CONDITIONS.FULLSCREEN_ON],
|
conditions: [TEST_CONDITIONS.FULLSCREEN_ON],
|
||||||
@@ -247,10 +269,11 @@ describe('ConfigManager', () => {
|
|||||||
|
|
||||||
manager.setConfig(config);
|
manager.setConfig(config);
|
||||||
|
|
||||||
expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1);
|
const configBefore = manager.getConfig();
|
||||||
|
|
||||||
stateManager.setState({ fullscreen: true });
|
stateManager.setState({ fullscreen: true });
|
||||||
|
const configAfter = manager.getConfig();
|
||||||
|
|
||||||
|
expect(configAfter).toEqual(configBefore);
|
||||||
expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1);
|
expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller);
|
const buttons = calculateButtons(controller);
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
icon: 'iris',
|
icon: 'iris',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
permanent: true,
|
permanent: true,
|
||||||
@@ -115,6 +117,8 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
icon: 'iris',
|
icon: 'iris',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
permanent: true,
|
permanent: true,
|
||||||
@@ -146,6 +150,9 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:video-switch',
|
icon: 'mdi:video-switch',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -258,6 +265,9 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:video-input-component',
|
icon: 'mdi:video-input-component',
|
||||||
style: {},
|
style: {},
|
||||||
title: 'Substream(s)',
|
title: 'Substream(s)',
|
||||||
@@ -299,6 +309,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:video-input-component',
|
icon: 'mdi:video-input-component',
|
||||||
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
|
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
|
||||||
title: 'Substream(s)',
|
title: 'Substream(s)',
|
||||||
@@ -360,6 +373,9 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:video-input-component',
|
icon: 'mdi:video-input-component',
|
||||||
title: 'Substream(s)',
|
title: 'Substream(s)',
|
||||||
style: {},
|
style: {},
|
||||||
@@ -452,6 +468,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:video-input-component',
|
icon: 'mdi:video-input-component',
|
||||||
title: 'Substream(s)',
|
title: 'Substream(s)',
|
||||||
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
|
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
|
||||||
@@ -515,6 +534,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:cctv',
|
icon: 'mdi:cctv',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -534,6 +556,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:cctv',
|
icon: 'mdi:cctv',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -569,6 +594,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:filmstrip',
|
icon: 'mdi:filmstrip',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -588,6 +616,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:filmstrip',
|
icon: 'mdi:filmstrip',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -622,6 +653,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:camera',
|
icon: 'mdi:camera',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -647,6 +681,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:camera',
|
icon: 'mdi:camera',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -689,6 +726,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:play-box-edit-outline',
|
icon: 'mdi:play-box-edit-outline',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -714,6 +754,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:play-box-edit-outline',
|
icon: 'mdi:play-box-edit-outline',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -754,6 +797,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:play-box-multiple',
|
icon: 'mdi:play-box-multiple',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -779,6 +825,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:play-box-multiple',
|
icon: 'mdi:play-box-multiple',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -819,6 +868,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:album',
|
icon: 'mdi:album',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -844,6 +896,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:album',
|
icon: 'mdi:album',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -887,6 +942,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:image',
|
icon: 'mdi:image',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -907,6 +965,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:image',
|
icon: 'mdi:image',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -942,6 +1003,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:chart-gantt',
|
icon: 'mdi:chart-gantt',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -964,6 +1028,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:chart-gantt',
|
icon: 'mdi:chart-gantt',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1016,6 +1083,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:download',
|
icon: 'mdi:download',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1084,6 +1154,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:web',
|
icon: 'mdi:web',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1114,6 +1187,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:microphone',
|
icon: 'mdi:microphone',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1171,6 +1247,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:microphone-message-off',
|
icon: 'mdi:microphone-message-off',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1200,6 +1279,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:microphone-off',
|
icon: 'mdi:microphone-off',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1237,6 +1319,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:microphone-message-off',
|
icon: 'mdi:microphone-message-off',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1269,6 +1354,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:microphone-off',
|
icon: 'mdi:microphone-off',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1305,6 +1393,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:microphone',
|
icon: 'mdi:microphone',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1338,6 +1429,9 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller, { fullscreenManager });
|
const buttons = calculateButtons(controller, { fullscreenManager });
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:fullscreen',
|
icon: 'mdi:fullscreen',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1360,6 +1454,9 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller, { fullscreenManager });
|
const buttons = calculateButtons(controller, { fullscreenManager });
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:fullscreen-exit',
|
icon: 'mdi:fullscreen-exit',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1392,6 +1489,9 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller, { inExpandedMode: false });
|
const buttons = calculateButtons(controller, { inExpandedMode: false });
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:arrow-expand-all',
|
icon: 'mdi:arrow-expand-all',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1406,6 +1506,9 @@ describe('MenuButtonController', () => {
|
|||||||
const buttons = calculateButtons(controller, { inExpandedMode: true });
|
const buttons = calculateButtons(controller, { inExpandedMode: true });
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:arrow-collapse-all',
|
icon: 'mdi:arrow-collapse-all',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1444,6 +1547,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:cast',
|
icon: 'mdi:cast',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1497,6 +1603,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:cast',
|
icon: 'mdi:cast',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1528,6 +1637,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:pause',
|
icon: 'mdi:pause',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1550,6 +1662,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:play',
|
icon: 'mdi:play',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1571,6 +1686,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:volume-high',
|
icon: 'mdi:volume-high',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1593,6 +1711,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:volume-off',
|
icon: 'mdi:volume-off',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1610,6 +1731,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:monitor-screenshot',
|
icon: 'mdi:monitor-screenshot',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1646,6 +1770,9 @@ describe('MenuButtonController', () => {
|
|||||||
expect(
|
expect(
|
||||||
calculateButtons(controller, { cameraManager: cameraManager, view: view }),
|
calculateButtons(controller, { cameraManager: cameraManager, view: view }),
|
||||||
).toContainEqual({
|
).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
|
icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1727,6 +1854,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
icon: 'mdi:pan',
|
icon: 'mdi:pan',
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1761,6 +1891,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
icon: 'mdi:pan',
|
icon: 'mdi:pan',
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1793,6 +1926,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
icon: 'mdi:pan',
|
icon: 'mdi:pan',
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1834,6 +1970,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
icon: 'mdi:pan',
|
icon: 'mdi:pan',
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1904,6 +2043,9 @@ describe('MenuButtonController', () => {
|
|||||||
|
|
||||||
if (expectedResult) {
|
if (expectedResult) {
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
icon: 'mdi:home',
|
icon: 'mdi:home',
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1955,6 +2097,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:folder',
|
icon: 'mdi:folder',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -1988,6 +2133,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:folder',
|
icon: 'mdi:folder',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -2027,6 +2175,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:folder-multiple',
|
icon: 'mdi:folder-multiple',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
@@ -2090,6 +2241,9 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(buttons).toContainEqual({
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
icon: 'mdi:folder-multiple',
|
icon: 'mdi:folder-multiple',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { PerformActionActionConfig } from '../../src/config/schema/actions/stock
|
|||||||
import { Actions } from '../../src/config/schema/actions/types.js';
|
import { Actions } from '../../src/config/schema/actions/types.js';
|
||||||
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
|
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
|
||||||
import { RawAdvancedCameraCardConfig } from '../../src/config/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', () => {
|
describe('general functions', () => {
|
||||||
it('should set value', () => {
|
it('should set value', () => {
|
||||||
|
|||||||
+131
-26
@@ -45,8 +45,8 @@ describe('config defaults', () => {
|
|||||||
dynamic: true,
|
dynamic: true,
|
||||||
live: 'auto',
|
live: 'auto',
|
||||||
media: 'auto',
|
media: 'auto',
|
||||||
ssl_verification: 'auto',
|
|
||||||
ssl_ciphers: 'auto',
|
ssl_ciphers: 'auto',
|
||||||
|
ssl_verification: 'auto',
|
||||||
},
|
},
|
||||||
ptz: {
|
ptz: {
|
||||||
c2r_delay_between_calls_seconds: 0.2,
|
c2r_delay_between_calls_seconds: 0.2,
|
||||||
@@ -56,13 +56,13 @@ describe('config defaults', () => {
|
|||||||
media_resolution: 'low',
|
media_resolution: 'low',
|
||||||
},
|
},
|
||||||
triggers: {
|
triggers: {
|
||||||
events: [],
|
|
||||||
entities: [],
|
entities: [],
|
||||||
|
events: [],
|
||||||
motion: false,
|
motion: false,
|
||||||
occupancy: false,
|
occupancy: false,
|
||||||
reviews: {
|
reviews: {
|
||||||
severities: ['high'],
|
|
||||||
description: true,
|
description: true,
|
||||||
|
severities: ['high'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -74,10 +74,11 @@ describe('config defaults', () => {
|
|||||||
aspect_ratio_mode: 'dynamic',
|
aspect_ratio_mode: 'dynamic',
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
},
|
},
|
||||||
|
elements: [],
|
||||||
image: {
|
image: {
|
||||||
zoomable: true,
|
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
|
zoomable: true,
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
auto_mute: ['unselected', 'hidden', 'microphone'],
|
auto_mute: ['unselected', 'hidden', 'microphone'],
|
||||||
@@ -203,109 +204,187 @@ describe('config defaults', () => {
|
|||||||
button_size: 40,
|
button_size: 40,
|
||||||
buttons: {
|
buttons: {
|
||||||
camera_ui: {
|
camera_ui: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
cameras: {
|
cameras: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
clips: {
|
clips: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
display_mode: {
|
display_mode: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
expand: {
|
expand: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
folders: {
|
folders: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
fullscreen: {
|
fullscreen: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
gallery: {
|
gallery: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
iris: {
|
iris: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
media_player: {
|
media_player: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
microphone: {
|
microphone: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
type: 'momentary',
|
type: 'momentary',
|
||||||
},
|
},
|
||||||
mute: {
|
mute: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
play: {
|
play: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
ptz_controls: {
|
ptz_controls: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
ptz_home: {
|
ptz_home: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
recordings: {
|
recordings: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
reviews: {
|
reviews: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
},
|
state_color: true,
|
||||||
set_review: {
|
|
||||||
enabled: true,
|
|
||||||
priority: 50,
|
|
||||||
},
|
},
|
||||||
screenshot: {
|
screenshot: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
|
},
|
||||||
|
set_review: {
|
||||||
|
alignment: 'matching',
|
||||||
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
snapshots: {
|
snapshots: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
substreams: {
|
substreams: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
timeline: {
|
timeline: {
|
||||||
|
alignment: 'matching',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
position: 'top',
|
position: 'top',
|
||||||
@@ -382,6 +461,15 @@ describe('config defaults', () => {
|
|||||||
view: {
|
view: {
|
||||||
camera_select: 'current',
|
camera_select: 'current',
|
||||||
default: 'auto',
|
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: {
|
keyboard_shortcuts: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
ptz_down: {
|
ptz_down: {
|
||||||
@@ -410,24 +498,15 @@ describe('config defaults', () => {
|
|||||||
themes: ['traditional'],
|
themes: ['traditional'],
|
||||||
},
|
},
|
||||||
triggers: {
|
triggers: {
|
||||||
|
actions: {
|
||||||
|
interaction_mode: 'inactive',
|
||||||
|
trigger: 'update',
|
||||||
|
untrigger: 'none',
|
||||||
|
},
|
||||||
|
filter_selected_camera: true,
|
||||||
show_trigger_status: false,
|
show_trigger_status: false,
|
||||||
untrigger_delay_seconds: 0,
|
untrigger_delay_seconds: 0,
|
||||||
untrigger_force_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',
|
type: 'custom:advanced-camera-card-menu-icon',
|
||||||
icon: 'mdi:cat',
|
alignment: 'matching',
|
||||||
|
enabled: true,
|
||||||
entity: 'camera.kitchen',
|
entity: 'camera.kitchen',
|
||||||
|
icon: 'mdi:cat',
|
||||||
|
permanent: false,
|
||||||
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'custom:advanced-camera-card-menu-state-icon',
|
type: 'custom:advanced-camera-card-menu-state-icon',
|
||||||
|
alignment: 'matching',
|
||||||
|
enabled: true,
|
||||||
entity: 'camera.kitchen',
|
entity: 'camera.kitchen',
|
||||||
icon: 'mdi:sheep',
|
icon: 'mdi:sheep',
|
||||||
|
permanent: false,
|
||||||
|
priority: 50,
|
||||||
state_color: false,
|
state_color: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -589,6 +677,7 @@ describe('config defaults', () => {
|
|||||||
icon: 'mdi:car',
|
icon: 'mdi:car',
|
||||||
permanent: false,
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
style: {
|
style: {
|
||||||
color: 'white',
|
color: 'white',
|
||||||
},
|
},
|
||||||
@@ -646,6 +735,7 @@ describe('config defaults', () => {
|
|||||||
],
|
],
|
||||||
permanent: false,
|
permanent: false,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
style: {
|
style: {
|
||||||
color: 'white',
|
color: 'white',
|
||||||
},
|
},
|
||||||
@@ -669,7 +759,10 @@ describe('config defaults', () => {
|
|||||||
title: 'Cooking time!',
|
title: 'Cooking time!',
|
||||||
},
|
},
|
||||||
'scene.kitchen_tv_scene': {
|
'scene.kitchen_tv_scene': {
|
||||||
|
enabled: true,
|
||||||
icon: 'mdi:television',
|
icon: 'mdi:television',
|
||||||
|
selected: false,
|
||||||
|
state_color: true,
|
||||||
title: 'TV!',
|
title: 'TV!',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1369,6 +1462,11 @@ describe('should lazy evaluate schemas', () => {
|
|||||||
status_bar_action: 'reset',
|
status_bar_action: 'reset',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
|
enabled: true,
|
||||||
|
exclusive: false,
|
||||||
|
expand: false,
|
||||||
|
priority: 50,
|
||||||
|
sufficient: false,
|
||||||
type: 'custom:advanced-camera-card-status-bar-string',
|
type: 'custom:advanced-camera-card-status-bar-string',
|
||||||
string: 'Item',
|
string: 'Item',
|
||||||
},
|
},
|
||||||
@@ -1379,13 +1477,20 @@ describe('should lazy evaluate schemas', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('should handle custom advanced camera card elements', () => {
|
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', () => {
|
it('should add custom error on advanced camera card element', () => {
|
||||||
const result = customSchema.safeParse({
|
const result = customSchema.safeParse({
|
||||||
type: 'custom:advanced-camera-card-foo',
|
type: 'custom:advanced-camera-card-foo',
|
||||||
});
|
});
|
||||||
expect(result.success).toBeFalsy();
|
expect(result.success).toBeFalsy();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(result.error.errors[0]).toEqual({
|
expect(result.error.issues[0]).toEqual({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
message: 'advanced-camera-card custom elements must match specific schemas',
|
message: 'advanced-camera-card custom elements must match specific schemas',
|
||||||
fatal: true,
|
fatal: true,
|
||||||
@@ -1429,7 +1534,7 @@ it('should strip trailing slashes from go2rtc url', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(config).toBeTruthy();
|
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', () => {
|
it('media viewer should not support microphone based conditions', () => {
|
||||||
|
|||||||
@@ -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());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2550,7 +2550,7 @@ __metadata:
|
|||||||
vitest-mock-extended: "npm:^1.3.1"
|
vitest-mock-extended: "npm:^1.3.1"
|
||||||
web-dialog: "npm:^0.0.11"
|
web-dialog: "npm:^0.0.11"
|
||||||
xss: "npm:^1.0.15"
|
xss: "npm:^1.0.15"
|
||||||
zod: "npm:^3.23.8"
|
zod: "npm:^4.3.6"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
@@ -12227,9 +12227,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"zod@npm:^3.23.8":
|
"zod@npm:^4.3.6":
|
||||||
version: 3.23.8
|
version: 4.3.6
|
||||||
resolution: "zod@npm:3.23.8"
|
resolution: "zod@npm:4.3.6"
|
||||||
checksum: 10c0/8f14c87d6b1b53c944c25ce7a28616896319d95bc46a9660fe441adc0ed0a81253b02b5abdaeffedbeb23bdd25a0bf1c29d2c12dd919aef6447652dd295e3e69
|
checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|||||||
Reference in New Issue
Block a user