refactor: Migrate config schema from Zod v3 to Zod v4 (#2357)

This commit is contained in:
Dermot Duffy
2026-02-18 21:47:43 -08:00
committed by GitHub
parent a15376602f
commit 529500ed94
51 changed files with 1110 additions and 443 deletions
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ZodError } from 'zod';
import { AutomationsManager } from '../../../src/card-controller/automations-manager';
import { ConfigManager } from '../../../src/card-controller/config/config-manager';
import { InitializationAspect } from '../../../src/card-controller/initialization-manager';
@@ -9,6 +8,7 @@ import { AdvancedCameraCardCondition } from '../../../src/config/schema/conditio
import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types';
import { createGeneralAction } from '../../../src/utils/action';
import { createCardAPI, createConfig, flushPromises } from '../../test-utils';
import { ZodError, z } from 'zod';
/**
* Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager.
@@ -63,6 +63,7 @@ const TEST_CONDITIONS = {
/** Profile settings for testing */
const TEST_PROFILES = {
CASTING: 'casting' as const,
LOW_PERFORMANCE: 'low-performance' as const,
} as const;
@@ -78,10 +79,10 @@ describe('ConfigManager', () => {
});
it('invalid configuration', () => {
const spy = vi.spyOn(advancedCameraCardConfigSchema, 'safeParse').mockReturnValue({
success: false,
error: new ZodError([]),
});
const schemaForMock: z.ZodType = advancedCameraCardConfigSchema;
const spy = vi
.spyOn(schemaForMock, 'safeParse')
.mockReturnValue({ success: false, error: new ZodError([]) });
const manager = new ConfigManager(createCardAPI());
expect(() => manager.setConfig({})).toThrowError(
@@ -136,7 +137,7 @@ describe('ConfigManager', () => {
expect(manager.getRawConfig()).toBe(config);
// Verify at least the camera is set.
expect(manager.getConfig()?.cameras[0].camera_entity).toBe('camera.office');
expect(manager.getConfig()?.cameras?.[0].camera_entity).toBe('camera.office');
// Verify at least one default was set.
expect(manager.getConfig()?.menu.alignment).toBe('left');
@@ -169,6 +170,23 @@ describe('ConfigManager', () => {
expect(manager.getConfig()?.live.draggable).toBeFalsy();
});
it('should apply casting profile menu changes without affecting unrelated hidden buttons', () => {
const manager = new ConfigManager(createCardAPI());
const config = {
type: 'custom:advanced-camera-card',
cameras: [TEST_CAMERAS.OFFICE],
profiles: [TEST_PROFILES.CASTING],
};
manager.setConfig(config);
expect(manager.getConfig()?.menu.buttons.play.enabled).toBeTruthy();
expect(manager.getConfig()?.menu.buttons.mute.enabled).toBeTruthy();
expect(manager.getConfig()?.menu.buttons.fullscreen.enabled).toBeFalsy();
expect(manager.getConfig()?.menu.buttons.media_player.enabled).toBeFalsy();
expect(manager.getConfig()?.menu.buttons.clips.enabled).toBeFalsy();
});
it('should skip identical configs', () => {
const api = createCardAPI();
const manager = new ConfigManager(api);
@@ -234,6 +252,10 @@ describe('ConfigManager', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: cameras,
menu: {
style: 'hidden',
position: 'top',
},
overrides: [
{
conditions: [TEST_CONDITIONS.FULLSCREEN_ON],
@@ -247,10 +269,11 @@ describe('ConfigManager', () => {
manager.setConfig(config);
expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1);
const configBefore = manager.getConfig();
stateManager.setState({ fullscreen: true });
const configAfter = manager.getConfig();
expect(configAfter).toEqual(configBefore);
expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1);
});
@@ -98,6 +98,8 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller);
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
icon: 'iris',
enabled: true,
permanent: true,
@@ -115,6 +117,8 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
icon: 'iris',
enabled: true,
permanent: true,
@@ -146,6 +150,9 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:video-switch',
enabled: true,
priority: 50,
@@ -258,6 +265,9 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:video-input-component',
style: {},
title: 'Substream(s)',
@@ -299,6 +309,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:video-input-component',
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
title: 'Substream(s)',
@@ -360,6 +373,9 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:video-input-component',
title: 'Substream(s)',
style: {},
@@ -452,6 +468,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:video-input-component',
title: 'Substream(s)',
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
@@ -515,6 +534,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:cctv',
enabled: true,
priority: 50,
@@ -534,6 +556,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:cctv',
enabled: true,
priority: 50,
@@ -569,6 +594,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:filmstrip',
enabled: false,
priority: 50,
@@ -588,6 +616,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:filmstrip',
enabled: false,
priority: 50,
@@ -622,6 +653,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:camera',
enabled: false,
priority: 50,
@@ -647,6 +681,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:camera',
enabled: false,
priority: 50,
@@ -689,6 +726,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:play-box-edit-outline',
enabled: false,
priority: 50,
@@ -714,6 +754,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:play-box-edit-outline',
enabled: false,
priority: 50,
@@ -754,6 +797,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:play-box-multiple',
enabled: true,
priority: 50,
@@ -779,6 +825,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:play-box-multiple',
enabled: true,
priority: 50,
@@ -819,6 +868,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:album',
enabled: false,
priority: 50,
@@ -844,6 +896,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:album',
enabled: false,
priority: 50,
@@ -887,6 +942,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:image',
enabled: false,
priority: 50,
@@ -907,6 +965,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:image',
enabled: false,
priority: 50,
@@ -942,6 +1003,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:chart-gantt',
enabled: true,
priority: 50,
@@ -964,6 +1028,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:chart-gantt',
enabled: true,
priority: 50,
@@ -1016,6 +1083,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:download',
enabled: true,
priority: 50,
@@ -1084,6 +1154,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:web',
enabled: true,
priority: 50,
@@ -1114,6 +1187,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:microphone',
enabled: false,
priority: 50,
@@ -1171,6 +1247,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:microphone-message-off',
enabled: false,
priority: 50,
@@ -1200,6 +1279,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:microphone-off',
enabled: false,
priority: 50,
@@ -1237,6 +1319,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:microphone-message-off',
enabled: false,
priority: 50,
@@ -1269,6 +1354,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:microphone-off',
enabled: false,
priority: 50,
@@ -1305,6 +1393,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:microphone',
enabled: false,
priority: 50,
@@ -1338,6 +1429,9 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller, { fullscreenManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:fullscreen',
enabled: true,
priority: 50,
@@ -1360,6 +1454,9 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller, { fullscreenManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:fullscreen-exit',
enabled: true,
priority: 50,
@@ -1392,6 +1489,9 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller, { inExpandedMode: false });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:arrow-expand-all',
enabled: false,
priority: 50,
@@ -1406,6 +1506,9 @@ describe('MenuButtonController', () => {
const buttons = calculateButtons(controller, { inExpandedMode: true });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:arrow-collapse-all',
enabled: false,
priority: 50,
@@ -1444,6 +1547,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:cast',
enabled: true,
priority: 50,
@@ -1497,6 +1603,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:cast',
enabled: true,
priority: 50,
@@ -1528,6 +1637,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:pause',
enabled: false,
priority: 50,
@@ -1550,6 +1662,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:play',
enabled: false,
priority: 50,
@@ -1571,6 +1686,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:volume-high',
enabled: false,
priority: 50,
@@ -1593,6 +1711,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:volume-off',
enabled: false,
priority: 50,
@@ -1610,6 +1731,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:monitor-screenshot',
enabled: false,
priority: 50,
@@ -1646,6 +1770,9 @@ describe('MenuButtonController', () => {
expect(
calculateButtons(controller, { cameraManager: cameraManager, view: view }),
).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
enabled: true,
priority: 50,
@@ -1727,6 +1854,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
enabled: false,
icon: 'mdi:pan',
priority: 50,
@@ -1761,6 +1891,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
enabled: false,
icon: 'mdi:pan',
priority: 50,
@@ -1793,6 +1926,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
enabled: false,
icon: 'mdi:pan',
priority: 50,
@@ -1834,6 +1970,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
enabled: false,
icon: 'mdi:pan',
priority: 50,
@@ -1904,6 +2043,9 @@ describe('MenuButtonController', () => {
if (expectedResult) {
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
enabled: false,
icon: 'mdi:home',
priority: 50,
@@ -1955,6 +2097,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:folder',
enabled: true,
priority: 50,
@@ -1988,6 +2133,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:folder',
enabled: true,
priority: 50,
@@ -2027,6 +2175,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:folder-multiple',
enabled: true,
priority: 50,
@@ -2090,6 +2241,9 @@ describe('MenuButtonController', () => {
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:folder-multiple',
enabled: true,
priority: 50,
+1 -1
View File
@@ -23,7 +23,7 @@ import { PerformActionActionConfig } from '../../src/config/schema/actions/stock
import { Actions } from '../../src/config/schema/actions/types.js';
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
import { RawAdvancedCameraCardConfig } from '../../src/config/types.js';
import { getParseErrorPaths } from '../../src/utils/zod.js';
import { getParseErrorPaths } from '../../src/utils/zod/parse-errors.js';
describe('general functions', () => {
it('should set value', () => {
+131 -26
View File
@@ -45,8 +45,8 @@ describe('config defaults', () => {
dynamic: true,
live: 'auto',
media: 'auto',
ssl_verification: 'auto',
ssl_ciphers: 'auto',
ssl_verification: 'auto',
},
ptz: {
c2r_delay_between_calls_seconds: 0.2,
@@ -56,13 +56,13 @@ describe('config defaults', () => {
media_resolution: 'low',
},
triggers: {
events: [],
entities: [],
events: [],
motion: false,
occupancy: false,
reviews: {
severities: ['high'],
description: true,
severities: ['high'],
},
},
},
@@ -74,10 +74,11 @@ describe('config defaults', () => {
aspect_ratio_mode: 'dynamic',
height: 'auto',
},
elements: [],
image: {
zoomable: true,
mode: 'auto',
refresh_seconds: 1,
zoomable: true,
},
live: {
auto_mute: ['unselected', 'hidden', 'microphone'],
@@ -203,109 +204,187 @@ describe('config defaults', () => {
button_size: 40,
buttons: {
camera_ui: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
cameras: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
clips: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
display_mode: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
download: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
expand: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
folders: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
fullscreen: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
gallery: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
image: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
info: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
iris: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
live: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
media_player: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
microphone: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
type: 'momentary',
},
mute: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
play: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
ptz_controls: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
ptz_home: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
recordings: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
reviews: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
},
set_review: {
enabled: true,
priority: 50,
state_color: true,
},
screenshot: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
set_review: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
snapshots: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
substreams: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
timeline: {
alignment: 'matching',
enabled: true,
permanent: false,
priority: 50,
state_color: true,
},
},
position: 'top',
@@ -382,6 +461,15 @@ describe('config defaults', () => {
view: {
camera_select: 'current',
default: 'auto',
default_cycle_camera: false,
default_reset: {
after_interaction: false,
entities: [],
every_seconds: 0,
interaction_mode: 'inactive',
},
dim: false,
interaction_seconds: 300,
keyboard_shortcuts: {
enabled: true,
ptz_down: {
@@ -410,24 +498,15 @@ describe('config defaults', () => {
themes: ['traditional'],
},
triggers: {
actions: {
interaction_mode: 'inactive',
trigger: 'update',
untrigger: 'none',
},
filter_selected_camera: true,
show_trigger_status: false,
untrigger_delay_seconds: 0,
untrigger_force_seconds: 0,
actions: {
trigger: 'update',
untrigger: 'none',
interaction_mode: 'inactive',
},
filter_selected_camera: true,
},
interaction_seconds: 300,
dim: false,
default_cycle_camera: false,
default_reset: {
after_interaction: false,
every_seconds: 0,
entities: [],
interaction_mode: 'inactive',
},
},
});
@@ -457,13 +536,22 @@ describe('config defaults', () => {
},
{
type: 'custom:advanced-camera-card-menu-icon',
icon: 'mdi:cat',
alignment: 'matching',
enabled: true,
entity: 'camera.kitchen',
icon: 'mdi:cat',
permanent: false,
priority: 50,
state_color: true,
},
{
type: 'custom:advanced-camera-card-menu-state-icon',
alignment: 'matching',
enabled: true,
entity: 'camera.kitchen',
icon: 'mdi:sheep',
permanent: false,
priority: 50,
state_color: false,
},
{
@@ -589,6 +677,7 @@ describe('config defaults', () => {
icon: 'mdi:car',
permanent: false,
priority: 50,
state_color: true,
style: {
color: 'white',
},
@@ -646,6 +735,7 @@ describe('config defaults', () => {
],
permanent: false,
priority: 50,
state_color: true,
style: {
color: 'white',
},
@@ -669,7 +759,10 @@ describe('config defaults', () => {
title: 'Cooking time!',
},
'scene.kitchen_tv_scene': {
enabled: true,
icon: 'mdi:television',
selected: false,
state_color: true,
title: 'TV!',
},
},
@@ -1369,6 +1462,11 @@ describe('should lazy evaluate schemas', () => {
status_bar_action: 'reset',
items: [
{
enabled: true,
exclusive: false,
expand: false,
priority: 50,
sufficient: false,
type: 'custom:advanced-camera-card-status-bar-string',
string: 'Item',
},
@@ -1379,13 +1477,20 @@ describe('should lazy evaluate schemas', () => {
});
describe('should handle custom advanced camera card elements', () => {
it('should reject non-custom element types', () => {
const result = customSchema.safeParse({
type: 'foo',
});
expect(result.success).toBeFalsy();
});
it('should add custom error on advanced camera card element', () => {
const result = customSchema.safeParse({
type: 'custom:advanced-camera-card-foo',
});
expect(result.success).toBeFalsy();
if (!result.success) {
expect(result.error.errors[0]).toEqual({
expect(result.error.issues[0]).toEqual({
code: 'custom',
message: 'advanced-camera-card custom elements must match specific schemas',
fatal: true,
@@ -1429,7 +1534,7 @@ it('should strip trailing slashes from go2rtc url', () => {
],
});
expect(config).toBeTruthy();
expect(config.cameras[0].go2rtc.url).toBe('https://my-custom-go2rtc');
expect(config.cameras?.[0].go2rtc.url).toBe('https://my-custom-go2rtc');
});
it('media viewer should not support microphone based conditions', () => {
-111
View File
@@ -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',
);
});
});
});
+80
View File
@@ -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();
});
});