Skip to content

Instantly share code, notes, and snippets.

@rob-gordon
Created April 11, 2023 22:37
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rob-gordon/e3c4572cb9e1d4c2f537c751b5faa09a to your computer and use it in GitHub Desktop.
Save rob-gordon/e3c4572cb9e1d4c2f537c751b5faa09a to your computer and use it in GitHub Desktop.
feedback interactions based on loop
import { describe, test, expect } from "vitest";
import { CreateLoop } from "./CreateLoop";
/**
* TODO:
* - add a before all hook
* - add a after all hook
* - add a before state set hook
* - add a after state set hook
* - abiity to preprocess arguments
*/
describe("CreateLoop", () => {
test("you can create one", () => {
const loop = new CreateLoop({ name: "test", tasks: [] }, {});
expect(loop.getState()).toEqual({ name: "test", tasks: [] });
});
// can add an action
test("you can add an action", () => {
const loop = new CreateLoop(
{ name: "test", tasks: [] },
{
test: {
execute: (state, args) => {
state.name = args;
},
},
}
);
expect(loop.getActions()).toEqual(["test"]);
});
// you can run a task to alter the state
test("you can run a task to alter the state", async () => {
const loop = new CreateLoop(
{ name: "A", tasks: [["changeName", "B"]] },
{
changeName: {
execute: (state, args) => {
state.name = args;
},
},
}
);
await loop.next();
expect(loop.getState()).toEqual({ name: "B", tasks: [] });
});
// you can run a task to alter the state (async)
test("you can run a task to alter the state (async)", async () => {
const loop = new CreateLoop(
{ name: "A", tasks: [["changeName", "B"]] },
{
changeName: {
execute: async (state, args) => {
await new Promise((resolve) => setTimeout(resolve, 100));
state.name = args;
},
},
}
);
await loop.next();
expect(loop.getState()).toEqual({ name: "B", tasks: [] });
});
// you can control behavior with after hook
test("you can run something after a task", async () => {
const loop = new CreateLoop(
{ i: 10, tasks: [["decrement", null]] },
{
decrement: {
execute: (state, args) => {
state.i -= 1;
},
},
},
{
after: (state) => {
if (state.i > 0) {
state.tasks.push(["decrement", null]);
}
},
}
);
await loop.run();
expect(loop.getState()).toEqual({ i: 0, tasks: [] });
});
test("you can exit early by wiping the task list", async () => {
const loop = new CreateLoop(
{ i: 10, tasks: [["decrement", null]] },
{
decrement: {
execute: async (state, args) => {
state.i -= 1;
},
},
},
{
after: (state) => {
if (state.i === 5) {
state.tasks = [];
} else if (state.i > 0) {
state.tasks.push(["decrement", null]);
}
},
}
);
await loop.run();
expect(loop.getState()).toEqual({ i: 5, tasks: [] });
});
test("an action can preprocess arguments with a preexecute fn", async () => {
const loop = new CreateLoop(
{ i: 10, tasks: [["decrement", 1]] },
{
decrement: {
preexecute: (state, args) => {
// force the argument to be 10 instead of 1
return 10;
},
execute: async (state, args) => {
state.i -= args;
},
},
}
);
await loop.next();
expect(loop.getState()).toEqual({ i: 0, tasks: [] });
});
});
import produce, { Draft } from "immer";
import cli from "@clack/prompts";
interface RequiredState {
tasks: [string, any][];
}
type Action<State extends RequiredState> = {
/**
* This is a function that runs before the execute function. It can be used to
* validate the arguments, or to do some other work before the execute function
*/
preexecute?: (state: Draft<State>, args: any) => any | Promise<any>;
/**
* This is the function that actually alters the state
*/
execute: (state: Draft<State>, args: any) => void | Promise<void>;
};
type Options<State extends RequiredState> = {
/**
* This is a function that runs after the state has been updated
* in the execute function. It can be used to do some other work
* after the execute function
*/
after?: (state: Draft<State>) => void | Promise<void>;
};
const baseActions: Record<string, Action<RequiredState>> = {};
const defaultOptions: Options<RequiredState> = {};
export class CreateLoop<T extends RequiredState> {
state: T;
actions: Record<string, Action<T>>;
options?: Options<T>;
constructor(
state: T,
actions: Record<string, Action<T>>,
options?: Options<T>
) {
this.state = state;
this.actions = { ...baseActions, ...actions };
this.options = { ...defaultOptions, ...options };
}
getState() {
return this.state;
}
getActions() {
return Object.keys(this.actions).sort();
}
async next() {
const [task, ...tasks] = this.state.tasks.slice();
this.state = { ...this.state, tasks };
let [actionKey, actionArgs] = task;
const action = this.actions[actionKey];
if (action) {
if (action.preexecute) {
let result = await produce(this.state, async (draft) => {
return action.preexecute(draft, actionArgs);
});
// if result is defined, use it as the actionArgs
if (result !== undefined) {
actionArgs = result;
}
}
this.state = await produce(this.state, async (draft) => {
await action.execute(draft, actionArgs);
});
} else {
throw new Error(`Action ${actionKey} not found`);
}
if (this.options?.after) {
this.state = await produce(this.state, async (draft) => {
await this.options.after(draft);
});
}
}
async run() {
while (this.state.tasks.length > 0) {
await this.next();
}
}
}
export async function loop<
State extends RequiredState,
ActionKey extends string
>(state: State, userActions: Record<ActionKey, Action<State>>) {
cli.intro("Welcome to the loop");
// add null task if tasks empty
if (state.tasks.length === 0) {
state.tasks.push(["NULL", null]);
}
let currentState = state;
const actions = { ...baseActions, ...userActions };
while (currentState.tasks.length > 0) {
console.log(JSON.stringify(currentState, null, 2));
const currentTask = currentState.tasks.pop();
const [actionKey, actionArgs] = currentTask;
const action = actions[actionKey];
if (action) {
const newState = await produce(currentState, async (draft) => {
await action.execute(draft, actionArgs);
});
// confirm new state
console.log(JSON.stringify(newState, null, 2));
const result = await cli.confirm({
message: "Apply new state?",
});
if (result) {
currentState = newState;
console.log("Made it here!");
console.log(JSON.stringify(currentState, null, 2));
}
}
}
cli.outro("Goodbye");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment