@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.
@@ -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 test_maps_hot_feat_and_fix_branches_to_numbers(self):
57
- listing = "dev\nmain\nfeat/271-foo\nfix/88-bar\nchore/x\nwork-plan/plan\n"
58
- enum = mock.Mock(return_value=mock.Mock(returncode=0, stdout=listing))
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", enum), \
61
- mock.patch("lib.git_state.branch_in_progress",
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
- return_value=mock.Mock(returncode=0, stdout=listing)), \
70
- mock.patch("lib.git_state.branch_in_progress", return_value=True):
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
- return_value=mock.Mock(returncode=0, stdout=listing)), \
78
- mock.patch("lib.git_state.branch_in_progress", return_value=False):
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, and the `updatedAt`-missing fallback. The algorithm has one
5
- home (lib/next_up.py) shared by handoff's --auto-next flag and brief's
6
- next_up_auto: true frontmatter knob so a regression here would surface
7
- in both commands.
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
- return {
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()