workstream-cli 0.0.1__py3-none-any.whl

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.
Files changed (86) hide show
  1. workstream/ARCHITECTURE.md +89 -0
  2. workstream/__init__.py +8 -0
  3. workstream/cli.py +136 -0
  4. workstream/commands/__init__.py +0 -0
  5. workstream/commands/backfill.py +139 -0
  6. workstream/commands/block.py +93 -0
  7. workstream/commands/checkin.py +51 -0
  8. workstream/commands/cron.py +119 -0
  9. workstream/commands/focus_cmd.py +273 -0
  10. workstream/commands/idea.py +172 -0
  11. workstream/commands/index.py +89 -0
  12. workstream/commands/init.py +567 -0
  13. workstream/commands/inspect_cmd.py +354 -0
  14. workstream/commands/list_cmd.py +99 -0
  15. workstream/commands/nest.py +108 -0
  16. workstream/commands/new.py +95 -0
  17. workstream/commands/next_cmd.py +333 -0
  18. workstream/commands/report.py +190 -0
  19. workstream/commands/resume.py +145 -0
  20. workstream/commands/review.py +227 -0
  21. workstream/commands/serve.py +23 -0
  22. workstream/commands/setup.py +178 -0
  23. workstream/commands/show.py +123 -0
  24. workstream/commands/snooze.py +117 -0
  25. workstream/commands/stale.py +116 -0
  26. workstream/commands/sweep.py +1753 -0
  27. workstream/commands/tree.py +105 -0
  28. workstream/commands/update_status.py +117 -0
  29. workstream/config.py +322 -0
  30. workstream/extensions/__init__.py +0 -0
  31. workstream/extensions/workstream.ts +633 -0
  32. workstream/focus_artifact.py +157 -0
  33. workstream/git.py +194 -0
  34. workstream/harness.py +49 -0
  35. workstream/llm.py +78 -0
  36. workstream/markdown.py +501 -0
  37. workstream/models.py +274 -0
  38. workstream/plan_index.py +88 -0
  39. workstream/provisioning.py +196 -0
  40. workstream/repo_discovery.py +158 -0
  41. workstream/review_artifact.py +96 -0
  42. workstream/scripts/migrate_statuses.py +120 -0
  43. workstream/skills/__init__.py +0 -0
  44. workstream/skills/workstream_context/SKILL.md +75 -0
  45. workstream/skills/workstream_context/__init__.py +0 -0
  46. workstream/skills/workstream_focus/SKILL.md +141 -0
  47. workstream/skills/workstream_init/SKILL.md +86 -0
  48. workstream/skills/workstream_review/SKILL.md +224 -0
  49. workstream/skills/workstream_sweep/SKILL.md +178 -0
  50. workstream/sweep_state.py +93 -0
  51. workstream/templates/dashboard.html +382 -0
  52. workstream/templates/detail.html +360 -0
  53. workstream/templates/plan.html +210 -0
  54. workstream/test/__init__.py +0 -0
  55. workstream/test/conftest.py +221 -0
  56. workstream/test/fixtures/sample_sprint_note.md +10 -0
  57. workstream/test/fixtures/sample_workstream.md +41 -0
  58. workstream/test/test_backfill.py +180 -0
  59. workstream/test/test_batch_writeback.py +81 -0
  60. workstream/test/test_commands.py +938 -0
  61. workstream/test/test_config.py +54 -0
  62. workstream/test/test_focus_artifact.py +211 -0
  63. workstream/test/test_git.py +88 -0
  64. workstream/test/test_heuristics.py +136 -0
  65. workstream/test/test_hierarchy.py +231 -0
  66. workstream/test/test_init.py +452 -0
  67. workstream/test/test_inspect.py +143 -0
  68. workstream/test/test_llm.py +78 -0
  69. workstream/test/test_markdown.py +626 -0
  70. workstream/test/test_models.py +506 -0
  71. workstream/test/test_next.py +206 -0
  72. workstream/test/test_plan_index.py +83 -0
  73. workstream/test/test_provisioning.py +270 -0
  74. workstream/test/test_repo_discovery.py +181 -0
  75. workstream/test/test_resume.py +71 -0
  76. workstream/test/test_sweep.py +1196 -0
  77. workstream/test/test_sweep_state.py +86 -0
  78. workstream/test/test_thoughts.py +516 -0
  79. workstream/test/test_web.py +606 -0
  80. workstream/thoughts.py +505 -0
  81. workstream/web.py +444 -0
  82. workstream_cli-0.0.1.dist-info/LICENSE +21 -0
  83. workstream_cli-0.0.1.dist-info/METADATA +93 -0
  84. workstream_cli-0.0.1.dist-info/RECORD +86 -0
  85. workstream_cli-0.0.1.dist-info/WHEEL +4 -0
  86. workstream_cli-0.0.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,606 @@
