diff --git a/README.md b/README.md index 646dda8..4032892 100644 --- a/README.md +++ b/README.md @@ -405,25 +405,16 @@ Objects generated by task constructors are callable, and receive part of the `Co ```typescript // Another test utility -import { zip } from 'mahler/testing'; +import { runTask } from 'mahler/testing'; -// plusOneAction is of type `Action` -const plusOneAction = plusOne({ target: 3 }); +// This executes the task. Useful for testing +// `runTask` calls the task with the given state and context +// the call will throw if the task condition fails +console.log(await runTask(plusOne, 0, { target: 3 })); // 1 -// `zip` packs the action (including condition) and returns -// a callable function -const doPlusOne = zip(plusOneAction); - -// This executes the action. Useful for testing -console.log(await doPlusOne(0)); // 1 - -// plusTwoMethod is of type `Method` -const plusTwoMethod = plusTwo({ target: 3 }); - -// zip in this case expands the method into its actions so it can be applied -// as if it was a function -const doPlusTwo = zip(plusTwoMethod); -console.log(await doPlusTwo(0)); // 2 +// `runTask` in this case expands the method into its actions and +// applies the actions sequentially +console.log(await runTask(doPlusTwo, 0, { target: 3 })); // 2 ``` Methods are useful for tweaking the plans under certain conditions. They also help reduce the search space. When looking for a plan, the Planner will try methods first, and only if methods fail, proceed to look for action tasks. During planning, the method is expanded recursively into its component actions, so they won't appear on the final plan. diff --git a/lib/testing/index.ts b/lib/testing/index.ts index fa524cb..316ad9d 100644 --- a/lib/testing/index.ts +++ b/lib/testing/index.ts @@ -2,3 +2,4 @@ export * from './mermaid'; export * from './builder'; export * from './stringify'; export * from './zip'; +export * from './run-task'; diff --git a/lib/testing/run-task.spec.ts b/lib/testing/run-task.spec.ts new file mode 100644 index 0000000..24407ed --- /dev/null +++ b/lib/testing/run-task.spec.ts @@ -0,0 +1,57 @@ +import { expect } from '~/test-utils'; +import { Task } from '../task'; +import { runTask } from './run-task'; + +describe('testing/run-task', () => { + const plusOne = Task.of().from({ + // This means the task can only be triggered + // if the system state is below the target + condition: (state, { target }) => state < target, + // The effect of the action is increasing the system + // counter by 1 + effect: (state) => ++state._, + // An optional description. Useful for testing + description: '+1', + }); + + const plusTwo = Task.of().from({ + condition: (state, { target }) => target - state > 1, + method: (_, { target }) => [plusOne({ target }), plusOne({ target })], + description: '+2', + }); + + const plusThree = Task.of().from({ + condition: (state, { target }) => target - state > 2, + method: (_, { target }) => [plusTwo({ target }), plusOne({ target })], + description: '+3', + }); + + const buggedPlusThree = Task.of().from({ + method: (_, { target }) => [plusTwo({ target }), plusOne({ target })], + description: '+3', + }); + + it('runs an action task if the condition is met', async () => { + expect(await runTask(plusOne, 1, { target: 2 })).to.equal(2); + expect(await runTask(plusOne, 0, { target: 2 })).to.equal(1); + }); + + it('throws if the condition of an action task is not met', async () => { + await expect(runTask(plusOne, 2, { target: 2 })).to.be.rejected; + await expect(runTask(plusOne, 3, { target: 2 })).to.be.rejected; + }); + + it('runs a method task by expanding its actions', async () => { + expect(await runTask(plusTwo, 0, { target: 2 })).to.equal(2); + expect(await runTask(plusTwo, 1, { target: 4 })).to.equal(3); + expect(await runTask(plusThree, 1, { target: 4 })).to.equal(4); + }); + + it('throws if a condition of a method task is not met', async () => { + await expect(runTask(plusTwo, 2, { target: 2 })).to.be.rejected; + await expect(runTask(plusTwo, 3, { target: 4 })).to.be.rejected; + await expect(runTask(plusThree, 2, { target: 4 })).to.be.rejected; + // the condition for the plusTwo call should fail here + await expect(runTask(buggedPlusThree, 3, { target: 4 })).to.be.rejected; + }); +}); diff --git a/lib/testing/run-task.ts b/lib/testing/run-task.ts new file mode 100644 index 0000000..89d1978 --- /dev/null +++ b/lib/testing/run-task.ts @@ -0,0 +1,23 @@ +import { zip } from './zip'; + +import type { Task, TaskOp, TaskArgs } from '../task'; +import type { Root, PathType } from '../path'; + +/** + * Run the task on a given state and context + * + * If the given task is a Method task, it expands the task first in + * a sequential fashion and runs all the returned actions + */ +export async function runTask< + TState = unknown, + TPath extends PathType = Root, + TOp extends TaskOp = 'update', +>( + task: Task, + state: TState, + args: TaskArgs, +): Promise { + const doTask = zip(task(args)); + return doTask(state); +} diff --git a/lib/testing/zip.ts b/lib/testing/zip.ts index 8220c0d..f48fc08 100644 --- a/lib/testing/zip.ts +++ b/lib/testing/zip.ts @@ -4,7 +4,9 @@ import { Method } from '../task'; function expand(s: T, method: Method): Array> { if (!method.condition(s)) { - return []; + throw new Error( + `${method.description}: condition not met for expanding method`, + ); } const res = method(s); @@ -35,7 +37,9 @@ export function zip( for (const action of actions) { if (!action.condition(s)) { - return s; + throw new Error( + `${action.description}: condition not met for running action`, + ); } await action(ref); }