Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/app-compose/src/compose/__tests__/compose.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const double = (x: number) => x * 2
describe("multi layer, compute shapes", () => {
const rootTag = createTag<number>({ name: "root" })

const app = compose()
let app = compose()
.meta({ name: "bench" })
.step(bind(rootTag, literal(1)))

Expand All @@ -30,7 +30,7 @@ describe("multi layer, compute shapes", () => {
},
})

app
app = app
.step([bind(a, shape(l, double)), bind(b, shape(l, double))])
.step([bind(c, shape(l, double)), bind(d, shape(l, double))])
.step(task)
Expand All @@ -48,12 +48,12 @@ describe("multi layer, compute shapes", () => {
})

describe("single layer, wide sequence", () => {
const layer = compose()
let layer = compose()

for (let i = 0; i < 100; i++) {
const task = createTask({ name: `task:${i}`, run: { fn: vi.fn() } })

layer.step(task)
layer = layer.step(task)
}

const app = compose().meta({ name: "bench" }).step(layer)
Expand Down
58 changes: 58 additions & 0 deletions packages/app-compose/src/compose/__tests__/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { literal } from "@computable"
import { bind, createTag, createTask } from "@runnable"
import { LIBRARY_NAME } from "@shared"
import { describe, expect, it } from "vitest"
import { compose } from "../compose"

describe("compose", () => {
describe("step", () => {
it("rejects non-composable arguments", () => {
const app = compose()
const message = `${LIBRARY_NAME} Invalid argument passed to step.`

expect(
// @ts-expect-error
() => app.step({ value: 123 }),
).toThrowError(message)
})
})

describe("guard", () => {
it("throws on warning graph", () => {
const tag = createTag<number>({ name: "alpha" })
const app = compose().step(bind(tag, literal(1)))

const message = `Unused Binding found with name Tag[alpha] in step root > #1.`

expect(() => app.guard()).toThrowError(message)
})

it("throws on error graph", () => {
const tag = createTag<number>({ name: "alpha" })

const app = compose()
.step(bind(tag, literal(1)))
.step(bind(tag, literal(2)))

const message = `A duplicate Binding found with name Tag[alpha] in step root > #2.`

expect(() => app.guard()).toThrowError(message)
})
})

describe("graph", () => {
it("provides correct graph structure", () => {
const task = createTask({ name: "task", run: { fn: () => {} } })
const tag = createTag<number>({ name: "alpha" })

const graph = compose()
.meta({ name: "app" })
.step(bind(tag, literal(1)))
.step(task)
.graph()

const result = { type: "seq", meta: { name: "app" }, children: [{ type: "run" }, { type: "run" }] }
expect(graph).toMatchObject(result)
})
})
})
81 changes: 72 additions & 9 deletions packages/app-compose/src/compose/__tests__/guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,66 @@ describe("unsatisfied guard", () => {

it("calls error on binding missing task context", () => {
const provider = createTask({ name: "alpha", run: { fn: () => 0 } })
const intermediate = createTag<number>({ name: "beta" })
const intermediate = createTag<string>({ name: "beta" })

const app = compose().step(bind(intermediate, provider.result))
const app = compose().step(bind(intermediate, provider.status))

guard(app[Node$])

const message =
"Unsatisfied dependencies found for Binding with name Tag[beta] in step root > #1: missing Task[alpha]::result."
"Unsatisfied dependencies found for Binding with name Tag[beta] in step root > #1: missing Task[alpha]::status."
expect(handler.error).toHaveBeenCalledExactlyOnceWith(message)
})

it("calls error on task missing context in its local con thread", () => {
const provider = createTag<number>({ name: "alpha" })

const taskA = createTask({ name: "A", run: { fn: vi.fn(), context: provider.value } })
const taskB = createTask({ name: "B", run: { fn: vi.fn(), context: provider.value } })

const app = compose().step([
compose()
.step(bind(provider, literal(1)))
.step(taskA),
compose().step(taskB),
])

guard(app[Node$])

const message =
"Unsatisfied dependencies found for Task with name Task[B] in step root > #1 > #2 > #1: missing Tag[alpha]."
expect(handler.error).toHaveBeenCalledExactlyOnceWith(message)
})

describe("missing list", () => {
const alpha = createTag<number>({ name: "alpha" })
const beta = createTag<number>({ name: "beta" })

const task = createTask({ name: "beta", run: { fn: vi.fn(), context: [alpha.value, beta.value] } })

it("calls error with a full missing list", () => {
const app = compose().step(task)

guard(app[Node$])

const message =
"Unsatisfied dependencies found for Task with name Task[beta] in step root > #1: missing Tag[alpha], Tag[beta]."
expect(handler.error).toHaveBeenCalledExactlyOnceWith(message)
})

it("only reports missing dependencies for the task", () => {
const app = compose()
.step(bind(alpha, literal(1)))
.step(task)

guard(app[Node$])

const message =
"Unsatisfied dependencies found for Task with name Task[beta] in step root > #2: missing Tag[beta]."
expect(handler.error).toHaveBeenCalledExactlyOnceWith(message)
})
})

it("provides rich path", () => {
const provider = createTag<number>({ name: "alpha" })
const consumer = createTask({ name: "beta", run: { fn, context: provider.value } })
Expand Down Expand Up @@ -187,14 +236,28 @@ describe("unsatisfied guard", () => {
})

describe("unused guard", () => {
const tag = createTag<number>({ name: "alpha" })
const alpha = createTag<number>({ name: "alpha" })

it("warns on unused binding", () => {
const node = compose().step(bind(tag, literal(1)))
it("warns on unused binding (seq)", () => {
const node = compose().step(bind(alpha, literal(1)))

guard(node[Node$])

const message = "Unused Binding found with name: Tag[alpha] in step root > #1."
const message = "Unused Binding found with name Tag[alpha] in step root > #1."
expect(handler.warn).toHaveBeenCalledWith(message)
})

it("warns on unused binding (con)", () => {
const beta = createTag<number>({ name: "alpha" })
const task = createTask({ name: "test", run: { fn: vi.fn(), context: beta.value } })

const node = compose()
.step([bind(alpha, literal(1)), bind(beta, literal(2))])
.step(task)

guard(node[Node$])

const message = "Unused Binding found with name Tag[alpha] in step root > #1 > #1."
expect(handler.warn).toHaveBeenCalledWith(message)
})

Expand All @@ -204,12 +267,12 @@ describe("unused guard", () => {
.step(
compose()
.meta({ name: "Layout" })
.step(bind(tag, literal(2))),
.step(bind(alpha, literal(2))),
)

guard(node[Node$])

const message = "Unused Binding found with name: Tag[alpha] in step MyApp > #1 (Layout) > #1."
const message = "Unused Binding found with name Tag[alpha] in step MyApp > #1 (Layout) > #1."
expect(handler.warn).toHaveBeenCalledWith(message)
})
})
35 changes: 32 additions & 3 deletions packages/app-compose/src/compose/__tests__/observer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createTask } from "@runnable"
import { literal, shape } from "@computable"
import { bind, createTag, createTask } from "@runnable"
import { describe, expect, it, vi } from "vitest"
import { compose } from "../compose"
import type { ComposeHookMap } from "../observer"
Expand All @@ -7,13 +8,41 @@ describe("observer", () => {
describe("onTaskFail", () => {
it("is called when a task fails", async () => {
const onTaskFail = vi.fn<ComposeHookMap["onTaskFail"]>()
const task = createTask({ name: "alpha", run: { fn: () => Promise.reject("test") } })
const task = createTask({ name: "alpha", run: { fn: () => Promise.reject("error") } })

const app = compose().meta({ name: "app", hooks: { onTaskFail } }).step(task)

await app.run()

expect(onTaskFail).toHaveBeenCalledExactlyOnceWith({ task, error: "test" })
expect(onTaskFail).toHaveBeenCalledExactlyOnceWith({ task, error: "error" })
})

it("is not called when a task succeeds", async () => {
const onTaskFail = vi.fn<ComposeHookMap["onTaskFail"]>()
const task = createTask({ name: "alpha", run: { fn: () => "okay" } })

const app = compose().meta({ name: "app", hooks: { onTaskFail } }).step(task)

await app.run()

expect(onTaskFail).not.toHaveBeenCalled()
})

it("is not called when another runnable fails", async () => {
const onTaskFail = vi.fn<ComposeHookMap["onTaskFail"]>()

const tag = createTag<number>({ name: "test" })
const task = createTask({ name: "alpha", run: { fn: () => "okay", context: tag.value } })

const value = shape(literal(1), () => {
throw new Error("error")
})

const app = compose().meta({ name: "app", hooks: { onTaskFail } }).step(bind(tag, value)).step(task)

await app.run()

expect(onTaskFail).not.toHaveBeenCalled()
})
})
})
3 changes: 2 additions & 1 deletion packages/app-compose/src/compose/__tests__/resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { build, literal, optional, reference, Spot$, type SpotInternal } from "@computable"
import { LIBRARY_NAME } from "@shared"
import { describe, expect, it } from "vitest"
import { resolve } from "../resolver"

Expand Down Expand Up @@ -83,6 +84,6 @@ describe("throws", () => {
it("on raw literal value in build context", () => {
const ctx = build({ a: 42 })

expect(() => resolve(ctx)).toThrow("Literal value found in context")
expect(() => resolve(ctx)).toThrow(`${LIBRARY_NAME} Literal value found in context`)
})
})
12 changes: 12 additions & 0 deletions packages/app-compose/src/compose/__tests__/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,18 @@ describe("runner", () => {

expect(log).toStrictEqual(["a", "b"]) // b isn't locked by a
})

it("exposes concurrent tasks in scope", async () => {
const taskA = createTask({ name: "a", run: { fn: () => "a" } })
const taskB = createTask({ name: "b", run: { fn: () => "b" } })

const app = compose().step([taskA, taskB])

const scope = await run(app[Node$])

expect(scope.get(taskA.result)).toBe("a")
expect(scope.get(taskB.status)).toBe("done")
})
})

describe("nested compose", () => {
Expand Down
15 changes: 6 additions & 9 deletions packages/app-compose/src/compose/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,23 @@ type Composer = {
const normalize = (arg: Composable): ComposeNode => {
if (Node$ in arg) return arg[Node$]
else if (Execute$ in arg) return { type: "run", value: arg as Runnable }
else throw new Error(/* TODO: better error messaging, but unreachable given correct types */)
else throw new Error(`${LIBRARY_NAME} Invalid argument passed to step.`)
}

const raiseOnGuard = (message: string): never => {
throw new Error(message)
}

const builder = (node: ComposeInner): Composer => {
const self: Composer = {
return {
[Node$]: node,

meta: (meta) => ((node.meta = { ...node.meta, ...meta }), self),
meta: (meta) => builder({ ...node, meta: { ...node.meta, ...meta } }),

step: (arg) => {
if (Array.isArray(arg)) node.children.push({ type: "con", children: arg.map(normalize) })
else node.children.push(normalize(arg))

return self
if (Array.isArray(arg))
return builder({ ...node, children: [...node.children, { type: "con", children: arg.map(normalize) }] })
return builder({ ...node, children: [...node.children, normalize(arg)] })
},

run: () => {
Expand All @@ -64,8 +63,6 @@ const builder = (node: ComposeInner): Composer => {

graph: () => graph(node),
}

return self
}

const compose = (): Composer => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-compose/src/compose/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const createNotify = (handler: GuardHandler) => ({
},

unused: ({ type, name, stack }: NotifyContext) => {
const message = `Unused ${TypeMap[type]} found with name: ${NameMap[type]}[${name}] in step ${stackToName(stack)}.`
const message = `Unused ${TypeMap[type]} found with name ${NameMap[type]}[${name}] in step ${stackToName(stack)}.`
handler.warn(message)
},
})
Expand Down
12 changes: 7 additions & 5 deletions packages/app-compose/src/computable/__tests__/shape.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest"
import { createComputer } from "../computer"
import { Missing$, type SpotInternal } from "../definition"
import { Compute$, Missing$, type SpotInternal } from "../definition"
import { literal } from "../literal"
import { shape } from "../shape"

Expand Down Expand Up @@ -38,13 +38,15 @@ describe("shape", () => {
})

it("skips mapping over missing value", () => {
const fn = vi.fn((x: number) => x + 1)
const fn = vi.fn((x: unknown) => x)
const value = shape(literal(0), fn) as SpotInternal

const a = shape(literal(1), () => Missing$ as unknown as number)
const b = shape(a, fn)
// note: unrealistic, computer does not run `fn` if `build` is missing
value[Compute$].unshift(() => Missing$)

compute(b as SpotInternal)
const result = compute(value as SpotInternal)

expect(fn).not.toHaveBeenCalled()
expect(result).toBe(Missing$)
})
})
2 changes: 1 addition & 1 deletion packages/app-compose/src/computable/optional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Optional$, type Spot, type SpotInternal } from "./definition"
import { proxy } from "./lens"

const optional = <T>(spot: Spot<T>): Spot<T | undefined> => {
const self = proxy.self(spot as SpotInternal<T | undefined>)
const self = proxy.self(spot as SpotInternal<T>)
const updated: SpotInternal<T | undefined> = { ...self, [Optional$]: true }

return updated
Expand Down
Loading