Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add and pass regression tests for PerseusItem parser #1907

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
19 changes: 12 additions & 7 deletions packages/perseus/src/perseus-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,14 @@ export type PerseusItem = {
hints: ReadonlyArray<Hint>;
// Details about the tools the user might need to answer the question
answerArea: PerseusAnswerArea | null | undefined;
// The version of the item. Not used by Perseus
itemDataVersion: Version;
// Deprecated field
/**
* The version of the item.
* @deprecated Not used.
*/
itemDataVersion: any;
/**
* @deprecated Superseded by per-widget answers.
*/
answer: any;
};

Expand Down Expand Up @@ -1034,17 +1039,17 @@ export type PerseusMatcherWidgetOptions = {
export type PerseusMatrixWidgetAnswers = ReadonlyArray<ReadonlyArray<number>>;
export type PerseusMatrixWidgetOptions = {
// Translatable Text; Shown before the matrix
prefix: string;
prefix?: string | undefined;
// Translatable Text; Shown after the matrix
suffix: string;
suffix?: string | undefined;
// A data matrix representing the "correct" answers to be entered into the matrix
answers: PerseusMatrixWidgetAnswers;
// The coordinate location of the cursor position at start. default: [0, 0]
cursorPosition: ReadonlyArray<number>;
cursorPosition?: ReadonlyArray<number> | undefined;
// The coordinate size of the matrix. Only supports 2-dimensional matrix. default: [3, 3]
matrixBoardSize: ReadonlyArray<number>;
// Whether this is meant to statically display the answers (true) or be used as an input field, graded against the answers
static: boolean;
static?: boolean | undefined;
};

export type PerseusMeasurerWidgetOptions = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {success} from "../result";

import type {Parser} from "../parser-types";

export function defaulted<T, Default extends T | null | undefined>(
export function defaulted<T, Default>(
parser: Parser<T>,
fallback: (missingValue: null | undefined) => Default,
): Parser<T | Default> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export * from "./number";
export * from "./object";
export * from "./optional";
export * from "./pair";
export * from "./pipe-parsers";
export * from "./record";
export * from "./string";
export * from "./trio";
export * from "./union";
export * from "./unknown";
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {success} from "../result";

import {composeParsers} from "./compose-parsers";
import {number} from "./number";
import {pair} from "./pair";
import {pipeParsers} from "./pipe-parsers";
import {string} from "./string";
import {anyFailure, ctx, parseFailureWith} from "./test-helpers";

Expand Down Expand Up @@ -72,9 +72,9 @@ describe("pair", () => {
});

it("returns the parsed values from each of its sub-parsers", () => {
const increment = composeParsers(number, (x, ctx) =>
const increment = pipeParsers(number).then((x, ctx) =>
ctx.success(x + 1),
);
).parser;
const incrementBoth = pair(increment, increment);
expect(incrementBoth([1, 5], ctx())).toEqual(success([2, 6]));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {success} from "../result";

import {pipeParsers} from "./pipe-parsers";
import {string} from "./string";
import {anyFailure, ctx} from "./test-helpers";

import type {PartialParser} from "../parser-types";

describe("pipeParsers given a single parser", () => {
const string2 = pipeParsers(string).parser;
it("accepts a valid value", () => {
expect(string2("abc", ctx())).toEqual(success("abc"));
});

it("rejects an invalid value", () => {
expect(string2(99, ctx())).toEqual(anyFailure);
});
});

describe("pipeParsers given a chain of parsers", () => {
const stringToNumber: PartialParser<string, number> = (rawVal, ctx) => {
if (/^\d+$/.test(rawVal)) {
return ctx.success(parseInt(rawVal, 10));
}
return ctx.failure("a numeric string", rawVal);
};

const numericString = pipeParsers(string).then(stringToNumber).parser;

it("accepts a valid value", () => {
expect(numericString("7", ctx())).toEqual(success(7));
});

it("rejects a value that fails the first parser", () => {
expect(numericString(99, ctx())).toEqual(anyFailure);
});

it("rejects a value that fails the second parser", () => {
expect(numericString("abc", ctx())).toEqual(anyFailure);
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import {isFailure} from "../result";

import type {
ParsedValue,
PartialParser,
ParseContext,
ParsedValue,
Parser,
PartialParser,
} from "../parser-types";

export function composeParsers<
export function pipeParsers<T>(p: Parser<T>): ParserPipeline<T> {
return new ParserPipeline(p);
}

export class ParserPipeline<T> {
constructor(public readonly parser: Parser<T>) {}

then<U>(nextParser: PartialParser<T, U>): ParserPipeline<U> {
return new ParserPipeline<U>(composeParsers(this.parser, nextParser));
}
}

function composeParsers<
A extends Parser<any>,
B extends PartialParser<ParsedValue<A>, any>,
>(parserA: A, parserB: B): Parser<ParsedValue<B>> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Test: pipeParsers()...then().parser returns the expected type

import {pipeParsers} from "./pipe-parsers";
import {string} from "./string";

import type {Parser, PartialParser} from "../parser-types";

const stringToNumber = summon<PartialParser<string, number>>();
const numberToBoolean = summon<PartialParser<number, boolean>>();

{
pipeParsers(string).then(stringToNumber).then(numberToBoolean)
.parser satisfies Parser<boolean>;
}

{
// @ts-expect-error - partial parser types don't match
pipeParsers(string).then(stringToNumber).then(stringToNumber).parser;
}

{
const p = pipeParsers(string)
.then(stringToNumber)
.then(numberToBoolean).parser;
// @ts-expect-error - return value is not assignable to Parser<string>
p satisfies Parser<string>;
}

function summon<T>(): T {
return "fake summoned value" as any;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {success} from "../result";

import {boolean} from "./boolean";
import {composeParsers} from "./compose-parsers";
import {number} from "./number";
import {pipeParsers} from "./pipe-parsers";
import {string} from "./string";
import {anyFailure, ctx, parseFailureWith} from "./test-helpers";
import {trio} from "./trio";
Expand Down Expand Up @@ -86,10 +86,10 @@ describe("trio()", () => {
});

it("returns the parsed values from each of its sub-parsers", () => {
const increment = composeParsers(number, (x, ctx) =>
const increment = pipeParsers(number).then((x, ctx) =>
ctx.success(x + 1),
);
const incrementBoth = trio(increment, increment, increment);
expect(incrementBoth([1, 5, 10], ctx())).toEqual(success([2, 6, 11]));
).parser;
const incrementAll = trio(increment, increment, increment);
expect(incrementAll([1, 5, 10], ctx())).toEqual(success([2, 6, 11]));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type {Parser} from "../parser-types";

export const unknown: Parser<unknown> = (rawValue, ctx) =>
ctx.success(rawValue);
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
optional,
string,
} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseWidget} from "./widget";

Expand All @@ -19,7 +20,7 @@ export const parseCategorizerWidget: Parser<CategorizerWidget> = parseWidget(
items: array(string),
categories: array(string),
randomizeItems: boolean,
static: boolean,
static: defaulted(boolean, () => false),
values: array(number),
highlightLint: optional(boolean),
linterContext: optional(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
object,
string,
} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseWidget} from "./widget";

Expand All @@ -15,7 +16,7 @@ export const parseDropdownWidget: Parser<DropdownWidget> = parseWidget(
constant("dropdown"),
object({
placeholder: string,
static: boolean,
static: defaulted(boolean, () => false),
choices: array(
object({
content: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import {
boolean,
constant,
enumeration,
number,
object,
optional,
pipeParsers,
string,
union,
} from "../general-purpose-parsers";

import {parseWidget} from "./widget";
Expand All @@ -21,7 +24,9 @@ const parseAnswerForm: Parser<PerseusExpressionAnswerForm> = object({
form: boolean,
simplify: boolean,
considered: enumeration("correct", "wrong", "ungraded"),
key: optional(string),
key: pipeParsers(optional(union(string).or(number).parser)).then(
(key, ctx) => ctx.success(String(key)),
).parser,
});

export const parseExpressionWidget: Parser<ExpressionWidget> = parseWidget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
record,
string,
} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import {parsePerseusRenderer} from "./perseus-renderer";
import {parseWidget} from "./widget";
Expand All @@ -17,7 +18,7 @@ import type {GradedGroupWidget} from "../../../perseus-types";
import type {Parser} from "../parser-types";

export const parseGradedGroupWidgetOptions = object({
title: string,
title: defaulted(string, () => ""),
hasHint: optional(nullable(boolean)),
// This module has an import cycle with parsePerseusRenderer.
// The anonymous function below ensures that we don't try to access
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import {
array,
boolean,
number,
object,
optional,
record,
string,
} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseImages} from "./images-map";
import {parseWidgetsMap} from "./widgets-map";

import type {Hint} from "../../../perseus-types";
Expand All @@ -16,13 +16,7 @@ import type {Parser} from "../parser-types";
export const parseHint: Parser<Hint> = object({
replace: optional(boolean),
content: string,
widgets: parseWidgetsMap,
widgets: defaulted(parseWidgetsMap, () => ({})),
metadata: optional(array(string)),
images: record(
string,
object({
width: number,
height: number,
}),
),
images: parseImages,
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
string,
union,
} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseWidget} from "./widget";

Expand All @@ -23,6 +24,6 @@ export const parseIframeWidget: Parser<IFrameWidget> = parseWidget(
height: union(number).or(string).parser,
allowFullScreen: boolean,
allowTopNavigation: optional(boolean),
static: boolean,
static: defaulted(boolean, () => false),
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {number, object, record, string} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import type {PerseusImageDetail} from "../../../perseus-types";
import type {Parser} from "../parser-types";

export const parseImages: Parser<{[key: string]: PerseusImageDetail}> =
defaulted(
record(
string,
object({
width: number,
height: number,
}),
),
() => ({}),
);
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,24 @@ import {
constant,
number,
object,
optional,
string,
} from "../general-purpose-parsers";
import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseWidget} from "./widget";

import type {MatrixWidget} from "../../../perseus-types";
import type {Parser} from "../parser-types";

export const parseMatrixWidget: Parser<MatrixWidget> = parseWidget(
constant("matrix"),
defaulted(constant("matrix"), () => "matrix"),
object({
prefix: string,
suffix: string,
prefix: optional(string),
suffix: optional(string),
answers: array(array(number)),
cursorPosition: array(number),
cursorPosition: optional(array(number)),
matrixBoardSize: array(number),
static: boolean,
static: optional(boolean),
}),
);
Loading
Loading