refactor: Migrate config schema from Zod v3 to Zod v4 (#2357)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user