@stylusnexus/work-plan 2026.6.14 → 2026.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +10 -3
- package/skills/work-plan/commands/export.py +3 -1
- package/skills/work-plan/commands/handoff.py +12 -3
- package/skills/work-plan/commands/set_next_up.py +154 -0
- package/skills/work-plan/lib/export_model.py +6 -1
- package/skills/work-plan/lib/git_state.py +53 -14
- package/skills/work-plan/lib/next_up.py +152 -29
- package/skills/work-plan/tests/test_export.py +53 -0
- package/skills/work-plan/tests/test_git_state.py +47 -12
- package/skills/work-plan/tests/test_next_up.py +281 -8
- package/skills/work-plan/tests/test_set_next_up.py +201 -0
- package/skills/work-plan/work_plan.py +5 -0
|
@@ -35,6 +35,9 @@ class BuildExportTest(unittest.TestCase):
|
|
|
35
35
|
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
36
36
|
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
37
37
|
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None, "in_progress": False, "in_progress_label": False, "blocked_by": [], "blocking": []})
|
|
38
|
+
# Phase 2: next_up_preset must be present in every track
|
|
39
|
+
self.assertIn("next_up_preset", t)
|
|
40
|
+
self.assertEqual(t["next_up_preset"], "flow") # default when no next_up_order in meta
|
|
38
41
|
json.dumps(out) # must be serializable
|
|
39
42
|
|
|
40
43
|
def test_path_is_null_when_track_has_no_path(self):
|
|
@@ -459,6 +462,56 @@ class InProgressExportTest(unittest.TestCase):
|
|
|
459
462
|
self.assertFalse(issue["in_progress_label"]) # no label present
|
|
460
463
|
|
|
461
464
|
|
|
465
|
+
class BuildExportNextUpPresetTest(unittest.TestCase):
|
|
466
|
+
"""Tests that build_export emits next_up_preset on each track (#326 Phase 2)."""
|
|
467
|
+
|
|
468
|
+
def _build(self, track_meta_override=None, next_up_default=None):
|
|
469
|
+
from types import SimpleNamespace
|
|
470
|
+
meta = {
|
|
471
|
+
"status": "active",
|
|
472
|
+
"launch_priority": "P2",
|
|
473
|
+
"milestone_alignment": "v1",
|
|
474
|
+
"blockers": [],
|
|
475
|
+
"next_up": [],
|
|
476
|
+
"depends_on": [],
|
|
477
|
+
"github": {"repo": "o/r", "issues": []},
|
|
478
|
+
}
|
|
479
|
+
if track_meta_override:
|
|
480
|
+
meta.update(track_meta_override)
|
|
481
|
+
t = SimpleNamespace(name="alpha", repo="o/r", tier="private",
|
|
482
|
+
path=Path("/tmp/notes/alpha.md"), folder="myrepo",
|
|
483
|
+
meta=meta)
|
|
484
|
+
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"},
|
|
485
|
+
now="2026-06-14T00:00", next_up_default=next_up_default)
|
|
486
|
+
return out["tracks"][0]
|
|
487
|
+
|
|
488
|
+
def test_next_up_preset_field_present(self):
|
|
489
|
+
"""Export emits next_up_preset for each track."""
|
|
490
|
+
track = self._build()
|
|
491
|
+
self.assertIn("next_up_preset", track)
|
|
492
|
+
|
|
493
|
+
def test_next_up_preset_defaults_to_flow(self):
|
|
494
|
+
"""Track with no next_up_order → next_up_preset == 'flow'."""
|
|
495
|
+
track = self._build()
|
|
496
|
+
self.assertEqual(track["next_up_preset"], "flow")
|
|
497
|
+
|
|
498
|
+
def test_next_up_preset_reflects_track_setting(self):
|
|
499
|
+
"""Track with next_up_order: {preset: priority-driven} → next_up_preset == 'priority-driven'."""
|
|
500
|
+
track = self._build({"next_up_order": {"preset": "priority-driven"}})
|
|
501
|
+
self.assertEqual(track["next_up_preset"], "priority-driven")
|
|
502
|
+
|
|
503
|
+
def test_next_up_preset_uses_global_default(self):
|
|
504
|
+
"""Track with no next_up_order + global next_up_default='backlog' → next_up_preset == 'backlog'."""
|
|
505
|
+
track = self._build(next_up_default="backlog")
|
|
506
|
+
self.assertEqual(track["next_up_preset"], "backlog")
|
|
507
|
+
|
|
508
|
+
def test_track_setting_overrides_global_default(self):
|
|
509
|
+
"""Track-level next_up_order overrides the global next_up_default."""
|
|
510
|
+
track = self._build({"next_up_order": {"preset": "backlog"}},
|
|
511
|
+
next_up_default="priority-driven")
|
|
512
|
+
self.assertEqual(track["next_up_preset"], "backlog")
|
|
513
|
+
|
|
514
|
+
|
|
462
515
|
class BlockedByExportTest(unittest.TestCase):
|
|
463
516
|
def _track(self, name, repo):
|
|
464
517
|
from types import SimpleNamespace
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for git_state pure functions."""
|
|
2
2
|
import unittest
|
|
3
3
|
import sys
|
|
4
|
+
from datetime import datetime, timedelta
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from unittest import mock
|
|
6
7
|
|
|
@@ -49,40 +50,74 @@ class BranchInProgressTest(unittest.TestCase):
|
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
class HotIssueNumbersTest(unittest.TestCase):
|
|
53
|
+
def setUp(self):
|
|
54
|
+
# hot_issue_numbers memoizes per resolved path; reset between cases so a
|
|
55
|
+
# prior test's "/repo" result doesn't leak into the next.
|
|
56
|
+
from lib import git_state
|
|
57
|
+
git_state._reset_hot_cache()
|
|
58
|
+
|
|
59
|
+
def _ref_line(self, branch, age_hours):
|
|
60
|
+
ts = int((datetime.now() - timedelta(hours=age_hours)).timestamp())
|
|
61
|
+
return f"{branch}\t{ts}"
|
|
62
|
+
|
|
63
|
+
def _enum(self, *lines):
|
|
64
|
+
return mock.Mock(return_value=mock.Mock(returncode=0,
|
|
65
|
+
stdout="\n".join(lines) + "\n"))
|
|
66
|
+
|
|
52
67
|
def test_returns_empty_when_repo_missing(self):
|
|
53
68
|
self.assertEqual(hot_issue_numbers(None), set())
|
|
54
69
|
self.assertEqual(hot_issue_numbers(Path("/nonexistent")), set())
|
|
55
70
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
def test_recent_feat_and_fix_branches_are_hot(self):
|
|
72
|
+
# Two recently-committed feat/fix branches + cold/non-matching ones.
|
|
73
|
+
lines = ["dev\t0", "main\t0",
|
|
74
|
+
self._ref_line("feat/271-foo", 1), # 1h ago -> hot
|
|
75
|
+
self._ref_line("fix/88-bar", 5), # 5h ago -> hot
|
|
76
|
+
self._ref_line("feat/9-old", 100), # 100h ago -> cold
|
|
77
|
+
"chore/x\t0", "work-plan/plan\t0"]
|
|
59
78
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
60
|
-
mock.patch("lib.git_state._git",
|
|
61
|
-
mock.patch("lib.git_state.
|
|
62
|
-
side_effect=lambda b, p: b in ("feat/271-foo", "fix/88-bar")):
|
|
79
|
+
mock.patch("lib.git_state._git", self._enum(*lines)), \
|
|
80
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
63
81
|
self.assertEqual(hot_issue_numbers(Path("/repo")), {271, 88})
|
|
64
82
|
|
|
65
83
|
def test_no_substring_collision_2710_is_not_271(self):
|
|
66
|
-
listing = "feat/2710-y\n"
|
|
67
84
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
68
85
|
mock.patch("lib.git_state._git",
|
|
69
|
-
|
|
70
|
-
mock.patch("lib.git_state.
|
|
86
|
+
self._enum(self._ref_line("feat/2710-y", 1))), \
|
|
87
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
71
88
|
self.assertEqual(hot_issue_numbers(Path("/repo")), {2710})
|
|
72
89
|
|
|
73
90
|
def test_cold_matched_branch_excluded(self):
|
|
74
|
-
listing = "feat/271-foo\n"
|
|
75
91
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
76
92
|
mock.patch("lib.git_state._git",
|
|
77
|
-
|
|
78
|
-
mock.patch("lib.git_state.
|
|
93
|
+
self._enum(self._ref_line("feat/271-foo", 48))), \
|
|
94
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
79
95
|
self.assertEqual(hot_issue_numbers(Path("/repo")), set())
|
|
80
96
|
|
|
97
|
+
def test_uncommitted_on_current_branch_is_hot_even_if_cold(self):
|
|
98
|
+
# A cold (old-tip) feat branch that's checked out with uncommitted work.
|
|
99
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
100
|
+
mock.patch("lib.git_state._git",
|
|
101
|
+
self._enum(self._ref_line("feat/271-foo", 200))), \
|
|
102
|
+
mock.patch("lib.git_state.current_branch", return_value="feat/271-foo"), \
|
|
103
|
+
mock.patch("lib.git_state.has_uncommitted", return_value=True):
|
|
104
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
|
|
105
|
+
|
|
81
106
|
def test_enumeration_failure_returns_empty(self):
|
|
82
107
|
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
83
108
|
mock.patch("lib.git_state._git", return_value=None):
|
|
84
109
|
self.assertEqual(hot_issue_numbers(Path("/repo")), set())
|
|
85
110
|
|
|
111
|
+
def test_memoizes_per_resolved_path(self):
|
|
112
|
+
# Second call with the same path must NOT re-invoke git.
|
|
113
|
+
enum = self._enum(self._ref_line("feat/271-foo", 1))
|
|
114
|
+
with mock.patch("lib.git_state.Path.exists", return_value=True), \
|
|
115
|
+
mock.patch("lib.git_state._git", enum), \
|
|
116
|
+
mock.patch("lib.git_state.current_branch", return_value=None):
|
|
117
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
|
|
118
|
+
self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
|
|
119
|
+
self.assertEqual(enum.call_count, 1)
|
|
120
|
+
|
|
86
121
|
|
|
87
122
|
if __name__ == "__main__":
|
|
88
123
|
unittest.main()
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Tests for the next_up suggestion algorithm.
|
|
2
2
|
|
|
3
3
|
Covers the priority + recency sort, blocker exclusion, closed-issue filter,
|
|
4
|
-
top-N capping,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
top-N capping, `updatedAt`-missing fallback, dependency gate (blocked_by),
|
|
5
|
+
in-progress float, fan-out ranking, and the deterministic number tiebreak.
|
|
6
|
+
The algorithm has one home (lib/next_up.py) shared by handoff's --auto-next
|
|
7
|
+
flag and brief's next_up_auto: true frontmatter knob — so a regression here
|
|
8
|
+
would surface in both commands.
|
|
8
9
|
"""
|
|
9
10
|
import sys
|
|
10
11
|
import unittest
|
|
@@ -13,19 +14,33 @@ from pathlib import Path
|
|
|
13
14
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
15
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
16
|
|
|
16
|
-
from lib.next_up import suggest_next_up
|
|
17
|
+
from lib.next_up import suggest_next_up, resolve_next_up_order, PRESETS, DEFAULT_PRESET
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def _issue(num, *, state="OPEN", priority=None, updated="2026-01-01T00:00:00Z",
|
|
20
|
-
title="", milestone=None):
|
|
21
|
-
"""Build a minimal issue dict matching gh's --json output.
|
|
21
|
+
title="", milestone=None, blocked_by=None, blocking=None):
|
|
22
|
+
"""Build a minimal issue dict matching gh's --json output.
|
|
23
|
+
|
|
24
|
+
blocked_by / blocking each accept a list of {number, repo, title} dicts
|
|
25
|
+
(OPEN-filtered, as delivered by github_state after #257).
|
|
26
|
+
"""
|
|
22
27
|
labels = [{"name": f"priority/{priority}"}] if priority else []
|
|
23
28
|
ms_obj = {"title": milestone} if milestone else None
|
|
24
|
-
|
|
29
|
+
issue = {
|
|
25
30
|
"number": num, "state": state, "labels": labels,
|
|
26
31
|
"updatedAt": updated, "title": title or f"issue #{num}",
|
|
27
32
|
"milestone": ms_obj,
|
|
28
33
|
}
|
|
34
|
+
if blocked_by is not None:
|
|
35
|
+
issue["blocked_by"] = blocked_by
|
|
36
|
+
if blocking is not None:
|
|
37
|
+
issue["blocking"] = blocking
|
|
38
|
+
return issue
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dep(num, repo="stylusnexus/demo"):
|
|
42
|
+
"""Build a minimal dependency edge dict {number, repo, title}."""
|
|
43
|
+
return {"number": num, "repo": repo, "title": f"dep #{num}"}
|
|
29
44
|
|
|
30
45
|
|
|
31
46
|
class SuggestNextUpTest(unittest.TestCase):
|
|
@@ -145,5 +160,263 @@ class SuggestNextUpTest(unittest.TestCase):
|
|
|
145
160
|
self.assertEqual(result, [2, 1]) # parsable+newer wins; garbage trails
|
|
146
161
|
|
|
147
162
|
|
|
163
|
+
class InProgressAndDependencyTest(unittest.TestCase):
|
|
164
|
+
"""Phase 1 additions: dependency gate, in-progress float, fan-out, tiebreak."""
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# in-progress float
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def test_in_progress_floats_above_higher_priority(self):
|
|
171
|
+
"""An in-progress P2 should sort above a non-in-progress P0."""
|
|
172
|
+
issues = [
|
|
173
|
+
_issue(1, priority="P0"), # high priority, NOT in-progress
|
|
174
|
+
_issue(2, priority="P2"), # lower priority, IS in-progress
|
|
175
|
+
]
|
|
176
|
+
result = suggest_next_up(issues, [], in_progress_nums={2})
|
|
177
|
+
self.assertEqual(result[0], 2, "in-progress issue must float to top")
|
|
178
|
+
self.assertEqual(result[1], 1)
|
|
179
|
+
|
|
180
|
+
def test_in_progress_floats_above_milestone_aligned(self):
|
|
181
|
+
"""In-progress with no milestone still beats milestone-aligned non-in-progress."""
|
|
182
|
+
issues = [
|
|
183
|
+
_issue(1, priority="P3", milestone="v1.0 — MVP"), # aligned, not in-prog
|
|
184
|
+
_issue(2, priority="P3", milestone=None), # no milestone, in-prog
|
|
185
|
+
]
|
|
186
|
+
result = suggest_next_up(issues, [], track_milestone="v1.0",
|
|
187
|
+
in_progress_nums={2})
|
|
188
|
+
self.assertEqual(result[0], 2)
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# dependency gate
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def test_blocked_issue_excluded_from_candidates(self):
|
|
195
|
+
"""An issue with a non-empty blocked_by list is gated out of the result."""
|
|
196
|
+
issues = [
|
|
197
|
+
_issue(1, priority="P0", blocked_by=[_dep(99)]), # blocked — gated
|
|
198
|
+
_issue(2, priority="P1"), # open, unblocked
|
|
199
|
+
]
|
|
200
|
+
result = suggest_next_up(issues, [])
|
|
201
|
+
self.assertNotIn(1, result, "blocked issue must not appear")
|
|
202
|
+
self.assertIn(2, result)
|
|
203
|
+
|
|
204
|
+
def test_blocked_but_in_progress_stays_in_result(self):
|
|
205
|
+
"""An in-progress issue is never gated out by blocked_by."""
|
|
206
|
+
issues = [
|
|
207
|
+
_issue(1, priority="P0", blocked_by=[_dep(99)]), # blocked but in-progress
|
|
208
|
+
_issue(2, priority="P1"),
|
|
209
|
+
]
|
|
210
|
+
result = suggest_next_up(issues, [], in_progress_nums={1})
|
|
211
|
+
self.assertIn(1, result, "in-progress issue must survive the blocked_by gate")
|
|
212
|
+
|
|
213
|
+
def test_empty_blocked_by_list_is_not_gated(self):
|
|
214
|
+
"""An explicit empty blocked_by list is treated as unblocked."""
|
|
215
|
+
issues = [
|
|
216
|
+
_issue(1, priority="P0", blocked_by=[]),
|
|
217
|
+
_issue(2, priority="P1"),
|
|
218
|
+
]
|
|
219
|
+
result = suggest_next_up(issues, [])
|
|
220
|
+
self.assertIn(1, result, "empty blocked_by should not gate the issue")
|
|
221
|
+
|
|
222
|
+
def test_missing_blocked_by_key_is_not_gated(self):
|
|
223
|
+
"""Issues without a blocked_by key at all pass through (backward-compat)."""
|
|
224
|
+
issues = [
|
|
225
|
+
_issue(1, priority="P0"), # no blocked_by key
|
|
226
|
+
_issue(2, priority="P1"),
|
|
227
|
+
]
|
|
228
|
+
result = suggest_next_up(issues, [])
|
|
229
|
+
self.assertIn(1, result)
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# fan-out ranking
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def test_higher_fanout_ranks_above_higher_priority(self):
|
|
236
|
+
"""Fan-out (unblocking count) outranks priority within same milestone bucket."""
|
|
237
|
+
issues = [
|
|
238
|
+
_issue(1, priority="P0", # high priority, zero fan-out
|
|
239
|
+
blocking=[]),
|
|
240
|
+
_issue(2, priority="P2", # lower priority, high fan-out
|
|
241
|
+
blocking=[_dep(10), _dep(11), _dep(12)]),
|
|
242
|
+
]
|
|
243
|
+
result = suggest_next_up(issues, [])
|
|
244
|
+
self.assertEqual(result[0], 2, "fan-out beats priority")
|
|
245
|
+
self.assertEqual(result[1], 1)
|
|
246
|
+
|
|
247
|
+
def test_milestone_beats_fanout(self):
|
|
248
|
+
"""Milestone alignment beats fan-out: aligned-zero-fanout > off-milestone-high-fanout."""
|
|
249
|
+
issues = [
|
|
250
|
+
_issue(1, priority="P2", milestone="v1.0 — MVP", # aligned, no fan-out
|
|
251
|
+
blocking=[]),
|
|
252
|
+
_issue(2, priority="P2", milestone="v2.0 — Beta", # off-milestone, high fan-out
|
|
253
|
+
blocking=[_dep(10), _dep(11), _dep(12)]),
|
|
254
|
+
]
|
|
255
|
+
result = suggest_next_up(issues, [], track_milestone="v1.0")
|
|
256
|
+
self.assertEqual(result[0], 1, "milestone-aligned must beat high-fanout off-milestone")
|
|
257
|
+
|
|
258
|
+
def test_blocking_field_absent_treated_as_zero_fanout(self):
|
|
259
|
+
"""Missing blocking key → fan-out = 0; does not crash."""
|
|
260
|
+
issues = [
|
|
261
|
+
_issue(1, priority="P1"), # no blocking key
|
|
262
|
+
_issue(2, priority="P1", blocking=[_dep(10)]), # fan-out=1
|
|
263
|
+
]
|
|
264
|
+
result = suggest_next_up(issues, [])
|
|
265
|
+
self.assertEqual(result[0], 2, "fan-out=1 beats fan-out=0")
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# deterministic tiebreak
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def test_number_tiebreak_when_all_else_equal(self):
|
|
272
|
+
"""When every sort dimension ties, lower issue number wins."""
|
|
273
|
+
issues = [
|
|
274
|
+
_issue(30, priority="P1", updated="2026-01-01T00:00:00Z"),
|
|
275
|
+
_issue(10, priority="P1", updated="2026-01-01T00:00:00Z"),
|
|
276
|
+
_issue(20, priority="P1", updated="2026-01-01T00:00:00Z"),
|
|
277
|
+
]
|
|
278
|
+
result = suggest_next_up(issues, [])
|
|
279
|
+
self.assertEqual(result, [10, 20, 30])
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
# backward-compat
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def test_no_in_progress_nums_does_not_crash(self):
|
|
286
|
+
"""Calling without in_progress_nums must not raise; no in-progress boost applied."""
|
|
287
|
+
issues = [_issue(1, priority="P0"), _issue(2, priority="P1")]
|
|
288
|
+
result = suggest_next_up(issues, []) # old call signature
|
|
289
|
+
self.assertEqual(result, [1, 2])
|
|
290
|
+
|
|
291
|
+
def test_blocked_by_excluded_without_in_progress_nums(self):
|
|
292
|
+
"""Without in_progress_nums, blocked issues are gated out (no in-prog bypass)."""
|
|
293
|
+
issues = [
|
|
294
|
+
_issue(1, priority="P0", blocked_by=[_dep(99)]),
|
|
295
|
+
_issue(2, priority="P1"),
|
|
296
|
+
]
|
|
297
|
+
result = suggest_next_up(issues, [])
|
|
298
|
+
self.assertNotIn(1, result)
|
|
299
|
+
self.assertIn(2, result)
|
|
300
|
+
|
|
301
|
+
def test_manual_blocker_still_excluded(self):
|
|
302
|
+
"""Manual blocker_nums exclusion is unaffected by the new in-progress param."""
|
|
303
|
+
issues = [
|
|
304
|
+
_issue(1, priority="P0"),
|
|
305
|
+
_issue(2, priority="P1"),
|
|
306
|
+
]
|
|
307
|
+
result = suggest_next_up(issues, [1], in_progress_nums={1})
|
|
308
|
+
# in-progress does NOT override manual blocker_nums exclusion
|
|
309
|
+
self.assertNotIn(1, result)
|
|
310
|
+
self.assertIn(2, result)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class PresetAndResolverTest(unittest.TestCase):
|
|
314
|
+
"""Phase 2: preset ordering + resolver tests."""
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# preset ordering
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def test_flow_preset_equivalent_to_phase1_default(self):
|
|
321
|
+
"""order=None and order=PRESETS['flow'] must produce identical results."""
|
|
322
|
+
issues = [
|
|
323
|
+
_issue(1, priority="P0", milestone="v1.0"),
|
|
324
|
+
_issue(2, priority="P2", updated="2026-04-30T00:00:00Z"),
|
|
325
|
+
_issue(3, priority="P1", milestone="v1.0", blocking=[_dep(5), _dep(6)]),
|
|
326
|
+
]
|
|
327
|
+
result_none = suggest_next_up(issues, [], track_milestone="v1.0")
|
|
328
|
+
result_flow = suggest_next_up(issues, [], track_milestone="v1.0",
|
|
329
|
+
order=PRESETS["flow"])
|
|
330
|
+
self.assertEqual(result_none, result_flow,
|
|
331
|
+
"order=None must be equivalent to order=PRESETS['flow']")
|
|
332
|
+
|
|
333
|
+
def test_priority_driven_ranks_p0_above_off_milestone(self):
|
|
334
|
+
"""priority-driven: a P0 with no milestone beats a P3 on the track milestone."""
|
|
335
|
+
issues = [
|
|
336
|
+
_issue(1, priority="P0", milestone=None),
|
|
337
|
+
_issue(2, priority="P3", milestone="v0.4.0 — MVP"),
|
|
338
|
+
]
|
|
339
|
+
result = suggest_next_up(issues, [], track_milestone="v0.4.0",
|
|
340
|
+
order=PRESETS["priority-driven"])
|
|
341
|
+
# With priority-driven, priority > milestone so P0 beats P3-on-milestone
|
|
342
|
+
self.assertEqual(result[0], 1,
|
|
343
|
+
"P0 with no milestone must beat P3 on track milestone under priority-driven")
|
|
344
|
+
|
|
345
|
+
def test_backlog_puts_oldest_first(self):
|
|
346
|
+
"""backlog preset: oldest issue (smallest timestamp) ranks above newer."""
|
|
347
|
+
issues = [
|
|
348
|
+
_issue(10, priority="P2", updated="2026-06-01T00:00:00Z"), # newer
|
|
349
|
+
_issue(20, priority="P2", updated="2024-01-01T00:00:00Z"), # older
|
|
350
|
+
]
|
|
351
|
+
result = suggest_next_up(issues, [], order=PRESETS["backlog"])
|
|
352
|
+
self.assertEqual(result[0], 20,
|
|
353
|
+
"backlog preset must surface oldest (most stalled) issue first")
|
|
354
|
+
|
|
355
|
+
def test_unknown_criterion_in_order_skipped(self):
|
|
356
|
+
"""An order list with an unknown criterion must not crash; known ones still apply."""
|
|
357
|
+
issues = [
|
|
358
|
+
_issue(1, priority="P0"),
|
|
359
|
+
_issue(2, priority="P1"),
|
|
360
|
+
]
|
|
361
|
+
# Should not raise; the unknown 'bogus' criterion is skipped
|
|
362
|
+
result = suggest_next_up(issues, [], order=["bogus", "priority", "recency"])
|
|
363
|
+
self.assertEqual(result[0], 1, "priority criterion must still sort correctly")
|
|
364
|
+
self.assertNotIn("bogus", result) # just making sure it didn't error on str
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------
|
|
367
|
+
# resolver
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def test_resolve_next_up_order_uses_track_frontmatter(self):
|
|
371
|
+
"""Track with next_up_order: {preset: priority-driven} → resolver returns that preset."""
|
|
372
|
+
meta = {"next_up_order": {"preset": "priority-driven"}}
|
|
373
|
+
name, order = resolve_next_up_order(meta)
|
|
374
|
+
self.assertEqual(name, "priority-driven")
|
|
375
|
+
self.assertEqual(order, PRESETS["priority-driven"])
|
|
376
|
+
|
|
377
|
+
def test_resolve_next_up_order_falls_back_to_global_default(self):
|
|
378
|
+
"""No track frontmatter → uses global default param."""
|
|
379
|
+
meta = {}
|
|
380
|
+
name, order = resolve_next_up_order(meta, default_preset="backlog")
|
|
381
|
+
self.assertEqual(name, "backlog")
|
|
382
|
+
self.assertEqual(order, PRESETS["backlog"])
|
|
383
|
+
|
|
384
|
+
def test_resolve_next_up_order_falls_back_to_default_preset(self):
|
|
385
|
+
"""Neither track frontmatter nor global default → returns ('flow', PRESETS['flow'])."""
|
|
386
|
+
meta = {}
|
|
387
|
+
name, order = resolve_next_up_order(meta)
|
|
388
|
+
self.assertEqual(name, DEFAULT_PRESET)
|
|
389
|
+
self.assertEqual(order, PRESETS[DEFAULT_PRESET])
|
|
390
|
+
|
|
391
|
+
def test_resolve_next_up_order_custom_uses_order_list(self):
|
|
392
|
+
"""next_up_order: {preset: custom, order: [priority, recency]} → custom order used."""
|
|
393
|
+
meta = {"next_up_order": {"preset": "custom", "order": ["priority", "recency"]}}
|
|
394
|
+
name, order = resolve_next_up_order(meta)
|
|
395
|
+
self.assertEqual(name, "custom")
|
|
396
|
+
self.assertEqual(order, ["priority", "recency"])
|
|
397
|
+
|
|
398
|
+
def test_resolve_next_up_order_unknown_preset_falls_back_to_flow(self):
|
|
399
|
+
"""Track has next_up_order: {preset: nonexistent} → returns ('flow', PRESETS['flow'])."""
|
|
400
|
+
meta = {"next_up_order": {"preset": "nonexistent"}}
|
|
401
|
+
name, order = resolve_next_up_order(meta)
|
|
402
|
+
self.assertEqual(name, DEFAULT_PRESET)
|
|
403
|
+
self.assertEqual(order, PRESETS[DEFAULT_PRESET])
|
|
404
|
+
|
|
405
|
+
def test_resolve_next_up_order_invalid_custom_order_falls_back(self):
|
|
406
|
+
"""next_up_order: {preset: custom, order: [bogus]} → falls back to flow."""
|
|
407
|
+
meta = {"next_up_order": {"preset": "custom", "order": ["bogus"]}}
|
|
408
|
+
name, order = resolve_next_up_order(meta)
|
|
409
|
+
self.assertEqual(name, DEFAULT_PRESET)
|
|
410
|
+
self.assertEqual(order, PRESETS[DEFAULT_PRESET])
|
|
411
|
+
|
|
412
|
+
def test_resolve_reads_next_up_order_not_next_up(self):
|
|
413
|
+
"""Resolver reads 'next_up_order' key (a mapping), NOT 'next_up' (the issue-list)."""
|
|
414
|
+
# next_up is the issue list — should not affect the resolver
|
|
415
|
+
meta = {"next_up": [101, 102], "next_up_order": {"preset": "backlog"}}
|
|
416
|
+
name, order = resolve_next_up_order(meta)
|
|
417
|
+
self.assertEqual(name, "backlog")
|
|
418
|
+
self.assertEqual(order, PRESETS["backlog"])
|
|
419
|
+
|
|
420
|
+
|
|
148
421
|
if __name__ == "__main__":
|
|
149
422
|
unittest.main()
|