diff --git a/src/card.ts b/src/card.ts index 05ce6225..5c940fe6 100644 --- a/src/card.ts +++ b/src/card.ts @@ -18,7 +18,6 @@ import throttle from 'lodash-es/throttle'; import screenfull from 'screenfull'; import { ViewContext } from 'view'; import 'web-dialog'; -import { z } from 'zod'; import pkg from '../package.json'; import { actionHandler } from './action-handler-directive.js'; import { AutomationsController } from './automations'; @@ -94,6 +93,7 @@ import { createViewWithSelectedSubstream, } from './utils/substream'; import { Timer } from './utils/timer'; +import { getParseErrorPaths } from './utils/zod.js'; import { View } from './view/view.js'; /** A note on media callbacks: @@ -341,67 +341,6 @@ class FrigateCard extends LitElement { return this._cameraManager.getStore().getCameraConfig(this._view.camera); } - /** - * Get configuration parse errors. - * @param error The ZodError object from parsing. - * @returns An array of string error paths. - */ - protected _getParseErrorPaths(error: z.ZodError): Set | null { - /* Zod errors involving unions are complex, as Zod may not be able to tell - * where the 'real' error is vs simply a union option not matching. This - * function finds all ZodError "issues" that don't have an error with 'type' - * in that object ('type' is the union discriminator for picture elements, - * the major union in the schema). An array of user-readable error - * locations is returned, or an empty list if none is available. None being - * available suggests the configuration has an error, but we can't tell - * exactly why (or rather Zod simply says it doesn't match any of the - * available unions). This usually suggests the user specified an incorrect - * type name entirely. */ - const contenders = new Set(); - if (error && error.issues) { - for (let i = 0; i < error.issues.length; i++) { - const issue = error.issues[i]; - if (issue.code == 'invalid_union') { - const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; - for (let j = 0; j < unionErrors.length; j++) { - const nestedErrors = this._getParseErrorPaths(unionErrors[j]); - if (nestedErrors && nestedErrors.size) { - nestedErrors.forEach(contenders.add, contenders); - } - } - } else if (issue.code == 'invalid_type') { - if (issue.path[issue.path.length - 1] == 'type') { - return null; - } - contenders.add(this._getParseErrorPathString(issue.path)); - } else if (issue.code != 'custom') { - contenders.add(this._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. - */ - protected _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; - } /** * Set the card configuration. @@ -415,7 +354,7 @@ class FrigateCard extends LitElement { const parseResult = frigateCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { const configUpgradeable = isConfigUpgradeable(inputConfig); - const hint = this._getParseErrorPaths(parseResult.error); + const hint = getParseErrorPaths(parseResult.error); let upgradeMessage = ''; if (configUpgradeable && getLovelace().mode !== 'yaml') { upgradeMessage = `${localize('error.upgrade_available')}. `; diff --git a/src/utils/zod.ts b/src/utils/zod.ts index 2298797c..d3d53e8b 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -56,3 +56,65 @@ export function getParseErrorKeys(error: z.ZodError): string[] { const errors = error.format(); return Object.keys(errors).filter((v) => !v.startsWith('_')); } + +/** + * Get configuration parse errors. + * @param error The ZodError object from parsing. + * @returns An array of string error paths. + */ +export const getParseErrorPaths = (error: z.ZodError): Set | null => { + /* Zod errors involving unions are complex, as Zod may not be able to tell + * where the 'real' error is vs simply a union option not matching. This + * function finds all ZodError "issues" that don't have an error with 'type' + * in that object ('type' is the union discriminator for picture elements, + * the major union in the schema). An array of user-readable error + * locations is returned, or an empty list if none is available. None being + * available suggests the configuration has an error, but we can't tell + * exactly why (or rather Zod simply says it doesn't match any of the + * available unions). This usually suggests the user specified an incorrect + * type name entirely. */ + const contenders = new Set(); + if (error && error.issues) { + for (let i = 0; i < error.issues.length; i++) { + const issue = error.issues[i]; + if (issue.code == 'invalid_union') { + const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; + for (let j = 0; j < unionErrors.length; j++) { + const nestedErrors = getParseErrorPaths(unionErrors[j]); + if (nestedErrors && nestedErrors.size) { + nestedErrors.forEach(contenders.add, contenders); + } + } + } else if (issue.code == 'invalid_type') { + if (issue.path[issue.path.length - 1] == 'type') { + return null; + } + contenders.add(getParseErrorPathString(issue.path)); + } else if (issue.code != 'custom') { + contenders.add(getParseErrorPathString(issue.path)); + } + } + } + return contenders; +}; + +/** + * Convert an array of strings and indices into a more user readable string, + * e.g. [a, 1, b, 2] => 'a[1] -> b[2]' + * @param path An array of strings and numbers. + * @returns A single string. + */ +const getParseErrorPathString = (path: (string | number)[]): string => { + let out = ''; + for (let i = 0; i < path.length; i++) { + const item = path[i]; + if (typeof item == 'number') { + out += '[' + item + ']'; + } else if (out) { + out += ' -> ' + item; + } else { + out = item; + } + } + return out; +}; diff --git a/tests/utils/zod.test.ts b/tests/utils/zod.test.ts new file mode 100644 index 00000000..0d55f48e --- /dev/null +++ b/tests/utils/zod.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { z } 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('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']), + ); + }); +});