1
+ """Tests for workstream.web — HTML dashboard generation and clastic app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from workstream.models import BranchRef, PlanRef, ThreadEntry, Workstream
10
+ from workstream.web import (
11
+ _format_idle_text,
12
+ _format_relative_date,
13
+ _idle_css_class,
14
+ _nest_group,
15
+ build_dashboard_context,
16
+ generate_dashboard_file,
17
+ render_dashboard_html,
18
+ )
19
+
20
+
21
+ # ── Fixtures ─────────────────────────────────────────────────────────
22
+
23
+ def _make_workstream(**overrides) -> Workstream:
24
+ """Build a Workstream with sensible defaults, applying overrides."""
25
+ defaults = {
26
+ 'id': '2026-03-28-abcdef1234',
27
+ 'title': 'Test WS',
28
+ 'status': 'active',
29
+ 'size': 'day',
30
+ 'tags': ['code'],
31
+ 'created': '2026-03-28',
32
+ 'updated': '2026-03-28',
33
+ 'last_activity': '2026-03-28',
34
+ }
35
+ defaults.update(overrides)
36
+ return Workstream(**defaults)
37
+
38
+
39
+ @pytest.fixture()
40
+ def sample_workstreams() -> list[Workstream]:
41
+ """A mixed set of workstreams covering all statuses."""
42
+ return [
43
+ _make_workstream(
44
+ id='ws-active-1', title='Alpha', status='active', size='week',
45
+ tags=['code', 'backend'],
46
+ updated='2026-03-27', last_activity='2026-03-27',
47
+ thread=[ThreadEntry(date='2026-03-27', body='Made progress.')],
48
+ next_actions=['Deploy v2'],
49
+ plans=[PlanRef(repo='myapp', path='alpha-plan.md', status='active', title='Alpha Plan', date='2026-03-25')],
50
+ branches=[BranchRef(repo='myapp', branch='feature/alpha', ahead=5)],
51
+ ),
52
+ _make_workstream(
53
+ id='ws-active-2', title='Beta', status='active', size='day',
54
+ tags=['code'],
55
+ updated='2026-03-26', last_activity='2026-03-26',
56
+ thread=[ThreadEntry(date='2026-03-26', body='Sketching design.')],
57
+ ),
58
+ _make_workstream(
59
+ id='ws-snoozed-1', title='Gamma', status='snoozed', size='day',
60
+ tags=['business'],
61
+ snooze_reason='Waiting on feedback',
62
+ thread=[ThreadEntry(date='2026-03-20', body='Submitted proposal.')],
63
+ ),
64
+ _make_workstream(
65
+ id='ws-snoozed-2', title='Delta', status='snoozed', size='hour',
66
+ tags=['foss'],
67
+ snooze_reason='Not urgent yet',
68
+ ),
69
+ _make_workstream(
70
+ id='ws-completed-1', title='Epsilon', status='completed', size='day',
71
+ tags=['code'],
72
+ thread=[ThreadEntry(date='2026-03-15', body='Shipped.')],
73
+ ),
74
+ ]
75
+
76
+
77
+ # ── Context building ─────────────────────────────────────────────────
78
+
79
+ def test_build_dashboard_context_counts(sample_workstreams):
80
+ """Context contains correct counts for each status."""
81
+ ctx = build_dashboard_context(sample_workstreams)
82
+ assert ctx['total_count'] == 5
83
+ assert ctx['active_count'] == 2
84
+ assert ctx['snoozed_count'] == 2
85
+ assert ctx['completed_count'] == 1
86
+
87
+
88
+ def test_build_dashboard_context_groups_order(sample_workstreams):
89
+ """Status groups appear in the canonical order."""
90
+ ctx = build_dashboard_context(sample_workstreams)
91
+ group_labels = [g['status_label'] for g in ctx['status_groups']]
92
+ assert group_labels == ['Active', 'Snoozed', 'Completed']
93
+
94
+
95
+ def test_build_dashboard_context_empty():
96
+ """Empty workstream list produces zero counts and no groups."""
97
+ ctx = build_dashboard_context([])
98
+ assert ctx['total_count'] == 0
99
+ assert ctx['status_groups'] == []
100
+
101
+
102
+ def test_build_dashboard_context_active_sorted_by_recency(sample_workstreams):
103
+ """Active workstreams are sorted most-recently-active first."""
104
+ ctx = build_dashboard_context(sample_workstreams)
105
+ active_group = ctx['status_groups'][0]
106
+ assert active_group['status_label'] == 'Active'
107
+ titles = [ws['title'] for ws in active_group['workstreams']]
108
+ # Alpha (2026-03-27) is more recent than Beta (2026-03-26)
109
+ assert titles.index('Alpha') < titles.index('Beta')
110
+
111
+
112
+ def test_build_dashboard_context_workstream_fields(sample_workstreams, monkeypatch):
113
+ """Each workstream dict has the expected shape."""
114
+ # Freeze date.today() to one day after fixture dates so relative
115
+ # date formatting is deterministic regardless of when tests run.
116
+ from datetime import date as _date
117
+ class _FakeDate(_date):
118
+ @classmethod
119
+ def today(cls):
120
+ return _date(2026, 3, 28)
121
+ monkeypatch.setattr('workstream.web.date', _FakeDate)
122
+
123
+ ctx = build_dashboard_context(sample_workstreams)
124
+ active = ctx['status_groups'][0]['workstreams']
125
+ alpha = next(ws for ws in active if ws['title'] == 'Alpha')
126
+
127
+ assert alpha['id'] == 'ws-active-1'
128
+ assert alpha['status'] == 'active'
129
+ assert alpha['size'] == 'week'
130
+ assert alpha['tags'] == ['code', 'backend']
131
+ assert alpha['last_thread_date'] == 'yesterday'
132
+ assert alpha['last_thread_iso'] == '2026-03-27'
133
+ assert 'Made progress' in alpha['last_thread_body']
134
+ assert alpha['next_actions'] == ['Deploy v2']
135
+ assert len(alpha['plans']) == 1
136
+ assert alpha['plans'][0]['repo'] == 'myapp'
137
+ assert len(alpha['branches']) == 1
138
+ assert alpha['branches'][0]['ahead'] == 5
139
+
140
+
141
+ def test_build_dashboard_context_snooze_reason(sample_workstreams):
142
+ """Snoozed workstreams include their snooze reason."""
143
+ ctx = build_dashboard_context(sample_workstreams)
144
+ snoozed_group = next(g for g in ctx['status_groups'] if g['status_label'] == 'Snoozed')
145
+ gamma = next(ws for ws in snoozed_group['workstreams'] if ws['title'] == 'Gamma')
146
+ assert gamma['snooze_reason'] == 'Waiting on feedback'
147
+
148
+
149
+ # ── HTML rendering ───────────────────────────────────────────────────
150
+
151
+ def test_render_dashboard_html(sample_workstreams):
152
+ """render_dashboard_html produces valid HTML with expected content."""
153
+ html = render_dashboard_html(sample_workstreams)
154
+
155
+ assert '<!doctype html>' in html
156
+ assert 'Workstreams Dashboard' in html
157
+
158
+ # Status badges
159
+ assert 'badge-active' in html
160
+ assert 'badge-snoozed' in html
161
+ assert 'badge-completed' in html
162
+
163
+ # Workstream titles
164
+ assert 'Alpha' in html
165
+ assert 'Beta' in html
166
+ assert 'Gamma' in html
167
+ assert 'Delta' in html
168
+ assert 'Epsilon' in html
169
+
170
+ # Thread content
171
+ assert 'Made progress' in html
172
+ assert 'Sketching design' in html
173
+
174
+ # Snooze reason
175
+ assert 'Waiting on feedback' in html
176
+
177
+ # Next actions
178
+ assert 'Deploy v2' in html
179
+
180
+ # Plans and branches
181
+ assert 'Alpha Plan' in html
182
+ assert 'feature/alpha' in html
183
+
184
+
185
+ def test_render_dashboard_html_empty():
186
+ """Empty workstream list renders without error."""
187
+ html = render_dashboard_html([])
188
+ assert '<!doctype html>' in html
189
+ assert '0 workstreams' in html
190
+
191
+
192
+ # ── File generation ──────────────────────────────────────────────────
193
+
194
+ def test_generate_dashboard_file(tmp_path: Path, sample_workstreams):
195
+ """generate_dashboard_file writes dashboard.html to the target directory."""
196
+ out_path = generate_dashboard_file(tmp_path, sample_workstreams)
197
+
198
+ assert out_path == tmp_path / 'dashboard.html'
199
+ assert out_path.exists()
200
+
201
+ content = out_path.read_text(encoding='utf-8')
202
+ assert 'Workstreams Dashboard' in content
203
+ assert 'Alpha' in content
204
+
205
+
206
+ # ── Clastic app ──────────────────────────────────────────────────────
207
+
208
+ def test_create_app(tmp_path: Path, sample_workstreams):
209
+ """create_app returns a functioning WSGI application."""
210
+ from workstream.config import Config
211
+ from workstream.markdown import save_workstream
212
+
213
+ ws_dir = tmp_path / 'workstreams'
214
+ ws_dir.mkdir()
215
+
216
+ # Save workstream files so the app can load them
217
+ for ws in sample_workstreams:
218
+ save_workstream(ws, ws_dir / ws.filename)
219
+
220
+ config = Config(workstreams_dir=str(ws_dir))
221
+
222
+ from workstream.web import create_app
223
+ app = create_app(config)
224
+
225
+ # Use clastic's local client
226
+ client = app.get_local_client()
227
+
228
+ # Test HTML route
229
+ resp = client.get('/')
230
+ assert resp.status_code == 200
231
+ assert b'Workstreams Dashboard' in resp.data
232
+ assert b'Alpha' in resp.data
233
+
234
+ # Test JSON route
235
+ resp_json = client.get('/json/')
236
+ assert resp_json.status_code == 200
237
+ import json
238
+ data = json.loads(resp_json.data)
239
+ assert data['total_count'] == 5
240
+ assert 'workstreams' in data
241
+
242
+
243
+
244
+ def test_detail_view(tmp_path: Path, sample_workstreams):
245
+ """Detail route renders full workstream data."""
246
+ from workstream.config import Config
247
+ from workstream.markdown import save_workstream
248
+
249
+ ws_dir = tmp_path / 'workstreams'
250
+ ws_dir.mkdir()
251
+ for ws in sample_workstreams:
252
+ save_workstream(ws, ws_dir / ws.filename)
253
+
254
+ config = Config(workstreams_dir=str(ws_dir))
255
+
256
+ from workstream.web import create_app
257
+ app = create_app(config)
258
+ client = app.get_local_client()
259
+
260
+ # Get Alpha by ID
261
+ resp = client.get('/ws/ws-active-1/')
262
+ assert resp.status_code == 200
263
+ assert b'Alpha' in resp.data
264
+ assert b'Made progress' in resp.data
265
+ assert b'Deploy v2' in resp.data
266
+
267
+ # JSON detail
268
+ import json
269
+ resp_json = client.get('/ws/ws-active-1/json/')
270
+ data = json.loads(resp_json.data)
271
+ assert data['title'] == 'Alpha'
272
+ assert len(data['thread']) == 1
273
+ assert data['thread'][0]['date'] == '2026-03-27'
274
+
275
+
276
+ def test_detail_view_not_found(tmp_path: Path):
277
+ """Invalid workstream ID returns error context."""
278
+ from workstream.config import Config
279
+
280
+ ws_dir = tmp_path / 'workstreams'
281
+ ws_dir.mkdir()
282
+
283
+ config = Config(workstreams_dir=str(ws_dir))
284
+
285
+ from workstream.web import create_app
286
+ app = create_app(config)
287
+ client = app.get_local_client()
288
+
289
+ resp = client.get('/ws/nonexistent-id/')
290
+ assert resp.status_code == 200
291
+ assert b'not found' in resp.data.lower()
292
+
293
+
294
+ # ── Idle text helper ──────────────────────────────────────────────────
295
+
296
+ class TestFormatIdleText:
297
+ def test_zero_returns_empty(self):
298
+ assert _format_idle_text(0) == ''
299
+
300
+ def test_negative_returns_empty(self):
301
+ assert _format_idle_text(-1) == ''
302
+
303
+ def test_one_day(self):
304
+ assert _format_idle_text(1) == '1 day idle'
305
+
306
+ def test_days_under_two_weeks(self):
307
+ assert _format_idle_text(5) == '5 days idle'
308
+ assert _format_idle_text(13) == '13 days idle'
309
+
310
+ def test_weeks_range(self):
311
+ assert _format_idle_text(14) == '~2 weeks idle'
312
+ assert _format_idle_text(21) == '~3 weeks idle'
313
+ assert _format_idle_text(29) == '~4 weeks idle'
314
+
315
+ def test_months_range(self):
316
+ assert _format_idle_text(30) == '~1 months idle'
317
+ assert _format_idle_text(60) == '~2 months idle'
318
+ assert _format_idle_text(90) == '~3 months idle'
319
+
320
+
321
+ class TestFormatRelativeDate:
322
+ def test_empty_returns_empty_pair(self):
323
+ assert _format_relative_date('') == ('', '')
324
+
325
+ def test_today(self):
326
+ from datetime import date
327
+ today = date.today().isoformat()
328
+ display, iso = _format_relative_date(today)
329
+ assert display == 'today'
330
+ assert iso == today
331
+
332
+ def test_yesterday(self):
333
+ from datetime import date, timedelta
334
+ d = (date.today() - timedelta(days=1)).isoformat()
335
+ display, iso = _format_relative_date(d)
336
+ assert display == 'yesterday'
337
+ assert iso == d
338
+
339
+ def test_n_days_ago(self):
340
+ from datetime import date, timedelta
341
+ d = (date.today() - timedelta(days=5)).isoformat()
342
+ display, iso = _format_relative_date(d)
343
+ assert display == '5 days ago'
344
+ assert iso == d
345
+
346
+ def test_exactly_seven_days_shows_relative(self):
347
+ from datetime import date, timedelta
348
+ d = (date.today() - timedelta(days=7)).isoformat()
349
+ display, iso = _format_relative_date(d)
350
+ assert display == '7 days ago'
351
+ assert iso == d
352
+
353
+ def test_eight_days_shows_date(self):
354
+ from datetime import date, timedelta
355
+ d = (date.today() - timedelta(days=8)).isoformat()
356
+ display, iso = _format_relative_date(d)
357
+ assert display == d # absolute date
358
+ assert iso == d
359
+
360
+ def test_future_date_shows_date(self):
361
+ from datetime import date, timedelta
362
+ d = (date.today() + timedelta(days=3)).isoformat()
363
+ display, iso = _format_relative_date(d)
364
+ assert display == d
365
+ assert iso == d
366
+
367
+ def test_iso_is_always_raw_date(self):
368
+ """iso_title must always equal the raw input, regardless of display form."""
369
+ from datetime import date, timedelta
370
+ for days in [0, 1, 3, 7, 14, 60]:
371
+ d = (date.today() - timedelta(days=days)).isoformat()
372
+ _, iso = _format_relative_date(d)
373
+ assert iso == d
374
+
375
+ def test_just_now(self):
376
+ from datetime import datetime, timedelta
377
+ d = (datetime.now() - timedelta(seconds=30)).isoformat(timespec='seconds')
378
+ display, iso = _format_relative_date(d)
379
+ assert display == 'just now'
380
+ assert iso == d
381
+
382
+ def test_minutes_ago(self):
383
+ from datetime import datetime, timedelta
384
+ d = (datetime.now() - timedelta(minutes=45)).isoformat(timespec='seconds')
385
+ display, iso = _format_relative_date(d)
386
+ assert display == '45 minutes ago'
387
+ assert iso == d
388
+
389
+ def test_one_minute_ago(self):
390
+ from datetime import datetime, timedelta
391
+ d = (datetime.now() - timedelta(minutes=1)).isoformat(timespec='seconds')
392
+ display, iso = _format_relative_date(d)
393
+ assert display == '1 minute ago'
394
+
395
+ def test_hours_ago(self):
396
+ from datetime import datetime, timedelta
397
+ d = (datetime.now() - timedelta(hours=3)).isoformat(timespec='seconds')
398
+ display, iso = _format_relative_date(d)
399
+ assert display == '3 hours ago'
400
+ assert iso == d
401
+
402
+ def test_one_hour_ago(self):
403
+ from datetime import datetime, timedelta
404
+ d = (datetime.now() - timedelta(hours=1)).isoformat(timespec='seconds')
405
+ display, iso = _format_relative_date(d)
406
+ assert display == '1 hour ago'
407
+
408
+ def test_datetime_yesterday(self):
409
+ from datetime import datetime, timedelta
410
+ d = (datetime.now() - timedelta(days=1, hours=2)).isoformat(timespec='seconds')
411
+ display, iso = _format_relative_date(d)
412
+ assert display == 'yesterday'
413
+
414
+ def test_datetime_days_ago(self):
415
+ from datetime import datetime, timedelta
416
+ d = (datetime.now() - timedelta(days=5)).isoformat(timespec='seconds')
417
+ display, iso = _format_relative_date(d)
418
+ assert display == '5 days ago'
419
+
420
+ def test_datetime_over_week_shows_date(self):
421
+ from datetime import datetime, timedelta
422
+ d = (datetime.now() - timedelta(days=10)).isoformat(timespec='seconds')
423
+ display, iso = _format_relative_date(d)
424
+ assert display == d[:10] # YYYY-MM-DD portion
425
+ assert iso == d
426
+
427
+ def test_datetime_future_shows_date(self):
428
+ from datetime import datetime, timedelta
429
+ d = (datetime.now() + timedelta(days=2)).isoformat(timespec='seconds')
430
+ display, iso = _format_relative_date(d)
431
+ assert display == d[:10]
432
+
433
+ def test_datetime_near_future_shows_just_now(self):
434
+ """Small negative deltas (< 24h) are timezone skew, not real future."""
435
+ from datetime import datetime, timedelta
436
+ d = (datetime.now() + timedelta(hours=2)).isoformat(timespec='seconds')
437
+ display, iso = _format_relative_date(d)
438
+ assert display == 'just now'
439
+
440
+
441
+ class TestIdleCssClass:
442
+ def test_fresh(self):
443
+ assert _idle_css_class(0) == ''
444
+ assert _idle_css_class(13) == ''
445
+
446
+ def test_warn(self):
447
+ assert _idle_css_class(14) == 'idle-warn'
448
+ assert _idle_css_class(29) == 'idle-warn'
449
+
450
+ def test_stale(self):
451
+ assert _idle_css_class(30) == 'idle-stale'
452
+ assert _idle_css_class(100) == 'idle-stale'
453
+
454
+
455
+ # ── Nest group helper ─────────────────────────────────────────────────
456
+
457
+ class TestNestGroup:
458
+ def test_flat_list_roots_only(self):
459
+ """All root items returned, each with empty children list."""
460
+ items = [
461
+ {'id': 'a', 'parent': '', 'title': 'A'},
462
+ {'id': 'b', 'parent': '', 'title': 'B'},
463
+ ]
464
+ result = _nest_group(items)
465
+ assert [d['id'] for d in result] == ['a', 'b']
466
+ assert all(d['children'] == [] for d in result)
467
+
468
+ def test_child_nested_inside_parent(self):
469
+ """Child appears in parent's children list, not in top-level result."""
470
+ items = [
471
+ {'id': 'parent', 'parent': '', 'title': 'Parent'},
472
+ {'id': 'child', 'parent': 'parent', 'title': 'Child'},
473
+ ]
474
+ result = _nest_group(items)
475
+ assert len(result) == 1
476
+ assert result[0]['id'] == 'parent'
477
+ assert len(result[0]['children']) == 1
478
+ assert result[0]['children'][0]['id'] == 'child'
479
+
480
+ def test_cross_group_parent_treated_as_root(self):
481
+ """A child whose parent is in a different status group appears as root."""
482
+ items = [
483
+ {'id': 'orphan', 'parent': 'not-in-group', 'title': 'Orphan'},
484
+ ]
485
+ result = _nest_group(items)
486
+ assert len(result) == 1
487
+ assert result[0]['id'] == 'orphan'
488
+ assert result[0]['children'] == []
489
+
490
+ def test_grandchild_nested(self):
491
+ """Grandchild nested inside child inside root."""
492
+ items = [
493
+ {'id': 'root', 'parent': '', 'title': 'Root'},
494
+ {'id': 'child', 'parent': 'root', 'title': 'Child'},
495
+ {'id': 'grandchild', 'parent': 'child', 'title': 'Grandchild'},
496
+ ]
497
+ result = _nest_group(items)
498
+ assert len(result) == 1
499
+ root = result[0]
500
+ assert root['id'] == 'root'
501
+ assert len(root['children']) == 1
502
+ child = root['children'][0]
503
+ assert child['id'] == 'child'
504
+ assert len(child['children']) == 1
505
+ assert child['children'][0]['id'] == 'grandchild'
506
+
507
+ # ── Dashboard context: new fields ─────────────────────────────────────
508
+
509
+ def test_dashboard_context_has_all_tags(sample_workstreams):
510
+ ctx = build_dashboard_context(sample_workstreams)
511
+ assert 'all_tags' in ctx
512
+ # sample tags: code, backend, business, foss
513
+ assert set(ctx['all_tags']) == {'code', 'backend', 'business', 'foss'}
514
+ # sorted
515
+ assert ctx['all_tags'] == sorted(ctx['all_tags'])
516
+
517
+
518
+ def test_dashboard_context_active_tag_default(sample_workstreams):
519
+ ctx = build_dashboard_context(sample_workstreams)
520
+ assert ctx['active_tag'] == ''
521
+
522
+
523
+ def test_dashboard_context_active_tag_set(sample_workstreams):
524
+ ctx = build_dashboard_context(sample_workstreams, active_tag='code')
525
+ assert ctx['active_tag'] == 'code'
526
+
527
+
528
+ def test_dashboard_context_has_idle_text(sample_workstreams):
529
+ ctx = build_dashboard_context(sample_workstreams)
530
+ active_group = ctx['status_groups'][0]
531
+ for ws in active_group['workstreams']:
532
+ assert 'idle_text' in ws
533
+ assert 'idle_class' in ws
534
+
535
+
536
+ def test_dashboard_context_nesting_fields(sample_workstreams):
537
+ """Every root workstream dict has a children key."""
538
+ ctx = build_dashboard_context(sample_workstreams)
539
+ for group in ctx['status_groups']:
540
+ for ws in group['workstreams']:
541
+ assert 'children' in ws
542
+
543
+ # ── Tag route ─────────────────────────────────────────────────────────
544
+
545
+ def test_tag_route(tmp_path: Path, sample_workstreams):
546
+ """The /tag/<name>/ route filters workstreams to those with the tag."""
547
+ from workstream.config import Config
548
+ from workstream.markdown import save_workstream
549
+
550
+ ws_dir = tmp_path / 'workstreams'
551
+ ws_dir.mkdir()
552
+ for ws in sample_workstreams:
553
+ save_workstream(ws, ws_dir / ws.filename)
554
+
555
+ config = Config(workstreams_dir=str(ws_dir))
556
+
557
+ from workstream.web import create_app
558
+ app = create_app(config)
559
+ client = app.get_local_client()
560
+
561
+ # 'code' tag is on Alpha, Beta, Epsilon
562
+ resp = client.get('/tag/code/')
563
+ assert resp.status_code == 200
564
+ assert b'Alpha' in resp.data
565
+ assert b'Beta' in resp.data
566
+ # Gamma has 'business' tag, not 'code'
567
+ assert b'Gamma' not in resp.data
568
+ # Should show active_tag in banner
569
+ assert b'#code' in resp.data
570
+
571
+
572
+ def test_tag_route_empty(tmp_path: Path, sample_workstreams):
573
+ """Tag route with no matching workstreams returns empty dashboard."""
574
+ from workstream.config import Config
575
+ from workstream.markdown import save_workstream
576
+
577
+ ws_dir = tmp_path / 'workstreams'
578
+ ws_dir.mkdir()
579
+ for ws in sample_workstreams:
580
+ save_workstream(ws, ws_dir / ws.filename)
581
+
582
+ config = Config(workstreams_dir=str(ws_dir))
583
+
584
+ from workstream.web import create_app
585
+ app = create_app(config)
586
+ client = app.get_local_client()
587
+
588
+ resp = client.get('/tag/nonexistent/')
589
+ assert resp.status_code == 200
590
+ assert b'0 workstreams' in resp.data
591
+
592
+
593
+ def test_dashboard_html_has_clickable_tags(sample_workstreams):
594
+ """Rendered HTML contains tag links, not plain spans."""
595
+ html = render_dashboard_html(sample_workstreams)
596
+ assert '<a class="tag" href="/tag/code/">#code</a>' in html
597
+ assert '<span class="tag">' not in html
598
+
599
+
600
+ def test_dashboard_html_has_inline_children(sample_workstreams):
601
+ """Parent card contains inline child rows; old indent class is gone."""
602
+ parent = _make_workstream(id='ws-parent', title='Parent WS', parent='')
603
+ child = _make_workstream(id='ws-child', title='Child WS', parent='ws-parent')
604
+ html = render_dashboard_html([parent, child])
605
+ assert 'ws-child-row' in html
606
+ assert 'ws-card-child' not in html