mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): add form structure validation and tests
This commit is contained in:
@@ -6,8 +6,9 @@ import {
|
||||
getElement,
|
||||
removeElement,
|
||||
reparentElement,
|
||||
validateFormStructure,
|
||||
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
|
||||
import type { ContainerElement } from 'features/nodes/types/workflow';
|
||||
import type { BuilderForm, ContainerElement } from 'features/nodes/types/workflow';
|
||||
import {
|
||||
buildContainer,
|
||||
buildText,
|
||||
@@ -756,4 +757,155 @@ describe('workflow builder form manipulation', () => {
|
||||
expect(form).toEqual(prevForm);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFormStructure', () => {
|
||||
it('should be happy with the default form', () => {
|
||||
const form = getDefaultForm();
|
||||
expect(validateFormStructure(form)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if all children are reachable and there are no extra elements', () => {
|
||||
const form = getDefaultForm();
|
||||
const container1 = buildContainer('row', []);
|
||||
addElement({
|
||||
form,
|
||||
element: container1,
|
||||
parentId: form.rootElementId,
|
||||
index: 0,
|
||||
});
|
||||
const container2 = buildContainer('row', []);
|
||||
addElement({
|
||||
form,
|
||||
element: container2,
|
||||
parentId: container1.id,
|
||||
index: 0,
|
||||
});
|
||||
const child = buildText('foo');
|
||||
addElement({
|
||||
form,
|
||||
element: child,
|
||||
parentId: container2.id,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
expect(validateFormStructure(form)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if a child is not reachable', () => {
|
||||
const form = getDefaultForm();
|
||||
const parent = buildContainer('row', []);
|
||||
addElement({
|
||||
form,
|
||||
element: parent,
|
||||
parentId: form.rootElementId,
|
||||
index: 0,
|
||||
});
|
||||
const el = buildText('foo');
|
||||
addElement({
|
||||
form,
|
||||
element: el,
|
||||
parentId: parent.id,
|
||||
index: 0,
|
||||
});
|
||||
el.parentId = 'non-existent-parent';
|
||||
parent.data.children = ['non-existent-child'];
|
||||
|
||||
expect(validateFormStructure(form)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if a non-root child's parent does not exist", () => {
|
||||
const form = getDefaultForm();
|
||||
const parent = buildContainer('row', []);
|
||||
addElement({
|
||||
form,
|
||||
element: parent,
|
||||
parentId: form.rootElementId,
|
||||
index: 0,
|
||||
});
|
||||
const el = buildText('foo');
|
||||
addElement({
|
||||
form,
|
||||
element: el,
|
||||
parentId: parent.id,
|
||||
index: 0,
|
||||
});
|
||||
el.parentId = 'non-existent-parent';
|
||||
expect(validateFormStructure(form)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be OK with the root not having a parent', () => {
|
||||
// This test is the same as the default form
|
||||
const form = getDefaultForm();
|
||||
const rootElement = form.elements[form.rootElementId];
|
||||
assert(rootElement !== undefined);
|
||||
expect(rootElement.parentId).toBeUndefined();
|
||||
expect(validateFormStructure(form)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if a child's parent is not a container", () => {
|
||||
const form = getDefaultForm();
|
||||
const notAContainer = buildText('foo');
|
||||
addElement({
|
||||
form,
|
||||
element: notAContainer,
|
||||
parentId: form.rootElementId,
|
||||
index: 0,
|
||||
});
|
||||
const el = buildText('bar');
|
||||
addElement({
|
||||
form,
|
||||
element: el,
|
||||
parentId: form.rootElementId,
|
||||
index: 0,
|
||||
});
|
||||
el.parentId = notAContainer.id;
|
||||
expect(validateFormStructure(form)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the child's parent does not have the child in its children list", () => {
|
||||
const form = getDefaultForm();
|
||||
const parent = buildContainer('row', []);
|
||||
addElement({
|
||||
form,
|
||||
element: parent,
|
||||
parentId: form.rootElementId,
|
||||
index: 0,
|
||||
});
|
||||
const el = buildText('foo');
|
||||
addElement({
|
||||
form,
|
||||
element: el,
|
||||
parentId: parent.id,
|
||||
index: 0,
|
||||
});
|
||||
parent.data.children = [];
|
||||
expect(validateFormStructure(form)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if there are extra elements', () => {
|
||||
const form = getDefaultForm();
|
||||
const parent = buildContainer('row', []);
|
||||
addElement({
|
||||
form,
|
||||
element: parent,
|
||||
parentId: form.rootElementId,
|
||||
index: 0,
|
||||
});
|
||||
const el = buildText('foo');
|
||||
el.parentId = parent.id;
|
||||
form.elements[el.id] = el;
|
||||
expect(validateFormStructure(form)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the root element is not a container', () => {
|
||||
const el = buildText('foo');
|
||||
const form: BuilderForm = {
|
||||
elements: {
|
||||
[el.id]: el,
|
||||
},
|
||||
rootElementId: el.id,
|
||||
};
|
||||
expect(validateFormStructure(form)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,3 +245,72 @@ export const getAllowedDropRegions = (form: BuilderForm, element: FormElement):
|
||||
|
||||
return dropRegions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the structure of a form.
|
||||
*
|
||||
* The form structure is valid if:
|
||||
* - The root element is a container
|
||||
* - Starting from the root element, all children referenced are reachable
|
||||
* - There are no extra elements in the form that are not reachable from the root element
|
||||
* - The root element has no parentId and is a container
|
||||
* - Non-root elements have a parentId
|
||||
* - All parent elements are containers
|
||||
* - All elements with a parentId are children of their parent
|
||||
*
|
||||
* @param form The form to validate
|
||||
*
|
||||
* @returns True if the form structure is valid, false otherwise
|
||||
*/
|
||||
export const validateFormStructure = (form: BuilderForm): boolean => {
|
||||
const { elements, rootElementId } = form;
|
||||
|
||||
const rootElement = elements[rootElementId];
|
||||
const isRootElementAContainer = rootElement !== undefined && isContainerElement(rootElement);
|
||||
|
||||
const childrenFoundInTree = new Set<string>();
|
||||
|
||||
const findChildren = (elementId: string): boolean => {
|
||||
const element = elements[elementId];
|
||||
if (!element) {
|
||||
// Element not found
|
||||
return false;
|
||||
}
|
||||
childrenFoundInTree.add(elementId);
|
||||
if (element.id === rootElementId) {
|
||||
// Special handling for root
|
||||
if (element.parentId !== undefined) {
|
||||
// Root element must not have a parent
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Handling for all other elements
|
||||
if (element.parentId === undefined) {
|
||||
// Element must have a parent
|
||||
return false;
|
||||
}
|
||||
const parent = elements[element.parentId];
|
||||
if (!parent) {
|
||||
// Parent must exist
|
||||
return false;
|
||||
}
|
||||
if (!isContainerElement(parent)) {
|
||||
// Parent must be a container
|
||||
return false;
|
||||
}
|
||||
if (!parent.data.children.includes(elementId)) {
|
||||
// Element must be a child of its parent
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isContainerElement(element) && element.data.children.length > 0) {
|
||||
return element.data.children.every(findChildren);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const noMissingChildren = findChildren(rootElementId);
|
||||
const noExtraElements = Object.keys(elements).length === childrenFoundInTree.size;
|
||||
|
||||
return isRootElementAContainer && noMissingChildren && noExtraElements;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { validateFormStructure } from 'features/nodes/components/sidePanel/builder/form-manipulation';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { FieldType } from './field';
|
||||
@@ -230,7 +231,7 @@ const zFormElement = z.union([zContainerElement, zNodeFieldElement, zHeadingElem
|
||||
|
||||
export type FormElement = z.infer<typeof zFormElement>;
|
||||
|
||||
export const getDefaultForm = () => {
|
||||
export const getDefaultForm = (): BuilderForm => {
|
||||
const rootElement = buildContainer('column', []);
|
||||
return {
|
||||
elements: {
|
||||
@@ -240,13 +241,22 @@ export const getDefaultForm = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const zBuilderForm = z
|
||||
.object({
|
||||
elements: z.record(zFormElement),
|
||||
rootElementId: zElementId,
|
||||
})
|
||||
.default(getDefaultForm);
|
||||
const zBuilderForm = z.object({
|
||||
elements: z.record(zFormElement),
|
||||
rootElementId: zElementId,
|
||||
});
|
||||
|
||||
export type BuilderForm = z.infer<typeof zBuilderForm>;
|
||||
|
||||
// Need to separate the form vaidation from the schema due to circular dependencies
|
||||
const zValidatedBuilderForm = zBuilderForm
|
||||
.default(getDefaultForm)
|
||||
.refine((val) => val.rootElementId in val.elements, {
|
||||
message: 'rootElementId must be a valid element id',
|
||||
})
|
||||
.refine((val) => validateFormStructure(val), {
|
||||
message: 'Form structure is invalid',
|
||||
});
|
||||
//# endregion
|
||||
|
||||
// #region Workflow
|
||||
@@ -266,7 +276,8 @@ export const zWorkflowV3 = z.object({
|
||||
category: zWorkflowCategory.default('user'),
|
||||
version: z.literal('3.0.0'),
|
||||
}),
|
||||
form: zBuilderForm,
|
||||
// Use the validated form schema!
|
||||
form: zValidatedBuilderForm,
|
||||
});
|
||||
export type WorkflowV3 = z.infer<typeof zWorkflowV3>;
|
||||
// #endregion
|
||||
|
||||
Reference in New Issue
Block a user