@stylusnexus/work-plan 2026.6.9
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/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +210 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Tests for manifest parsing + scoring."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
8
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
9
|
+
|
|
10
|
+
from lib.manifest import (
|
|
11
|
+
DeclaredPath, strip_range, parse_declared_paths,
|
|
12
|
+
count_checkboxes, plan_date_from_filename,
|
|
13
|
+
ManifestScore, score_manifest,
|
|
14
|
+
is_in_tree, out_of_tree_ratio,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InTreeTest(unittest.TestCase):
|
|
19
|
+
ROOT = Path("/repo")
|
|
20
|
+
|
|
21
|
+
def test_relative_is_in_tree(self):
|
|
22
|
+
self.assertTrue(is_in_tree("src/foo.ts", self.ROOT))
|
|
23
|
+
|
|
24
|
+
def test_tilde_is_out_of_tree(self):
|
|
25
|
+
self.assertFalse(is_in_tree("~/.claude/skills/x.py", self.ROOT))
|
|
26
|
+
|
|
27
|
+
def test_absolute_elsewhere_is_out_of_tree(self):
|
|
28
|
+
self.assertFalse(is_in_tree("/Applications/other/x.ts", self.ROOT))
|
|
29
|
+
|
|
30
|
+
def test_absolute_under_root_is_in_tree(self):
|
|
31
|
+
self.assertTrue(is_in_tree("/repo/src/x.ts", self.ROOT))
|
|
32
|
+
|
|
33
|
+
def test_dotdot_escape_is_out_of_tree(self):
|
|
34
|
+
self.assertFalse(is_in_tree("../sibling/x.ts", self.ROOT))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OutOfTreeRatioTest(unittest.TestCase):
|
|
38
|
+
def test_all_foreign(self):
|
|
39
|
+
decls = [DeclaredPath("create", "~/.claude/a.py"),
|
|
40
|
+
DeclaredPath("create", "/Applications/other/b.ts")]
|
|
41
|
+
self.assertEqual(out_of_tree_ratio(decls, Path("/repo")), 1.0)
|
|
42
|
+
|
|
43
|
+
def test_mixed(self):
|
|
44
|
+
decls = [DeclaredPath("create", "src/a.ts"),
|
|
45
|
+
DeclaredPath("create", "~/b.py")]
|
|
46
|
+
self.assertEqual(out_of_tree_ratio(decls, Path("/repo")), 0.5)
|
|
47
|
+
|
|
48
|
+
def test_empty_is_zero(self):
|
|
49
|
+
self.assertEqual(out_of_tree_ratio([], Path("/repo")), 0.0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StripRangeTest(unittest.TestCase):
|
|
53
|
+
def test_strips_line_range(self):
|
|
54
|
+
self.assertEqual(strip_range("src/foo.ts:120-145"), "src/foo.ts")
|
|
55
|
+
|
|
56
|
+
def test_strips_single_line(self):
|
|
57
|
+
self.assertEqual(strip_range("src/foo.ts:12"), "src/foo.ts")
|
|
58
|
+
|
|
59
|
+
def test_strips_multi_range(self):
|
|
60
|
+
self.assertEqual(strip_range("src/foo.tsx:104-115,217-247"), "src/foo.tsx")
|
|
61
|
+
|
|
62
|
+
def test_leaves_bare_path(self):
|
|
63
|
+
self.assertEqual(strip_range("src/foo.ts"), "src/foo.ts")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ParseDeclaredPathsTest(unittest.TestCase):
|
|
67
|
+
SAMPLE = (
|
|
68
|
+
"**Files:**\n"
|
|
69
|
+
"- Create: `src/lib/idea.ts`\n"
|
|
70
|
+
"- Modify: `src/app/route.ts:10-22`\n"
|
|
71
|
+
"- Test: `tests/idea.test.ts`\n"
|
|
72
|
+
"Run: `npm test`\n" # not a declared path (no Create/Modify/Test)
|
|
73
|
+
"See `SomeType` for details\n" # not a path
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def test_extracts_three_kinds(self):
|
|
77
|
+
decls = parse_declared_paths(self.SAMPLE)
|
|
78
|
+
kinds = {d.kind for d in decls}
|
|
79
|
+
self.assertEqual(kinds, {"create", "modify", "test"})
|
|
80
|
+
|
|
81
|
+
def test_strips_range_on_modify(self):
|
|
82
|
+
decls = parse_declared_paths(self.SAMPLE)
|
|
83
|
+
modify = [d for d in decls if d.kind == "modify"][0]
|
|
84
|
+
self.assertEqual(modify.path, "src/app/route.ts")
|
|
85
|
+
|
|
86
|
+
def test_ignores_non_declaration_backticks(self):
|
|
87
|
+
decls = parse_declared_paths(self.SAMPLE)
|
|
88
|
+
paths = {d.path for d in decls}
|
|
89
|
+
self.assertNotIn("npm test", paths)
|
|
90
|
+
self.assertNotIn("SomeType", paths)
|
|
91
|
+
|
|
92
|
+
def test_dedupes_first_kind_wins(self):
|
|
93
|
+
text = "- Create: `a/b.ts`\n- Modify: `a/b.ts`\n"
|
|
94
|
+
decls = parse_declared_paths(text)
|
|
95
|
+
self.assertEqual(len(decls), 1)
|
|
96
|
+
self.assertEqual(decls[0].kind, "create")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CountCheckboxesTest(unittest.TestCase):
|
|
100
|
+
def test_counts_done_and_total_multiline(self):
|
|
101
|
+
text = "- [x] one\n- [ ] two\n - [X] three\n- [ ] four\n"
|
|
102
|
+
done, total = count_checkboxes(text)
|
|
103
|
+
self.assertEqual((done, total), (2, 4))
|
|
104
|
+
|
|
105
|
+
def test_no_checkboxes(self):
|
|
106
|
+
self.assertEqual(count_checkboxes("plain prose"), (0, 0))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PlanDateTest(unittest.TestCase):
|
|
110
|
+
def test_extracts_iso_prefix(self):
|
|
111
|
+
self.assertEqual(plan_date_from_filename("2026-03-16-idea-mode-ui.md"),
|
|
112
|
+
date(2026, 3, 16))
|
|
113
|
+
|
|
114
|
+
def test_returns_none_without_date(self):
|
|
115
|
+
self.assertIsNone(plan_date_from_filename("idea-mode-ui.md"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ScoreManifestTest(unittest.TestCase):
|
|
119
|
+
def _decls(self):
|
|
120
|
+
return [
|
|
121
|
+
DeclaredPath("create", "src/new.ts"),
|
|
122
|
+
DeclaredPath("create", "src/missing.ts"),
|
|
123
|
+
DeclaredPath("modify", "src/existing.ts"),
|
|
124
|
+
DeclaredPath("test", "tests/new.test.ts"),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def test_scores_with_injected_predicates(self):
|
|
128
|
+
present = {"src/new.ts", "tests/new.test.ts", "src/existing.ts"}
|
|
129
|
+
committed = {"src/existing.ts"}
|
|
130
|
+
score = score_manifest(
|
|
131
|
+
self._decls(), Path("/repo"), date(2026, 3, 1),
|
|
132
|
+
exists=lambda rel: rel in present,
|
|
133
|
+
committed_since=lambda rel: rel in committed,
|
|
134
|
+
)
|
|
135
|
+
# create: new.ts present(yes), missing.ts(no) -> 1/2
|
|
136
|
+
# modify: existing.ts committed-since(yes) -> 1/1
|
|
137
|
+
# test: new.test.ts present(yes) -> 1/1
|
|
138
|
+
self.assertEqual(score.total, 4)
|
|
139
|
+
self.assertEqual(score.satisfied, 3)
|
|
140
|
+
self.assertEqual(score.by_kind["create"], (1, 2))
|
|
141
|
+
self.assertEqual(score.by_kind["modify"], (1, 1))
|
|
142
|
+
self.assertEqual(score.by_kind["test"], (1, 1))
|
|
143
|
+
self.assertAlmostEqual(score.pct, 75.0)
|
|
144
|
+
|
|
145
|
+
def test_modify_existing_but_not_committed_is_unsatisfied(self):
|
|
146
|
+
score = score_manifest(
|
|
147
|
+
[DeclaredPath("modify", "src/old.ts")], Path("/repo"), date(2026, 3, 1),
|
|
148
|
+
exists=lambda rel: True, # file exists...
|
|
149
|
+
committed_since=lambda rel: False, # ...but untouched since plan date
|
|
150
|
+
)
|
|
151
|
+
self.assertEqual(score.satisfied, 0)
|
|
152
|
+
|
|
153
|
+
def test_empty_manifest_pct_none(self):
|
|
154
|
+
score = score_manifest([], Path("/repo"), None,
|
|
155
|
+
exists=lambda rel: False,
|
|
156
|
+
committed_since=lambda rel: False)
|
|
157
|
+
self.assertEqual(score.total, 0)
|
|
158
|
+
self.assertIsNone(score.pct)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
unittest.main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Tests for new-issue matching."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
8
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
9
|
+
|
|
10
|
+
from lib.new_issues import build_slug_labels, match_issue_to_tracks
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MatchIssueTest(unittest.TestCase):
|
|
14
|
+
def test_label_match_wins(self):
|
|
15
|
+
issue = {"number": 9, "title": "unrelated", "labels": [{"name": "track/tabletop"}]}
|
|
16
|
+
matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
|
|
17
|
+
self.assertEqual(matches, ["tabletop"])
|
|
18
|
+
|
|
19
|
+
def test_keyword_in_title(self):
|
|
20
|
+
issue = {"number": 10, "title": "fix tabletop initiative tracker", "labels": []}
|
|
21
|
+
matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
|
|
22
|
+
self.assertEqual(matches, ["tabletop"])
|
|
23
|
+
|
|
24
|
+
def test_no_match_returns_empty(self):
|
|
25
|
+
issue = {"number": 11, "title": "boring thing", "labels": []}
|
|
26
|
+
self.assertEqual(match_issue_to_tracks(issue, ["tabletop", "ux-redesign"]), [])
|
|
27
|
+
|
|
28
|
+
def test_multiple_matches(self):
|
|
29
|
+
issue = {"number": 12, "title": "tabletop ux redesign for combat", "labels": []}
|
|
30
|
+
matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
|
|
31
|
+
self.assertEqual(set(matches), {"tabletop", "ux-redesign"})
|
|
32
|
+
|
|
33
|
+
def test_slug_labels_override_single(self):
|
|
34
|
+
# Repo uses flat label `storytelling` instead of `track/storytelling-enhancements`.
|
|
35
|
+
issue = {"number": 100, "title": "unrelated", "labels": [{"name": "storytelling"}]}
|
|
36
|
+
slug_labels = {"storytelling-enhancements": ["storytelling"]}
|
|
37
|
+
matches = match_issue_to_tracks(issue, ["storytelling-enhancements"],
|
|
38
|
+
slug_labels=slug_labels)
|
|
39
|
+
self.assertEqual(matches, ["storytelling-enhancements"])
|
|
40
|
+
|
|
41
|
+
def test_slug_labels_override_multiple_or_semantics(self):
|
|
42
|
+
# Track configured to match if EITHER label is present (OR semantics).
|
|
43
|
+
slug_labels = {"ai-generators": ["ai", "generators"]}
|
|
44
|
+
|
|
45
|
+
issue_a = {"number": 200, "title": "x", "labels": [{"name": "ai"}]}
|
|
46
|
+
self.assertEqual(
|
|
47
|
+
match_issue_to_tracks(issue_a, ["ai-generators"], slug_labels=slug_labels),
|
|
48
|
+
["ai-generators"],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
issue_b = {"number": 201, "title": "x", "labels": [{"name": "generators"}]}
|
|
52
|
+
self.assertEqual(
|
|
53
|
+
match_issue_to_tracks(issue_b, ["ai-generators"], slug_labels=slug_labels),
|
|
54
|
+
["ai-generators"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
issue_c = {"number": 202, "title": "x", "labels": [{"name": "unrelated"}]}
|
|
58
|
+
self.assertEqual(
|
|
59
|
+
match_issue_to_tracks(issue_c, ["ai-generators"], slug_labels=slug_labels),
|
|
60
|
+
[],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def test_default_track_slug_label_still_works_when_other_track_overrides(self):
|
|
64
|
+
# Two tracks: one overridden, one using default `track/<slug>`.
|
|
65
|
+
slug_labels = {"storytelling-enhancements": ["storytelling"]}
|
|
66
|
+
issue = {"number": 300, "title": "unrelated", "labels": [{"name": "track/tabletop"}]}
|
|
67
|
+
matches = match_issue_to_tracks(
|
|
68
|
+
issue, ["storytelling-enhancements", "tabletop"], slug_labels=slug_labels
|
|
69
|
+
)
|
|
70
|
+
self.assertEqual(matches, ["tabletop"])
|
|
71
|
+
|
|
72
|
+
def test_type_label_does_not_leak_into_track_match(self):
|
|
73
|
+
# A `type:feature` label on its own should NOT auto-match a track
|
|
74
|
+
# unless a track explicitly opts into it via slug_labels.
|
|
75
|
+
issue = {
|
|
76
|
+
"number": 400,
|
|
77
|
+
"title": "boring thing",
|
|
78
|
+
"labels": [{"name": "type:feature"}, {"name": "priority:P3"}],
|
|
79
|
+
}
|
|
80
|
+
# No slug_labels override → default behaviour, no match.
|
|
81
|
+
self.assertEqual(match_issue_to_tracks(issue, ["tabletop"]), [])
|
|
82
|
+
# Explicit opt-in for one track → that track matches.
|
|
83
|
+
slug_labels = {"feature-work": ["type:feature"]}
|
|
84
|
+
matches = match_issue_to_tracks(
|
|
85
|
+
issue, ["feature-work", "tabletop"], slug_labels=slug_labels
|
|
86
|
+
)
|
|
87
|
+
self.assertEqual(matches, ["feature-work"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BuildSlugLabelsTest(unittest.TestCase):
|
|
91
|
+
def _track(self, slug, labels=None, has_fm=True):
|
|
92
|
+
meta = {"track": slug}
|
|
93
|
+
if labels is not None:
|
|
94
|
+
meta["github"] = {"labels": labels}
|
|
95
|
+
return SimpleNamespace(name=slug, has_frontmatter=has_fm, meta=meta)
|
|
96
|
+
|
|
97
|
+
def test_extracts_labels_from_frontmatter(self):
|
|
98
|
+
tracks = [
|
|
99
|
+
self._track("storytelling-enhancements", ["storytelling"]),
|
|
100
|
+
self._track("ai-generators", ["ai", "generators"]),
|
|
101
|
+
]
|
|
102
|
+
result = build_slug_labels(tracks)
|
|
103
|
+
self.assertEqual(result, {
|
|
104
|
+
"storytelling-enhancements": ["storytelling"],
|
|
105
|
+
"ai-generators": ["ai", "generators"],
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
def test_omits_tracks_without_labels(self):
|
|
109
|
+
# Tracks without `github.labels` are absent from the map; callers fall
|
|
110
|
+
# back to the default `track/<slug>` pattern for those.
|
|
111
|
+
tracks = [
|
|
112
|
+
self._track("tabletop"), # no labels
|
|
113
|
+
self._track("storytelling-enhancements", ["storytelling"]),
|
|
114
|
+
]
|
|
115
|
+
result = build_slug_labels(tracks)
|
|
116
|
+
self.assertEqual(result, {"storytelling-enhancements": ["storytelling"]})
|
|
117
|
+
|
|
118
|
+
def test_skips_tracks_without_frontmatter(self):
|
|
119
|
+
tracks = [
|
|
120
|
+
self._track("ghost", ["foo"], has_fm=False),
|
|
121
|
+
]
|
|
122
|
+
self.assertEqual(build_slug_labels(tracks), {})
|
|
123
|
+
|
|
124
|
+
def test_strips_blank_label_entries(self):
|
|
125
|
+
tracks = [self._track("foo", ["a", " ", ""])]
|
|
126
|
+
self.assertEqual(build_slug_labels(tracks), {"foo": ["a"]})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
unittest.main()
|