150 lines
8.9 KiB
Python
150 lines
8.9 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import json
|
||
|
|
import subprocess
|
||
|
|
import unittest
|
||
|
|
|
||
|
|
from hub_support import PLAN
|
||
|
|
|
||
|
|
HOUR = 3600
|
||
|
|
NOW = 100 * HOUR
|
||
|
|
PLAN_PROBE = """
|
||
|
|
const P = require(process.argv[1]);
|
||
|
|
const H = 3600;
|
||
|
|
const now = 100 * H;
|
||
|
|
const row = (task, fields) => ({task, kind: "task", status: "queued", ended: false, start: null, end: null, points: null, wsjf: null, value: null, worker: "-", title: "-", roles: {}, ...fields});
|
||
|
|
const rows = [
|
||
|
|
row("work/a", {status: "done", ended: true, start: 80 * H, end: 82 * H, points: 2, worker: "Skills"}),
|
||
|
|
row("work/b", {status: "done", ended: true, start: 84 * H, end: 88 * H, points: 2, roles: {Stage: "R"}}),
|
||
|
|
row("work/c", {status: "done", ended: true, start: 90 * H, end: 91 * H}),
|
||
|
|
row("work/run", {status: "running", start: 95 * H, points: 5, worker: "Stage"}),
|
||
|
|
row("work/next", {points: 3}),
|
||
|
|
row("work/tail", {}),
|
||
|
|
row("work/stop", {status: "stopped", ended: true, start: 85 * H, end: 86 * H, points: 1}),
|
||
|
|
row("queue/x", {kind: "queue", status: "awaiting"}),
|
||
|
|
row("operator/push", {kind: "command", status: "done", ended: true, end: 70 * H}),
|
||
|
|
];
|
||
|
|
const edges = [
|
||
|
|
{from: "work/run", to: "work/next", kind: "after"},
|
||
|
|
{from: "work/next", to: "work/tail", kind: "after"},
|
||
|
|
{from: "work/c", to: "work/tail", kind: "base"},
|
||
|
|
{from: "work/run", to: "queue/x", kind: "queue"},
|
||
|
|
{from: "work/gone", to: "work/tail", kind: "base"},
|
||
|
|
];
|
||
|
|
const loop = [row("loop/a", {}), row("loop/b", {})];
|
||
|
|
const looped = [{from: "loop/a", to: "loop/b", kind: "base"}, {from: "loop/b", to: "loop/a", kind: "queue"}];
|
||
|
|
const lay = (estimation, forecast) => P.bars(rows, edges, {now, estimation, forecast}).map((bar) => [bar.task, bar.start / H, bar.end / H, bar.forecast, bar.dot]);
|
||
|
|
const sorts = [
|
||
|
|
row("t1", {wsjf: {score: 2}, value: 8, points: 3, status: "running", start: 5, worker: "Bravo", title: "Zulu"}),
|
||
|
|
row("t2", {status: "done", start: 3, end: 9}),
|
||
|
|
row("t3", {wsjf: {score: 5}, value: 3, points: 1, worker: "alpha", title: "Alpha"}),
|
||
|
|
row("t4", {wsjf: {score: 2}, value: 13, points: 8, status: "blocked", start: 1, end: 4, worker: "Charlie", title: "mike"}),
|
||
|
|
];
|
||
|
|
const context = {filtered: [rows[0], rows[7]], since: 90 * H, until: 99 * H, now, holders: {"identity/oidc": "Stage"}};
|
||
|
|
const scope = (choice) => {
|
||
|
|
const found = P.scoped(rows, choice, context);
|
||
|
|
return {tasks: found.rows.map((entry) => entry.task), from: found.from / H, to: found.to / H};
|
||
|
|
};
|
||
|
|
const series = (estimation) => P.burn(P.scoped(rows, "all", context).rows, {estimation, from: 70 * H, to: now}).points.map((point) => [point.t / H, point.scope, point.done, point.remaining, point.unestimated]);
|
||
|
|
console.log(JSON.stringify({
|
||
|
|
options: [P.FORECASTS, P.ESTIMATIONS, P.SORTS.map(([id]) => id), P.SERIES],
|
||
|
|
medians: P.medians(rows),
|
||
|
|
median: [P.median([]), P.median([3, 1, 2]), P.median([4, 1, 3, 2])],
|
||
|
|
bars: {noneAlways: lay("none", "always"), pointsAlways: lay("points", "always"), pointsOnly: lay("points", "points"), noneOnly: lay("none", "points"), real: lay("points", "real")},
|
||
|
|
loop: P.bars(loop, looped, {now, estimation: "none", forecast: "always"}).map((bar) => bar.task),
|
||
|
|
empty: P.bars([row("work/new", {}), row("work/gone", {status: "done", ended: true})], [], {now, estimation: "none", forecast: "always"}),
|
||
|
|
scopes: {all: scope("all"), filter: scope("filter"), agent: scope("agent:Stage"), line: scope("line:identity/oidc"), none: scope("agent:Nobody")},
|
||
|
|
burn: {none: series("none"), points: series("points")},
|
||
|
|
sorted: Object.fromEntries(P.SORTS.map(([id]) => [id, [P.sortRows(sorts, id, false).map((entry) => entry.task), P.sortRows(sorts, id, true).map((entry) => entry.task)]])),
|
||
|
|
unknown: P.sortRows(sorts, "nope", false).map((entry) => entry.task),
|
||
|
|
}));
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
class TestPlanModule(unittest.TestCase):
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls):
|
||
|
|
done = subprocess.run(["node", "-e", PLAN_PROBE, str(PLAN)], capture_output=True, text=True, timeout=60, check=True)
|
||
|
|
cls.data = json.loads(done.stdout)
|
||
|
|
|
||
|
|
def test_the_selectors_offer_the_forecasts_estimations_sort_modes_and_series(self):
|
||
|
|
forecasts, estimations, sorts, series = self.data["options"]
|
||
|
|
self.assertEqual(forecasts, [["always", "always"], ["points", "points only"], ["real", "real only"]])
|
||
|
|
self.assertEqual(estimations, [["none", "No estimate"], ["points", "Story points"]])
|
||
|
|
self.assertEqual(sorts, ["prio", "wsjf", "value", "complexity", "status", "start", "end", "agent", "title"])
|
||
|
|
self.assertEqual(series, ["scope", "done", "remaining", "unestimated"])
|
||
|
|
|
||
|
|
def test_medians_take_done_tasks_only_and_the_points_of_those_with_points(self):
|
||
|
|
self.assertEqual(self.data["medians"], {"duration": 2 * HOUR, "perPoint": 1.5 * HOUR})
|
||
|
|
self.assertEqual(self.data["median"], [None, 2, 2.5])
|
||
|
|
|
||
|
|
def test_without_estimate_a_forecast_is_as_long_as_the_median_duration_behind_its_predecessors(self):
|
||
|
|
self.assertEqual(self.data["bars"]["noneAlways"], [
|
||
|
|
["work/a", 80, 82, False, False],
|
||
|
|
["work/b", 84, 88, False, False],
|
||
|
|
["work/c", 90, 91, False, False],
|
||
|
|
["work/run", 95, 100, False, False],
|
||
|
|
["work/next", 100, 102, True, False],
|
||
|
|
["work/tail", 102, 104, True, False],
|
||
|
|
["work/stop", 85, 86, False, False],
|
||
|
|
["queue/x", 100, 102, True, False],
|
||
|
|
["operator/push", 70, 70, False, True],
|
||
|
|
])
|
||
|
|
|
||
|
|
def test_with_story_points_a_forecast_is_points_times_the_median_hours_per_point(self):
|
||
|
|
bars = {task: rest for task, *rest in self.data["bars"]["pointsAlways"]}
|
||
|
|
self.assertEqual(bars["work/next"], [100, 104.5, True, False])
|
||
|
|
self.assertEqual(bars["work/tail"], [104.5, 106.5, True, False])
|
||
|
|
|
||
|
|
def test_points_only_draws_a_dot_behind_the_predecessors_of_a_task_without_estimate(self):
|
||
|
|
only = {task: rest for task, *rest in self.data["bars"]["pointsOnly"]}
|
||
|
|
self.assertEqual((only["work/next"], only["work/tail"], only["queue/x"]), ([100, 104.5, True, False], [104.5, 104.5, True, True], [100, 100, True, True]))
|
||
|
|
none = {task: rest for task, *rest in self.data["bars"]["noneOnly"]}
|
||
|
|
self.assertEqual((none["work/next"], none["work/tail"]), ([100, 100, True, True], [100, 100, True, True]))
|
||
|
|
|
||
|
|
def test_real_only_draws_no_forecast(self):
|
||
|
|
self.assertEqual([task for task, *_ in self.data["bars"]["real"]], ["work/a", "work/b", "work/c", "work/run", "work/stop", "operator/push"])
|
||
|
|
self.assertFalse(any(bar[3] for bar in self.data["bars"]["real"]))
|
||
|
|
|
||
|
|
def test_a_cycle_still_draws_every_task_no_done_task_means_a_dot_and_an_ended_task_without_times_no_bar(self):
|
||
|
|
self.assertEqual(self.data["loop"], ["loop/a", "loop/b"])
|
||
|
|
self.assertEqual(self.data["empty"], [{"task": "work/new", "start": NOW, "end": NOW, "forecast": True, "dot": True}])
|
||
|
|
|
||
|
|
def test_the_scopes_are_the_whole_register_the_filter_in_its_window_and_one_agent_or_line(self):
|
||
|
|
work = ["work/a", "work/b", "work/c", "work/run", "work/next", "work/tail", "work/stop"]
|
||
|
|
scopes = self.data["scopes"]
|
||
|
|
self.assertEqual(scopes["all"], {"tasks": work, "from": 80, "to": 100})
|
||
|
|
self.assertEqual(scopes["filter"], {"tasks": ["work/a"], "from": 90, "to": 99})
|
||
|
|
self.assertEqual(scopes["agent"], {"tasks": ["work/b", "work/run"], "from": 84, "to": 100})
|
||
|
|
self.assertEqual(scopes["line"], scopes["agent"])
|
||
|
|
self.assertEqual(scopes["none"], {"tasks": [], "from": 76, "to": 100})
|
||
|
|
|
||
|
|
def test_without_estimate_the_burn_counts_tasks(self):
|
||
|
|
self.assertEqual(self.data["burn"]["none"], [
|
||
|
|
[70, 2, 0, 2, 0], [80, 3, 0, 3, 0], [82, 3, 1, 2, 0], [84, 4, 1, 3, 0], [85, 5, 1, 4, 0], [86, 4, 1, 3, 0],
|
||
|
|
[88, 4, 2, 2, 0], [90, 5, 2, 3, 0], [91, 5, 3, 2, 0], [95, 6, 3, 3, 0], [100, 6, 3, 3, 0],
|
||
|
|
])
|
||
|
|
|
||
|
|
def test_with_story_points_the_burn_sums_complexity_and_counts_the_unestimated_apart(self):
|
||
|
|
burn = self.data["burn"]["points"]
|
||
|
|
self.assertEqual(burn[0], [70, 3, 0, 3, 1])
|
||
|
|
self.assertEqual(burn[-1], [100, 12, 4, 8, 1])
|
||
|
|
self.assertEqual([point[4] for point in burn], [1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1])
|
||
|
|
|
||
|
|
def test_the_list_sorts_by_every_mode_in_both_directions_with_missing_keys_last(self):
|
||
|
|
self.assertEqual(self.data["sorted"], {
|
||
|
|
"prio": [["t1", "t2", "t3", "t4"], ["t4", "t3", "t2", "t1"]],
|
||
|
|
"wsjf": [["t1", "t4", "t3", "t2"], ["t3", "t1", "t4", "t2"]],
|
||
|
|
"value": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
|
||
|
|
"complexity": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
|
||
|
|
"status": [["t3", "t1", "t4", "t2"], ["t2", "t4", "t1", "t3"]],
|
||
|
|
"start": [["t4", "t2", "t1", "t3"], ["t1", "t2", "t4", "t3"]],
|
||
|
|
"end": [["t4", "t2", "t1", "t3"], ["t2", "t4", "t1", "t3"]],
|
||
|
|
"agent": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
|
||
|
|
"title": [["t3", "t4", "t2", "t1"], ["t1", "t2", "t4", "t3"]],
|
||
|
|
})
|
||
|
|
self.assertEqual(self.data["unknown"], ["t1", "t2", "t3", "t4"])
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|