From b37040d2d3ec853c93805b737bf95388cd2208aa Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Fri, 11 Jun 2021 20:32:46 +0300 Subject: [PATCH] refactor: Deep cloning of state, deep equal checker --- src/Formulario.ts | 14 +-- src/FormularioField.vue | 64 ++++------- src/FormularioFieldGroup.vue | 10 +- src/FormularioForm.vue | 176 ++++++++++++------------------ src/PathRegistry.ts | 71 ------------ src/types.ts | 64 +++++++++-- src/utils/clone.ts | 30 ++--- src/utils/compare.ts | 82 ++++++++++++++ src/utils/id.ts | 10 ++ src/utils/index.ts | 3 +- src/utils/shallowEquals.ts | 42 ------- src/validation/rules.ts | 4 +- src/validation/validator.ts | 2 - test/unit/FormularioField.test.js | 1 - test/unit/PathRegistry.test.js | 50 --------- test/unit/utils/compare.test.js | 125 +++++++++++++++++++++ 16 files changed, 395 insertions(+), 353 deletions(-) delete mode 100644 src/PathRegistry.ts create mode 100644 src/utils/compare.ts create mode 100644 src/utils/id.ts delete mode 100644 src/utils/shallowEquals.ts delete mode 100644 test/unit/PathRegistry.test.js create mode 100644 test/unit/utils/compare.test.js diff --git a/src/Formulario.ts b/src/Formulario.ts index 1e7f4c8..8aa49f0 100644 --- a/src/Formulario.ts +++ b/src/Formulario.ts @@ -7,10 +7,10 @@ import { ValidationRuleFn, ValidationMessageFn, ValidationMessageI18NFn, - ViolationsRecord, + Violation, } from '@/validation/validator' -import { FormularioFormInterface } from '@/types' +import { FormularioForm } from '@/types' export interface FormularioOptions { validationRules?: Record; @@ -24,7 +24,7 @@ export default class Formulario { public validationRules: Record = {} public validationMessages: Record = {} - private readonly registry: Map + private readonly registry: Map public constructor (options?: FormularioOptions) { this.registry = new Map() @@ -47,12 +47,12 @@ export default class Formulario { throw new Error(`[Formulario]: Formulario.extend(): should be passed an object (was ${typeof extendWith})`) } - public runValidation (id: string): Promise { + public runValidation (id: string): Promise> { if (!this.registry.has(id)) { throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`) } - const form = this.registry.get(id) as FormularioFormInterface + const form = this.registry.get(id) as FormularioForm return form.runValidation() } @@ -62,7 +62,7 @@ export default class Formulario { return } - const form = this.registry.get(id) as FormularioFormInterface + const form = this.registry.get(id) as FormularioForm form.resetValidation() } @@ -71,7 +71,7 @@ export default class Formulario { * Used by forms instances to add themselves into a registry * @internal */ - public register (id: string, form: FormularioFormInterface): void { + public register (id: string, form: FormularioForm): void { if (this.registry.has(id)) { throw new Error(`[Formulario]: Formulario.register(): id "${id}" is already in use`) } diff --git a/src/FormularioField.vue b/src/FormularioField.vue index 586a1b1..6351007 100644 --- a/src/FormularioField.vue +++ b/src/FormularioField.vue @@ -13,7 +13,7 @@ import { Prop, Watch, } from 'vue-property-decorator' -import { has, shallowEquals, snakeToCamel } from './utils' +import { deepEquals, has, snakeToCamel } from './utils' import { processConstraints, validate, @@ -87,35 +87,18 @@ export default class FormularioField extends Vue { return has(this.$options.propsData || {}, 'value') } - public get model (): unknown { - return this.modelGetConverter(this.hasModel ? this.value : this.proxy) - } - - public set model (value: unknown) { - value = this.modelSetConverter(value, this.proxy) - - if (!shallowEquals(value, this.proxy)) { - this.proxy = value - this.$emit('input', value) - - if (typeof this.__FormularioForm_set === 'function') { - this.__FormularioForm_set(this.fullPath, value) - this.__FormularioForm_emitInput() - } - } - } - private get context (): FormularioFieldContext { return Object.defineProperty({ name: this.fullPath, + path: this.fullPath, runValidation: this.runValidation.bind(this), violations: this.violations, errors: this.localErrors, allErrors: [...this.localErrors, ...this.violations.map(v => v.message)], }, 'model', { - get: () => this.model, - set: (value: unknown) => { - this.model = value + get: () => this.modelGetConverter(this.proxy), + set: (value: unknown): void => { + this.syncProxy(this.modelSetConverter(value, this.proxy)) }, }) } @@ -137,18 +120,12 @@ export default class FormularioField extends Vue { } @Watch('value') - private onValueChange (newValue: unknown, oldValue: unknown): void { - if (this.hasModel && !shallowEquals(newValue, oldValue)) { - this.model = newValue - } + private onValueChange (): void { + this.syncProxy(this.value) } @Watch('proxy') - private onProxyChange (newValue: unknown, oldValue: unknown): void { - if (!this.hasModel && !shallowEquals(newValue, oldValue)) { - this.model = newValue - } - + private onProxyChange (): void { if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) { this.runValidation() } else { @@ -160,10 +137,6 @@ export default class FormularioField extends Vue { * @internal */ public created (): void { - if (!shallowEquals(this.model, this.proxy)) { - this.model = this.proxy - } - if (typeof this.__FormularioForm_register === 'function') { this.__FormularioForm_register(this.fullPath, this) } @@ -182,13 +155,22 @@ export default class FormularioField extends Vue { } } + private syncProxy (value: unknown): void { + if (!deepEquals(value, this.proxy)) { + this.proxy = value + this.$emit('input', value) + + if (typeof this.__FormularioForm_set === 'function') { + this.__FormularioForm_set(this.fullPath, value) + this.__FormularioForm_emitInput() + } + } + } + public runValidation (): Promise { this.validationRun = this.validate().then(violations => { - if (!shallowEquals(this.violations, violations)) { - this.emitValidation(this.fullPath, violations) - } - this.violations = violations + this.emitValidation(this.fullPath, violations) return this.violations }) @@ -202,8 +184,8 @@ export default class FormularioField extends Vue { this.$formulario.getRules(this.normalizedValidationRules), this.$formulario.getMessages(this, this.normalizedValidationMessages), ), { - value: this.context.model, - name: this.context.name, + value: this.proxy, + name: this.fullPath, formValues: this.__FormularioForm_getState(), }) } diff --git a/src/FormularioFieldGroup.vue b/src/FormularioFieldGroup.vue index c60ddc6..f805846 100644 --- a/src/FormularioFieldGroup.vue +++ b/src/FormularioFieldGroup.vue @@ -22,17 +22,17 @@ export default class FormularioFieldGroup extends Vue { @Provide('__Formulario_path') get fullPath (): string { - const name = `${this.name}` + const path = `${this.name}` - if (parseInt(name).toString() === name) { - return `${this.__Formulario_path}[${name}]` + if (parseInt(path).toString() === path) { + return `${this.__Formulario_path}[${path}]` } if (this.__Formulario_path === '') { - return name + return path } - return `${this.__Formulario_path}.${name}` + return `${this.__Formulario_path}.${path}` } } diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index 92a8018..b0f6f92 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -14,67 +14,51 @@ import { Watch, } from 'vue-property-decorator' import { + id, clone, + deepEquals, get, has, merge, set, - shallowEquals, unset, } from '@/utils' -import PathRegistry from '@/PathRegistry' - -import { FormularioFieldInterface } from '@/types' -import { - Violation, - ViolationsRecord, -} from '@/validation/validator' - -type ErrorsRecord = Record +import { FormularioField } from '@/types' +import { Violation } from '@/validation/validator' type ValidationEventPayload = { name: string; violations: Violation[]; } -let counter = 0 +const update = (state: Record, path: string, value: unknown): Record => { + if (value === undefined) { + return unset(state, path) as Record + } + + return set(state, path, value) as Record +} @Component({ name: 'FormularioForm' }) export default class FormularioForm extends Vue { @Model('input', { default: () => ({}) }) public readonly state!: Record - @Prop({ default: () => `formulario-form-${++counter}` }) + @Prop({ default: () => id('formulario-form') }) public readonly id!: string // Describes validation errors of whole form - @Prop({ default: () => ({}) }) readonly fieldsErrors!: ErrorsRecord + @Prop({ default: () => ({}) }) readonly fieldsErrors!: Record // Only used on FormularioForm default slot @Prop({ default: () => ([]) }) readonly formErrors!: string[] private proxy: Record = {} - private registry: PathRegistry = new PathRegistry() + private registry: Map = new Map() // Local error messages are temporal, they wiped each resetValidation call - private localFieldsErrors: ErrorsRecord = {} + private localFieldsErrors: Record = {} private localFormErrors: string[] = [] - private get hasModel (): boolean { - return has(this.$options.propsData || {}, 'state') - } - - private get modelIsDefined (): boolean { - return this.state && typeof this.state === 'object' - } - - private get modelCopy (): Record { - if (this.hasModel && typeof this.state === 'object') { - return { ...this.state } // @todo - use a deep clone to detach reference types - } - - return {} - } - private get fieldsErrorsComputed (): Record { return merge(this.fieldsErrors || {}, this.localFieldsErrors) } @@ -84,19 +68,21 @@ export default class FormularioForm extends Vue { } @Provide('__FormularioForm_register') - private register (path: string, field: FormularioFieldInterface): void { - this.registry.add(path, field) + private register (path: string, field: FormularioField): void { + if (!this.registry.has(path)) { + this.registry.set(path, field) + } - const value = get(this.modelCopy, path) + const value = get(this.proxy, path) - if (!field.hasModel && this.modelIsDefined) { + if (!field.hasModel) { if (value !== undefined) { - field.model = value + field.proxy = value } else { this.setFieldValue(path, null) this.emitInput() } - } else if (field.hasModel && !shallowEquals(field.proxy, value)) { + } else if (!deepEquals(field.proxy, value)) { this.setFieldValue(path, field.proxy) this.emitInput() } @@ -109,10 +95,9 @@ export default class FormularioForm extends Vue { @Provide('__FormularioForm_unregister') private unregister (path: string): void { if (this.registry.has(path)) { - this.registry.remove(path) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [path]: _, ...newProxy } = this.proxy - this.proxy = newProxy + this.registry.delete(path) + this.proxy = unset(this.proxy, path) as Record + this.emitInput() } } @@ -123,16 +108,12 @@ export default class FormularioForm extends Vue { @Provide('__FormularioForm_set') private setFieldValue (path: string, value: unknown): void { - if (value === undefined) { - this.proxy = unset(this.proxy, path) as Record - } else { - this.proxy = set(this.proxy, path, value) as Record - } + this.proxy = update(this.proxy, path, value) } @Provide('__FormularioForm_emitInput') private emitInput (): void { - this.$emit('input', { ...this.proxy }) + this.$emit('input', clone(this.proxy)) } @Provide('__FormularioForm_emitValidation') @@ -141,9 +122,29 @@ export default class FormularioForm extends Vue { } @Watch('state', { deep: true }) - private onStateChange (state: Record): void { - if (this.hasModel && state && typeof state === 'object') { - this.loadState(state) + private onStateChange (newState: Record): void { + const newProxy = clone(newState) + const oldProxy = this.proxy + + let proxyHasChanges = false + + this.registry.forEach((field, path) => { + const newValue = get(newState, path, null) + const oldValue = get(oldProxy, path, null) + + field.proxy = newValue + + if (!deepEquals(newValue, oldValue)) { + field.$emit('input', newValue) + update(newProxy, path, newValue) + proxyHasChanges = true + } + }) + + this.proxy = newProxy + + if (proxyHasChanges) { + this.emitInput() } } @@ -155,18 +156,22 @@ export default class FormularioForm extends Vue { } public created (): void { - this.syncProxy() this.$formulario.register(this.id, this) + if (typeof this.state === 'object') { + this.proxy = clone(this.state) + } } public beforeDestroy (): void { this.$formulario.unregister(this.id) } - public runValidation (): Promise { - const violations: ViolationsRecord = {} - const runs = this.registry.map((field: FormularioFieldInterface, path: string) => { - return field.runValidation().then(v => { violations[path] = v }) + public runValidation (): Promise> { + const runs: Promise[] = [] + const violations: Record = {} + + this.registry.forEach((field, path) => { + runs.push(field.runValidation().then(v => { violations[path] = v })) }) return Promise.all(runs).then(() => violations) @@ -178,7 +183,10 @@ export default class FormularioForm extends Vue { }) } - public setErrors ({ fieldsErrors, formErrors }: { fieldsErrors?: ErrorsRecord; formErrors?: string[] }): void { + public setErrors ({ fieldsErrors, formErrors }: { + fieldsErrors?: Record; + formErrors?: string[]; + }): void { this.localFieldsErrors = fieldsErrors || {} this.localFormErrors = formErrors || [] } @@ -186,63 +194,21 @@ export default class FormularioForm extends Vue { public resetValidation (): void { this.localFieldsErrors = {} this.localFormErrors = [] - this.registry.forEach((field: FormularioFieldInterface) => { + this.registry.forEach((field: FormularioField) => { field.resetValidation() }) } private onSubmit (): Promise { - return this.runValidation() - .then(violations => { - const hasErrors = Object.keys(violations).some(path => violations[path].length > 0) + return this.runValidation().then(violations => { + const hasErrors = Object.keys(violations).some(path => violations[path].length > 0) - if (!hasErrors) { - this.$emit('submit', clone(this.proxy)) - } else { - this.$emit('error', violations) - } - }) - } - - private loadState (state: Record): void { - const paths = Array.from(new Set([ - ...Object.keys(state), - ...Object.keys(this.proxy), - ])) - - let proxyHasChanges = false - - paths.forEach(path => { - if (!this.registry.hasSubset(path)) { - return + if (!hasErrors) { + this.$emit('submit', clone(this.proxy)) + } else { + this.$emit('error', violations) } - - this.registry.getSubset(path).forEach((field, path) => { - const oldValue = get(this.proxy, path, null) - const newValue = get(state, path, null) - - if (!shallowEquals(newValue, oldValue)) { - this.setFieldValue(path, newValue) - proxyHasChanges = true - } - - if (!shallowEquals(newValue, field.proxy)) { - field.model = newValue - } - }) }) - - this.syncProxy() - - if (proxyHasChanges) { - this.$emit('input', { ...this.proxy }) - } - } - - private syncProxy (): void { - if (this.modelIsDefined) { - this.proxy = this.modelCopy - } } } diff --git a/src/PathRegistry.ts b/src/PathRegistry.ts deleted file mode 100644 index 966434f..0000000 --- a/src/PathRegistry.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @internal - */ -export default class PathRegistry { - private registry: Map - - constructor () { - this.registry = new Map() - } - - has (path: string): boolean { - return this.registry.has(path) - } - - hasSubset (path: string): boolean { - for (const itemPath of this.registry.keys()) { - if (itemPath === path || itemPath.includes(path + '.')) { - return true - } - } - - return false - } - - get (path: string): T | undefined { - return this.registry.get(path) - } - - /** - * Returns registry subset by given path - field & descendants - */ - getSubset (path: string): PathRegistry { - const subset: PathRegistry = new PathRegistry() - - for (const itemPath of this.registry.keys()) { - if ( - itemPath === path || - itemPath.startsWith(path + '.') || - itemPath.startsWith(path + '[') - ) { - subset.add(itemPath, this.registry.get(itemPath) as T) - } - } - - return subset - } - - add (path: string, item: T): void { - if (!this.registry.has(path)) { - this.registry.set(path, item) - } - } - - remove (path: string): void { - this.registry.delete(path) - } - - paths (): IterableIterator { - return this.registry.keys() - } - - forEach (callback: (field: T, path: string) => void): void { - this.registry.forEach((field, path) => { - callback(field, path) - }) - } - - map (mapper: (item: T, path: string) => U): U[] { - return Array.from(this.registry.keys()).map(path => mapper(this.get(path) as T, path)) - } -} diff --git a/src/types.ts b/src/types.ts index 5e2542d..de306a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,13 @@ -import { Violation, ViolationsRecord } from '@/validation/validator' +import Vue from 'vue' +import { Violation } from '@/validation/validator' -export interface FormularioFormInterface { - runValidation(): Promise; +export interface FormularioForm extends Vue { + runValidation(): Promise>; resetValidation(): void; } -export interface FormularioFieldInterface { +export interface FormularioField extends Vue { hasModel: boolean; - model: unknown; proxy: unknown; setErrors(errors: string[]): void; runValidation(): Promise; @@ -33,10 +33,58 @@ export interface FormularioFieldModelSetConverter { export type Empty = undefined | null -export type RecordKey = string | number -export type RecordLike = T[] | Record +export enum TYPE { + ARRAY = 'ARRAY', + BIGINT = 'BIGINT', + BOOLEAN = 'BOOLEAN', + DATE = 'DATE', + FUNCTION = 'FUNCTION', + NUMBER = 'NUMBER', + RECORD = 'RECORD', + STRING = 'STRING', + SYMBOL = 'SYMBOL', + UNDEFINED = 'UNDEFINED', + NULL = 'NULL', +} -export type Scalar = boolean | number | string | symbol | Empty +export function typeOf (value: unknown): string { + switch (typeof value) { + case 'bigint': + return TYPE.BIGINT + case 'boolean': + return TYPE.BOOLEAN + case 'function': + return TYPE.FUNCTION + case 'number': + return TYPE.NUMBER + case 'string': + return TYPE.STRING + case 'symbol': + return TYPE.SYMBOL + case 'undefined': + return TYPE.UNDEFINED + case 'object': + if (value === null) { + return TYPE.NULL + } + + if (value instanceof Date) { + return TYPE.DATE + } + + if (Array.isArray(value)) { + return TYPE.ARRAY + } + + if (value.constructor.name === 'Object') { + return TYPE.RECORD + } + + return 'InstanceOf<' + value.constructor.name + '>' + } + + throw new Error() +} export function isRecordLike (value: unknown): boolean { return typeof value === 'object' && value !== null && ['Array', 'Object'].includes(value.constructor.name) diff --git a/src/utils/clone.ts b/src/utils/clone.ts index 88d83ef..6275f19 100644 --- a/src/utils/clone.ts +++ b/src/utils/clone.ts @@ -1,10 +1,4 @@ -import has from '@/utils/has' -import { - RecordLike, - Scalar, - isRecordLike, - isScalar, -} from '@/types' +import { isRecordLike, isScalar } from '@/types' const cloneInstance = (original: T): T => { return Object.assign(Object.create(Object.getPrototypeOf(original)), original) @@ -14,27 +8,27 @@ const cloneInstance = (original: T): T => { * A simple (somewhat non-comprehensive) clone function, valid for our use * case of needing to unbind reactive watchers. */ -export default function clone (value: unknown): unknown { +export default function clone (value: T): T { if (isScalar(value)) { - return value as Scalar + return value } if (value instanceof Date) { - return new Date(value) + return new Date(value) as unknown as T } if (!isRecordLike(value)) { return cloneInstance(value) } - const source: RecordLike = value as RecordLike - const copy: RecordLike = Array.isArray(source) ? [] : {} - - for (const key in source) { - if (has(source, key)) { - copy[key] = clone(source[key]) - } + if (Array.isArray(value)) { + return value.slice().map(clone) as unknown as T } - return copy + const source: Record = value as Record + + return Object.keys(source).reduce((copy, key) => ({ + ...copy, + [key]: clone(source[key]) + }), {}) as unknown as T } diff --git a/src/utils/compare.ts b/src/utils/compare.ts new file mode 100644 index 0000000..927e26a --- /dev/null +++ b/src/utils/compare.ts @@ -0,0 +1,82 @@ +import has from './has' +import { typeOf, TYPE } from '@/types' + +export interface EqualPredicate { + (a: unknown, b: unknown): boolean; +} + +export function datesEquals (a: Date, b: Date): boolean { + return a.getTime() === b.getTime() +} + +export function arraysEquals ( + a: unknown[], + b: unknown[], + predicate: EqualPredicate +): boolean { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (!predicate(a[i], b[i])) { + return false + } + } + + return true +} + +export function recordsEquals ( + a: Record, + b: Record, + predicate: EqualPredicate +): boolean { + if (Object.keys(a).length !== Object.keys(b).length) { + return false + } + + for (const prop in a as object) { + if (!has(b, prop) || !predicate(a[prop], b[prop])) { + return false + } + } + + return true +} + +export function strictEquals (a: unknown, b: unknown): boolean { + return a === b +} + +export function equals (a: unknown, b: unknown, predicate: EqualPredicate): boolean { + const typeOfA = typeOf(a) + const typeOfB = typeOf(b) + + return typeOfA === typeOfB && ( + (typeOfA === TYPE.ARRAY && arraysEquals( + a as unknown[], + b as unknown[], + predicate + )) || + (typeOfA === TYPE.DATE && datesEquals(a as Date, b as Date)) || + (typeOfA === TYPE.RECORD && recordsEquals( + a as Record, + b as Record, + predicate + )) || + (typeOfA.includes('InstanceOf') && equals( + Object.entries(a as object), + Object.entries(b as object), + predicate, + )) + ) +} + +export function deepEquals (a: unknown, b: unknown): boolean { + return a === b || equals(a, b, deepEquals) +} + +export function shallowEquals (a: unknown, b: unknown): boolean { + return a === b || equals(a, b, strictEquals) +} diff --git a/src/utils/id.ts b/src/utils/id.ts new file mode 100644 index 0000000..72d3c87 --- /dev/null +++ b/src/utils/id.ts @@ -0,0 +1,10 @@ +const registry: Map = new Map() + +export default (prefix: string): string => { + const current = registry.get(prefix) || 0 + const next = current + 1 + + registry.set(prefix, next) + + return `${prefix}-${next}` +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 7322c20..c5aae58 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,8 @@ +export { default as id } from './id' export { default as clone } from './clone' export { default as has } from './has' export { default as merge } from './merge' export { get, set, unset } from './access' export { default as regexForFormat } from './regexForFormat' -export { default as shallowEquals } from './shallowEquals' +export { deepEquals, shallowEquals } from './compare' export { default as snakeToCamel } from './snakeToCamel' diff --git a/src/utils/shallowEquals.ts b/src/utils/shallowEquals.ts deleted file mode 100644 index 2895688..0000000 --- a/src/utils/shallowEquals.ts +++ /dev/null @@ -1,42 +0,0 @@ -export function equalsDates (a: Date, b: Date): boolean { - return a.getTime() === b.getTime() -} - -export function shallowEqualsRecords ( - a: Record, - b: Record -): boolean { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - - if (aKeys.length !== bKeys.length) { - return false - } - - if (aKeys.length === 0) { - return a === b - } - - return aKeys.reduce((equals: boolean, key: string): boolean => { - return equals && a[key] === b[key] - }, true) -} - -export default function shallowEquals (a: unknown, b: unknown): boolean { - if (a === b) { - return true - } - - if (!a || !b) { - return false - } - - if (a instanceof Date && b instanceof Date) { - return equalsDates(a, b) - } - - return shallowEqualsRecords( - a as Record, - b as Record - ) -} diff --git a/src/validation/rules.ts b/src/validation/rules.ts index 9479feb..383453a 100644 --- a/src/validation/rules.ts +++ b/src/validation/rules.ts @@ -130,7 +130,7 @@ const rules: Record = { * Rule: Value is in an array (stack). */ in ({ value }: ValidationContext, ...stack: any[]): boolean { - return stack.some(item => typeof item === 'object' ? shallowEquals(item, value) : item === value) + return stack.some(item => shallowEquals(item, value)) }, /** @@ -198,7 +198,7 @@ const rules: Record = { * Rule: Value is not in stack. */ not ({ value }: ValidationContext, ...stack: any[]): boolean { - return !stack.some(item => typeof item === 'object' ? shallowEquals(item, value) : item === value) + return !stack.some(item => shallowEquals(item, value)) }, /** diff --git a/src/validation/validator.ts b/src/validation/validator.ts index 6f8a964..a34b2b5 100644 --- a/src/validation/validator.ts +++ b/src/validation/validator.ts @@ -11,8 +11,6 @@ export interface Violation { context: ValidationContext|null; } -export type ViolationsRecord = Record - export interface ValidationRuleFn { (context: ValidationContext, ...args: any[]): Promise|boolean; } diff --git a/test/unit/FormularioField.test.js b/test/unit/FormularioField.test.js index 73d753a..6acc99f 100644 --- a/test/unit/FormularioField.test.js +++ b/test/unit/FormularioField.test.js @@ -283,7 +283,6 @@ describe('FormularioField', () => { const form = wrapper.findComponent(FormularioForm) expect(form.emitted('input')).toEqual([ - [{}], [{ date: new Date('2001-05-12') }], ]) }) diff --git a/test/unit/PathRegistry.test.js b/test/unit/PathRegistry.test.js deleted file mode 100644 index 5bce004..0000000 --- a/test/unit/PathRegistry.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import PathRegistry from '@/PathRegistry' - -describe('PathRegistry', () => { - test('subset structure', () => { - const registry = new PathRegistry() - - const paths = path => Array.from(registry.getSubset(path).paths()) - - registry.add('name', null) - registry.add('address', []) - registry.add('address[0]', {}) - registry.add('address[0].street', 'Baker Street') - registry.add('address[0].building', '221b') - registry.add('address[1]', {}) - registry.add('address[1].street', '') - registry.add('address[1].building', '') - - expect(paths('name')).toEqual(['name']) - expect(paths('address')).toEqual([ - 'address', - 'address[0]', - 'address[0].street', - 'address[0].building', - 'address[1]', - 'address[1].street', - 'address[1].building', - ]) - expect(paths('address[1]')).toEqual([ - 'address[1]', - 'address[1].street', - 'address[1].building', - ]) - - registry.remove('address[1]') - - expect(paths('address')).toEqual([ - 'address', - 'address[0]', - 'address[0].street', - 'address[0].building', - 'address[1].street', - 'address[1].building', - ]) - - expect(paths('address[1]')).toEqual([ - 'address[1].street', - 'address[1].building', - ]) - }) -}) diff --git a/test/unit/utils/compare.test.js b/test/unit/utils/compare.test.js new file mode 100644 index 0000000..0eda86b --- /dev/null +++ b/test/unit/utils/compare.test.js @@ -0,0 +1,125 @@ +import { deepEquals, shallowEquals } from '@/utils/compare' + +class Sample { + constructor() { + this.fieldA = 'fieldA' + this.fieldB = 'fieldB' + } + + doSomething () {} +} + +describe('compare', () => { + describe('deepEquals', () => { + test.each` + type | a + ${'booleans'} | ${false} + ${'numbers'} | ${123} + ${'strings'} | ${'hello'} + ${'symbols'} | ${Symbol(123)} + ${'undefined'} | ${undefined} + ${'null'} | ${null} + ${'array'} | ${[1, 2, 3]} + ${'pojo'} | ${{ a: 1, b: 2 }} + ${'empty array'} | ${[]} + ${'empty pojo'} | ${{}} + ${'date'} | ${new Date()} + `('A=A check on $type', ({ a }) => { + expect(deepEquals(a, a)).toBe(true) + }) + + test.each` + a | b | expected + ${[]} | ${[]} | ${true} + ${[1, 2, 3]} | ${[1, 2, 3]} | ${true} + ${[1, 2, { a: 1 }]} | ${[1, 2, { a: 1 }]} | ${true} + ${[1, 2, { a: 1 }]} | ${[1, 2, { a: 2 }]} | ${false} + ${[1, 2, 3]} | ${[1, 2, 4]} | ${false} + ${[1, 2, 3]} | ${[1, 2]} | ${false} + ${[]} | ${[1, 2]} | ${false} + ${{}} | ${{}} | ${true} + ${{ a: 1, b: 2 }} | ${{ a: 1, b: 2 }} | ${true} + ${{ a: 1, b: 2 }} | ${{ a: 1 }} | ${false} + ${{ a: {} }} | ${{ a: {} }} | ${true} + ${{ a: { b: 1 } }} | ${{ a: { b: 1 } }} | ${true} + ${{ a: { b: 1 } }} | ${{ a: { b: 2 } }} | ${false} + ${new Date()} | ${new Date()} | ${true} + `('A=B & B=A check: A=$a, B=$b, expected: $expected', ({ a, b, expected }) => { + expect(deepEquals(a, b)).toBe(expected) + expect(deepEquals(b, a)).toBe(expected) + }) + + test('A=B & B=A check for instances', () => { + const a = new Sample() + const b = new Sample() + + expect(deepEquals(a, b)).toBe(true) + expect(deepEquals(b, a)).toBe(true) + + b.fieldA += '~' + + expect(deepEquals(a, b)).toBe(false) + expect(deepEquals(b, a)).toBe(false) + }) + + test('A=B & B=A check for instances with nesting', () => { + const a = new Sample() + const b = new Sample() + + a.fieldA = new Sample() + b.fieldA = new Sample() + + expect(deepEquals(a, b)).toBe(true) + expect(deepEquals(b, a)).toBe(true) + + b.fieldA.fieldA += '~' + + expect(deepEquals(a, b)).toBe(false) + expect(deepEquals(b, a)).toBe(false) + }) + }) + + describe('shallowEquals', () => { + test.each` + type | a + ${'booleans'} | ${false} + ${'numbers'} | ${123} + ${'strings'} | ${'hello'} + ${'symbols'} | ${Symbol(123)} + ${'undefined'} | ${undefined} + ${'null'} | ${null} + ${'array'} | ${[1, 2, 3]} + ${'pojo'} | ${{ a: 1, b: 2 }} + ${'empty array'} | ${[]} + ${'empty pojo'} | ${{}} + ${'date'} | ${new Date()} + `('A=A check on $type', ({ a }) => { + expect(shallowEquals(a, a)).toBe(true) + }) + + test.each` + a | b | expected + ${[]} | ${[]} | ${true} + ${[1, 2, 3]} | ${[1, 2, 3]} | ${true} + ${[1, 2, 3]} | ${[1, 2, 4]} | ${false} + ${[1, 2, 3]} | ${[1, 2]} | ${false} + ${[]} | ${[1, 2]} | ${false} + ${{}} | ${{}} | ${true} + ${{ a: 1, b: 2 }} | ${{ a: 1, b: 2 }} | ${true} + ${{ a: 1, b: 2 }} | ${{ a: 1 }} | ${false} + ${{ a: {} }} | ${{ a: {} }} | ${false} + ${new Date()} | ${new Date()} | ${true} + `('A=B & B=A check: A=$a, B=$b, expected: $expected', ({ a, b, expected }) => { + expect(shallowEquals(a, b)).toBe(expected) + expect(shallowEquals(b, a)).toBe(expected) + }) + + test('A=B & B=A check for instances', () => { + const a = new Sample() + const b = new Sample() + + expect(shallowEquals(a, b)).toBe(false) + expect(shallowEquals(b, a)).toBe(false) + }) + }) +})