From 6ba23d4ccbb5c574ca452a04f4142d5ab7f03ef4 Mon Sep 17 00:00:00 2001 From: James Houghton Date: Thu, 31 Oct 2024 22:37:21 -0400 Subject: [PATCH] make conditions a discriminated union --- .../preFlight/validateTreatmentFile.test.js | 43 +- server/src/preFlight/validateTreatmentFile.ts | 609 +++++++++++------- 2 files changed, 408 insertions(+), 244 deletions(-) diff --git a/server/src/preFlight/validateTreatmentFile.test.js b/server/src/preFlight/validateTreatmentFile.test.js index 0187a2c2..ccebdf06 100644 --- a/server/src/preFlight/validateTreatmentFile.test.js +++ b/server/src/preFlight/validateTreatmentFile.test.js @@ -5,7 +5,6 @@ import { expect, test } from "vitest"; import { referenceSchema, conditionSchema, - introConditionSchema, elementsSchema, promptSchema, topSchema, @@ -74,28 +73,28 @@ test("condition missing required value", () => { expect(result.success).toBe(false); }); -test("condition in intro valid", () => { - const condition = { - reference: "prompt.namedPrompt", - comparator: "equals", - value: "value", - }; - const result = introConditionSchema.safeParse(condition); - if (!result.success) console.log(result.error.message); - expect(result.success).toBe(true); -}); +// test("condition in intro valid", () => { +// const condition = { +// reference: "prompt.namedPrompt", +// comparator: "equals", +// value: "value", +// }; +// const result = introConditionSchema.safeParse(condition); +// if (!result.success) console.log(result.error.message); +// expect(result.success).toBe(true); +// }); -test("condition in intro errors on position", () => { - const condition = { - reference: "prompt.namedPrompt", - comparator: "equals", - value: "value", - position: 1, - }; - const result = introConditionSchema.safeParse(condition); - if (!result.success) console.log(result.error.message); - expect(result.success).toBe(false); -}); +// test("condition in intro errors on position", () => { +// const condition = { +// reference: "prompt.namedPrompt", +// comparator: "equals", +// value: "value", +// position: 1, +// }; +// const result = introConditionSchema.safeParse(condition); +// if (!result.success) console.log(result.error.message); +// expect(result.success).toBe(false); +// }); // ----------- Small schemas ------------ diff --git a/server/src/preFlight/validateTreatmentFile.ts b/server/src/preFlight/validateTreatmentFile.ts index fdcc05f2..e5e2b330 100644 --- a/server/src/preFlight/validateTreatmentFile.ts +++ b/server/src/preFlight/validateTreatmentFile.ts @@ -12,6 +12,89 @@ function isValidRegex(pattern: string): boolean { } } +// --------------- Little Schemas --------------- // +// can be used in form validation + +// Names should have properties: +// max length: 64 characters +// min length: 1 character +// allowed characters: a-z, A-Z, 0-9, -, _, and space +export const nameSchema = z + .string() + .min(1, "Name is required") + .max(64) + .regex(/^[a-zA-Z0-9-_ ]+$/); +export type NameType = z.infer; + +export const descriptionSchema = z.string(); +export type DescriptionType = z.infer; + + +// TODO: check that file exists +export const fileSchema = z.string().optional(); +export type FileType = z.infer; + +// TODO: check that url is a valid url +export const urlSchema = z.string().url(); +export type UrlType = z.infer; + +// stage duration: +// min: 1 second +// max: 1 hour +export const durationSchema = z + .number() + .int() + .positive() + .max(3600, "Duration must be less than 3600 seconds"); +export type DurationType = z.infer; + +//display time should have these properties: +// min: 1 sec +// max: 1 hour +export const displayTimeSchema = z + .number() + .int() + .nonnegative() + .max(3600, "Duration must be less than 1 hour"); +export type DisplayTimeType = z.infer; + +// hideTime should have these properties: +// min: 1 sec +// max: 1 hour +export const hideTimeSchema = z + .number() + .int() + .positive() + .max(3600, "Duration must be less than 1 hour"); +export type HideTimeType = z.infer; + +export const positionSchema = z.number().int().nonnegative(); +export type PositionType = z.infer; + +export const positionSelectorSchema = z + .enum(["shared", "player", "all"]) + .or(positionSchema) + .default("player"); +export type PositionSelectorType = z.infer; + +// showToPositions is a list of nonnegative integers +// and are unique +export const showToPositionsSchema = z.array(positionSchema).nonempty(); // TODO: check for unique values (or coerce to unique values) +export type ShowToPositionsType = z.infer; + +// hideFromPositions is a list of nonnegative integers +// and are unique +export const hideFromPositionsSchema = z.array(positionSchema).nonempty(); // TODO: check for unique values (or coerce to unique values) +export type HideFromPositionsType = z.infer; + +export const discussionSchema = z.object({ + chatType: z.enum(["text", "audio", "video"]), + showNickname: z.boolean(), + showTitle: z.boolean(), +}); +export type DiscussionType = z.infer; + + export const referenceSchema = z .string() .transform((str) => str.split(".")) @@ -73,219 +156,296 @@ export const referenceSchema = z export type ReferenceType = z.infer; -const refineCondition = (obj: any, ctx: any) => { - const { comparator, value } = obj; - if (!["exists", "doesNotExist"].includes(comparator) && value === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Value is required for '${comparator}'`, - path: ["value"], - }); - } - - if (["isOneOf", "isNotOneOf"].includes(comparator) && !Array.isArray(value)) { - ctx.addIssue({ - code: z.ZodIssueCode.invalid_type, - expected: "array", - received: typeof value, - message: `Value must be an array for '${comparator}'`, - path: ["value"], - }); - } - - if ( - [ - "hasLengthAtLeast", - "hasLengthAtMost", - "isAbove", - "isBelow", - "isAtLeast", - "isAtMost", - ].includes(comparator) && - typeof value !== "number" - ) { - ctx.addIssue({ - code: z.ZodIssueCode.invalid_type, - expected: "number", - received: typeof value, - message: `Value must be a number for '${comparator}'`, - path: ["value"], - }); - } - - if ( - ["hasLengthAtLeast", "hasLengthAtMost"].includes(comparator) && - typeof value === "number" && - value < 0 - ) { - ctx.addIssue({ - code: z.ZodIssueCode.too_small, - type: "number", - minimum: 0, - inclusive: false, - message: `Value must be a positive number for '${comparator}'`, - path: ["value"], - }); - } - - if ( - ["includes", "doesNotInclude"].includes(comparator) && - typeof value !== "string" - ) { - ctx.addIssue({ - code: z.ZodIssueCode.invalid_type, - expected: "string", - received: typeof value, - message: `Value must be a string for '${comparator}'`, - path: ["value"], - }); - } - - if ( - ["matches", "doesNotMatch"].includes(comparator) && - (typeof value !== "string" || !isValidRegex(value)) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Value must be a valid regex expression for '${comparator}'`, - path: ["value"], - }); - } -}; - -const baseConditionSchema = z - .object({ - reference: referenceSchema, - comparator: z.enum([ - "exists", - "doesNotExist", - "equals", - "doesNotEqual", - "isAbove", - "isBelow", - "isAtLeast", - "isAtMost", - "hasLengthAtLeast", - "hasLengthAtMost", - "includes", - "doesNotInclude", - "matches", - "doesNotMatch", - "isOneOf", - "isNotOneOf", - ]), - value: z - .number() - .or(z.string()) - .or(z.array(z.string().or(z.number()))) - .or(z.boolean()) - .optional(), - }) - .strict(); - -export const introConditionSchema = - baseConditionSchema.superRefine(refineCondition); -export type IntroConditionType = z.infer; +// --------------- Conditions --------------- // -export const conditionSchema = baseConditionSchema - .extend({ - position: z +const baseConditionSchema = z.object({ + reference: referenceSchema, + position: z // todo: superrefine this somewhere so that it only exists in game stages, not in intro or exit steps .enum(["shared", "player", "all", "percentAgreement"]) .or(z.number().nonnegative().int()) - .default("player"), - }) - .superRefine(refineCondition); - -export type ConditionType = z.infer; - -// Do we have a separate type schema? or do we include it in the individual element types? -// maybe just make it a dropdown in the researcher portal -const typeSchema = z.string().min(1, "Type is required"); - -// --------------- Little Schemas --------------- // -// can be used in form validation - -// TODO: check that file exists -export const fileSchema = z.string().optional(); -export type FileType = z.infer; - -// TODO: check that url is a valid url -export const urlSchema = z.string().url(); -export type UrlType = z.infer; - -// Names should have properties: -// max length: 64 characters -// min length: 1 character -// allowed characters: a-z, A-Z, 0-9, -, _, and space -export const nameSchema = z - .string() - .min(1, "Name is required") - .max(64) - .regex(/^[a-zA-Z0-9-_ ]+$/); - -export type NameType = z.infer; - -// stage duration: -// min: 1 second -// max: 1 hour -export const durationSchema = z - .number() - .int() - .positive() - .max(3600, "Duration must be less than 3600 seconds"); - -export type DurationType = z.infer; - -// Description is optional -export const descriptionSchema = z.string(); -export type DescriptionType = z.infer; - -//display time should have these properties: -// min: 1 sec -// max: 1 hour -export const displayTimeSchema = z - .number() - .int() - .nonnegative() - .max(3600, "Duration must be less than 1 hour"); -export type DisplayTimeType = z.infer; - -// hideTime should have these properties: -// min: 1 sec -// max: 1 hour -export const hideTimeSchema = z - .number() - .int() - .positive() - .max(3600, "Duration must be less than 1 hour"); -export type HideTimeType = z.infer; + .optional(), +}); -export const positionSchema = z.number().int().nonnegative(); -export type PositionType = z.infer; +const conditionExistsSchema = baseConditionSchema.extend({ + comparator: z.literal("exists"), + value: z.undefined(), +}).strict(); + +const conditionDoesNotExistSchema = baseConditionSchema.extend({ + comparator: z.literal("doesNotExist"), + value: z.undefined(), +}).strict(); + +const conditionEqualsSchema = baseConditionSchema.extend({ + comparator: z.literal("equals"), + value: z.string().or(z.number()), +}).strict(); + +const conditionDoesNotEqualSchema = baseConditionSchema.extend({ + comparator: z.literal("doesNotEqual"), + value: z.string().or(z.number()), +}).strict(); + +const conditionIsAboveSchema = baseConditionSchema.extend({ + comparator: z.literal("isAbove"), + value: z.number(), +}).strict(); + +const conditionIsBelowSchema = baseConditionSchema.extend({ + comparator: z.literal("isBelow"), + value: z.number(), +}).strict(); + +const conditionIsAtLeastSchema = baseConditionSchema.extend({ + comparator: z.literal("isAtLeast"), + value: z.number(), +}).strict(); + +const conditionIsAtMostSchema = baseConditionSchema.extend({ + comparator: z.literal("isAtMost"), + value: z.number(), +}).strict(); + +const conditionHasLengthAtLeastSchema = baseConditionSchema.extend({ + comparator: z.literal("hasLengthAtLeast"), + value: z.number().nonnegative().int(), +}).strict(); + +const conditionHasLengthAtMostSchema = baseConditionSchema.extend({ + comparator: z.literal("hasLengthAtMost"), + value: z.number().nonnegative().int(), +}).strict(); + +const conditionIncludesSchema = baseConditionSchema.extend({ + comparator: z.literal("includes"), + value: z.string(), +}).strict(); + +const conditionDoesNotIncludeSchema = baseConditionSchema.extend({ + comparator: z.literal("doesNotInclude"), + value: z.string(), +}).strict(); + +// todo: extend this to include regex validation +const conditionMatchesSchema = baseConditionSchema.extend({ + comparator: z.literal("matches"), + value: z.string(), +}).strict(); + +const conditionDoesNotMatchSchema = baseConditionSchema.extend({ + comparator: z.literal("doesNotMatch"), + value: z.string(), +}).strict(); + +const conditionIsOneOfSchema = baseConditionSchema.extend({ + comparator: z.literal("isOneOf"), + value: z.array(z.string().or(z.number())).nonempty(), +}).strict(); + +const conditionIsNotOneOfSchema = baseConditionSchema.extend({ + comparator: z.literal("isNotOneOf"), + value: z.array(z.string().or(z.number())).nonempty(), +}).strict(); + + + +// const refineCondition = (obj: any, ctx: any) => { +// const { comparator, value } = obj; +// if (!["exists", "doesNotExist"].includes(comparator) && value === undefined) { +// ctx.addIssue({ +// code: z.ZodIssueCode.custom, +// message: `Value is required for '${comparator}'`, +// path: ["value"], +// }); +// } + +// if (["isOneOf", "isNotOneOf"].includes(comparator) && !Array.isArray(value)) { +// ctx.addIssue({ +// code: z.ZodIssueCode.invalid_type, +// expected: "array", +// received: typeof value, +// message: `Value must be an array for '${comparator}'`, +// path: ["value"], +// }); +// } + +// if ( +// [ +// "hasLengthAtLeast", +// "hasLengthAtMost", +// "isAbove", +// "isBelow", +// "isAtLeast", +// "isAtMost", +// ].includes(comparator) && +// typeof value !== "number" +// ) { +// ctx.addIssue({ +// code: z.ZodIssueCode.invalid_type, +// expected: "number", +// received: typeof value, +// message: `Value must be a number for '${comparator}'`, +// path: ["value"], +// }); +// } + +// if ( +// ["hasLengthAtLeast", "hasLengthAtMost"].includes(comparator) && +// typeof value === "number" && +// value < 0 +// ) { +// ctx.addIssue({ +// code: z.ZodIssueCode.too_small, +// type: "number", +// minimum: 0, +// inclusive: false, +// message: `Value must be a positive number for '${comparator}'`, +// path: ["value"], +// }); +// } + +// if ( +// ["includes", "doesNotInclude"].includes(comparator) && +// typeof value !== "string" +// ) { +// ctx.addIssue({ +// code: z.ZodIssueCode.invalid_type, +// expected: "string", +// received: typeof value, +// message: `Value must be a string for '${comparator}'`, +// path: ["value"], +// }); +// } + +// if ( +// ["matches", "doesNotMatch"].includes(comparator) && +// (typeof value !== "string" || !isValidRegex(value)) +// ) { +// ctx.addIssue({ +// code: z.ZodIssueCode.custom, +// message: `Value must be a valid regex expression for '${comparator}'`, +// path: ["value"], +// }); +// } +// }; + +// Modify `comparator` validation in `conditionSchema` to trigger more specific errors +// const validComparators = [ +// "exists", +// "doesNotExist", +// "equals", +// "doesNotEqual", +// "isAbove", +// "isBelow", +// "isAtLeast", +// "isAtMost", +// "hasLengthAtLeast", +// "hasLengthAtMost", +// "includes", +// "doesNotInclude", +// "matches", +// "doesNotMatch", +// "isOneOf", +// "isNotOneOf", +// ]; + +// const baseConditionSchema = z +// .object({ +// reference: referenceSchema, +// comparator: z.enum(validComparators).superRefine((comp, ctx) => { +// if (!validComparators.includes(comp)) { +// ctx.addIssue({ +// code: z.ZodIssueCode.custom, +// message: `Invalid comparator '${comp}'`, +// path: ["comparator"], +// }); +// } +// }), +// value: z +// .number() +// .or(z.string()) +// .or(z.array(z.string().or(z.number()))) +// .or(z.boolean()) +// .optional(), +// }) +// .strict() +// .superRefine(refineCondition); // keep the rest of refine logic in `refineCondition` + +// export const introConditionSchema = baseConditionSchema; + +// export type IntroConditionType = z.infer; + +// const validComparators = [ +// "exists", +// "doesNotExist", +// "equals", +// "doesNotEqual", +// "isAbove", +// "isBelow", +// "isAtLeast", +// "isAtMost", +// "hasLengthAtLeast", +// "hasLengthAtMost", +// "includes", +// "doesNotInclude", +// "matches", +// "doesNotMatch", +// "isOneOf", +// "isNotOneOf", +// ] as const; + +// const baseConditionSchema = z +// .object({ +// reference: referenceSchema, +// comparator: z.enum(validComparators), +// value: z +// .number() +// .or(z.string()) +// .or(z.array(z.string().or(z.number()))) +// .or(z.boolean()) +// .optional(), +// }) +// .strict(); -export const positionSelectorSchema = z - .enum(["shared", "player", "all"]) - .or(positionSchema) - .default("player"); -export type PositionSelectorType = z.infer; +// export const introConditionSchema = +// baseConditionSchema.superRefine(refineCondition); -// showToPositions is a list of nonnegative integers -// and are unique -export const showToPositionsSchema = z.array(positionSchema).nonempty(); // TODO: check for unique values (or coerce to unique values) -export type ShowToPositionsType = z.infer; +// export type IntroConditionType = z.infer; -// hideFromPositions is a list of nonnegative integers -// and are unique -export const hideFromPositionsSchema = z.array(positionSchema).nonempty(); // TODO: check for unique values (or coerce to unique values) -export type HideFromPositionsType = z.infer; +// export const conditionSchema = baseConditionSchema +// .extend({ +// position: z +// .enum(["shared", "player", "all", "percentAgreement"]) +// .or(z.number().nonnegative().int()) +// .default("player"), +// }) +// .superRefine(refineCondition); + +// export type ConditionType = z.infer; + +export const conditionSchema = z + .discriminatedUnion("comparator", [ + conditionExistsSchema, + conditionDoesNotExistSchema, + conditionEqualsSchema, + conditionDoesNotEqualSchema, + conditionIsAboveSchema, + conditionIsBelowSchema, + conditionIsAtLeastSchema, + conditionIsAtMostSchema, + conditionHasLengthAtLeastSchema, + conditionHasLengthAtMostSchema, + conditionIncludesSchema, + conditionDoesNotIncludeSchema, + conditionMatchesSchema, + conditionDoesNotMatchSchema, + conditionIsOneOfSchema, + conditionIsNotOneOfSchema, +]); +// export type ConditionType = z.infer; -export const discussionSchema = z.object({ - chatType: z.enum(["text", "audio", "video"]), - showNickname: z.boolean(), - showTitle: z.boolean(), -}); -export type DiscussionType = z.infer; +const conditionsSchema = z.array(conditionSchema).nonempty(); // ------------------ Elements ------------------ // @@ -298,7 +458,7 @@ const elementBaseSchema = z hideTime: hideTimeSchema.optional(), showToPositions: showToPositionsSchema.optional(), hideFromPositions: hideFromPositionsSchema.optional(), - conditions: z.array(conditionSchema).optional(), + conditions: conditionsSchema.optional(), tags: z.array(z.string()).optional(), }) .strict(); @@ -307,25 +467,25 @@ const audioSchema = elementBaseSchema.extend({ type: z.literal("audio"), file: fileSchema, // Todo: check that file exists -}); +}).strict(); const imageSchema = elementBaseSchema.extend({ type: z.literal("image"), file: fileSchema, // Todo: check that file exists -}); +}).strict(); const displaySchema = elementBaseSchema.extend({ type: z.literal("display"), reference: referenceSchema, position: positionSelectorSchema, -}); +}).strict(); const promptSchema = elementBaseSchema.extend({ type: z.literal("prompt"), file: fileSchema, shared: z.boolean().optional(), -}); +}).strict(); const promptShorthandSchema = fileSchema.transform((str) => { const newElement = { @@ -339,31 +499,31 @@ const qualtricsSchema = elementBaseSchema.extend({ type: z.literal("qualtrics"), url: urlSchema, params: z.array(z.record(z.string().or(z.number()))).optional(), -}); +}).strict(); const separatorSchema = elementBaseSchema.extend({ type: z.literal("separator"), style: z.enum(["thin", "thick", "regular"]).optional(), -}); +}).strict(); const sharedNotepadSchema = elementBaseSchema.extend({ type: z.literal("sharedNotepad"), -}); +}).strict(); const submitButtonSchema = elementBaseSchema.extend({ type: z.literal("submitButton"), buttonText: z.string().max(50).optional(), -}); +}).strict(); const surveySchema = elementBaseSchema.extend({ type: z.literal("survey"), surveyName: z.string(), // Todo: check that surveyName is a valid survey name -}); +}).strict(); const talkMeterSchema = elementBaseSchema.extend({ type: z.literal("talkMeter"), -}); +}).strict(); const timerSchema = elementBaseSchema.extend({ type: z.literal("timer"), @@ -372,13 +532,13 @@ const timerSchema = elementBaseSchema.extend({ warnTimeRemaining: z.number().gt(0).optional(), // Todo: check that startTime < endTime // Todo: check that warnTimeRemaining < endTime - startTime -}); +}).strict(); const videoSchema = elementBaseSchema.extend({ type: z.literal("video"), url: z.string().url(), // Todo: check that url is a valid url -}); +}).strict(); export const elementSchema = z .discriminatedUnion("type", [ @@ -395,12 +555,14 @@ export const elementSchema = z timerSchema, videoSchema, ]) - .or(promptShorthandSchema); + // .or(promptShorthandSchema); export type ElementType = z.infer; export const elementsSchema = z.array(elementSchema).nonempty(); export type ElementsType = z.infer; +// ------------------ Stages ------------------ // + export const stageSchema = z .object({ name: nameSchema, @@ -419,6 +581,10 @@ export const introExitStepSchema = z elements: elementsSchema, }) .strict(); + +// Todo: add a superrefine that checks that no conditions have position values +// and that no elements have showToPositions or hideFromPositions + export type IntroExitStepType = z.infer; export const playerSchema = z @@ -551,7 +717,6 @@ export type TemplateContextType = z.infer; // list all the possible things that could go into a template const templateableSchemas = z.union([ referenceSchema, - introConditionSchema, conditionSchema, elementSchema, stageSchema, @@ -589,9 +754,9 @@ export const topSchema = z.object({ .min(1, "Templates cannot be empty") .optional(), introSequences: z - .array(introSequenceSchema.or(templateContextSchema)) - .nonempty() - .or(templateContextSchema), + .array(introSequenceSchema) + .nonempty(), + // .or(templateContextSchema), // this is a problem, need to use superrefine to see what type of thing we're looking at - template, or not. treatments: z .array(treatmentSchema.or(templateContextSchema)) .nonempty()