|
| 1 | +/*! |
| 2 | + * Licensed to the Apache Software Foundation (ASF) under one |
| 3 | + * or more contributor license agreements. See the NOTICE file |
| 4 | + * distributed with this work for additional information |
| 5 | + * regarding copyright ownership. The ASF licenses this file |
| 6 | + * to you under the Apache License, Version 2.0 (the |
| 7 | + * "License"); you may not use this file except in compliance |
| 8 | + * with the License. You may obtain a copy of the License at |
| 9 | + * |
| 10 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 11 | + * |
| 12 | + * Unless required by applicable law or agreed to in writing, |
| 13 | + * software distributed under the License is distributed on an |
| 14 | + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 15 | + * KIND, either express or implied. See the License for the |
| 16 | + * specific language governing permissions and limitations |
| 17 | + * under the License. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, expect, it } from "vitest"; |
| 21 | +import { findTaskCycle, type TaskEdge } from "../../src/sdk/cycle.js"; |
| 22 | + |
| 23 | +/** `"a>b c>d"` as edges, so a graph reads as the shape it is. */ |
| 24 | +function edges(spec: string): TaskEdge[] { |
| 25 | + if (spec === "") return []; |
| 26 | + return spec.split(" ").map((pair) => { |
| 27 | + const [upstream, downstream] = pair.split(">"); |
| 28 | + return { upstream: upstream!, downstream: downstream! }; |
| 29 | + }); |
| 30 | +} |
| 31 | + |
| 32 | +describe("findTaskCycle", () => { |
| 33 | + it.each([ |
| 34 | + ["no tasks at all", [], ""], |
| 35 | + ["one task and no edges", ["a"], ""], |
| 36 | + ["a chain", ["a", "b", "c"], "a>b b>c"], |
| 37 | + ["a diamond, where one task is reached twice", ["a", "b", "c", "d"], "a>b a>c b>d c>d"], |
| 38 | + ["a fan-out", ["a", "b", "c"], "a>b a>c"], |
| 39 | + ["a fan-in", ["a", "b", "c"], "a>c b>c"], |
| 40 | + ["two disconnected components", ["a", "b", "c", "d"], "a>b c>d"], |
| 41 | + ["an isolated task beside a chain", ["a", "b", "lonely"], "a>b"], |
| 42 | + ])("finds none in %s", (_label, taskIds, spec) => { |
| 43 | + expect(findTaskCycle(taskIds, edges(spec))).toBeUndefined(); |
| 44 | + }); |
| 45 | + |
| 46 | + it.each([ |
| 47 | + ["a self-edge", ["a"], "a>a", ["a", "a"]], |
| 48 | + ["a two-task cycle", ["a", "b"], "a>b b>a", ["a", "b", "a"]], |
| 49 | + ["a three-task cycle", ["a", "b", "c"], "a>b b>c c>a", ["a", "b", "c", "a"]], |
| 50 | + ])("reports the tasks on %s", (_label, taskIds, spec, expected) => { |
| 51 | + expect(findTaskCycle(taskIds, edges(spec))).toEqual(expected); |
| 52 | + }); |
| 53 | + |
| 54 | + it("reports only the cycle, not the path that led into it", () => { |
| 55 | + // `entry` reaches the cycle but is not on it, so naming it would send a |
| 56 | + // reader looking for an edge that does not exist. |
| 57 | + expect(findTaskCycle(["entry", "a", "b"], edges("entry>a a>b b>a"))).toEqual(["a", "b", "a"]); |
| 58 | + }); |
| 59 | + |
| 60 | + it("finds a cycle in a component the search reaches second", () => { |
| 61 | + expect(findTaskCycle(["a", "b", "x", "y"], edges("a>b x>y y>x"))).toEqual(["x", "y", "x"]); |
| 62 | + }); |
| 63 | + |
| 64 | + it("ignores an edge naming a task the Dag does not hold", () => { |
| 65 | + // The Dag reports an unknown endpoint in its own terms; it cannot be part |
| 66 | + // of a cycle among the tasks that do exist. |
| 67 | + expect(findTaskCycle(["a"], edges("a>ghost ghost>a"))).toBeUndefined(); |
| 68 | + }); |
| 69 | + |
| 70 | + it("handles a chain far longer than the call stack would take", () => { |
| 71 | + const taskIds = Array.from({ length: 50_000 }, (_, i) => `t${i}`); |
| 72 | + const chain = taskIds.slice(0, -1).map((upstream, i) => ({ |
| 73 | + upstream, |
| 74 | + downstream: taskIds[i + 1]!, |
| 75 | + })); |
| 76 | + |
| 77 | + expect(findTaskCycle(taskIds, chain)).toBeUndefined(); |
| 78 | + expect( |
| 79 | + findTaskCycle(taskIds, [...chain, { upstream: "t49999", downstream: "t0" }]), |
| 80 | + ).toHaveLength(taskIds.length + 1); |
| 81 | + }); |
| 82 | + |
| 83 | + it("does not re-walk a task the search has already finished with", () => { |
| 84 | + // A wide diamond lattice: exponential if finished tasks were revisited. |
| 85 | + const taskIds = ["start"]; |
| 86 | + const wide: TaskEdge[] = []; |
| 87 | + let previous = "start"; |
| 88 | + for (let layer = 0; layer < 30; layer += 1) { |
| 89 | + const left = `l${layer}`; |
| 90 | + const right = `r${layer}`; |
| 91 | + const join = `j${layer}`; |
| 92 | + taskIds.push(left, right, join); |
| 93 | + wide.push( |
| 94 | + { upstream: previous, downstream: left }, |
| 95 | + { upstream: previous, downstream: right }, |
| 96 | + { upstream: left, downstream: join }, |
| 97 | + { upstream: right, downstream: join }, |
| 98 | + ); |
| 99 | + previous = join; |
| 100 | + } |
| 101 | + |
| 102 | + expect(findTaskCycle(taskIds, wide)).toBeUndefined(); |
| 103 | + }); |
| 104 | +}); |
0 commit comments