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.
- workstream/ARCHITECTURE.md +89 -0
- workstream/__init__.py +8 -0
- workstream/cli.py +136 -0
- workstream/commands/__init__.py +0 -0
- workstream/commands/backfill.py +139 -0
- workstream/commands/block.py +93 -0
- workstream/commands/checkin.py +51 -0
- workstream/commands/cron.py +119 -0
- workstream/commands/focus_cmd.py +273 -0
- workstream/commands/idea.py +172 -0
- workstream/commands/index.py +89 -0
- workstream/commands/init.py +567 -0
- workstream/commands/inspect_cmd.py +354 -0
- workstream/commands/list_cmd.py +99 -0
- workstream/commands/nest.py +108 -0
- workstream/commands/new.py +95 -0
- workstream/commands/next_cmd.py +333 -0
- workstream/commands/report.py +190 -0
- workstream/commands/resume.py +145 -0
- workstream/commands/review.py +227 -0
- workstream/commands/serve.py +23 -0
- workstream/commands/setup.py +178 -0
- workstream/commands/show.py +123 -0
- workstream/commands/snooze.py +117 -0
- workstream/commands/stale.py +116 -0
- workstream/commands/sweep.py +1753 -0
- workstream/commands/tree.py +105 -0
- workstream/commands/update_status.py +117 -0
- workstream/config.py +322 -0
- workstream/extensions/__init__.py +0 -0
- workstream/extensions/workstream.ts +633 -0
- workstream/focus_artifact.py +157 -0
- workstream/git.py +194 -0
- workstream/harness.py +49 -0
- workstream/llm.py +78 -0
- workstream/markdown.py +501 -0
- workstream/models.py +274 -0
- workstream/plan_index.py +88 -0
- workstream/provisioning.py +196 -0
- workstream/repo_discovery.py +158 -0
- workstream/review_artifact.py +96 -0
- workstream/scripts/migrate_statuses.py +120 -0
- workstream/skills/__init__.py +0 -0
- workstream/skills/workstream_context/SKILL.md +75 -0
- workstream/skills/workstream_context/__init__.py +0 -0
- workstream/skills/workstream_focus/SKILL.md +141 -0
- workstream/skills/workstream_init/SKILL.md +86 -0
- workstream/skills/workstream_review/SKILL.md +224 -0
- workstream/skills/workstream_sweep/SKILL.md +178 -0
- workstream/sweep_state.py +93 -0
- workstream/templates/dashboard.html +382 -0
- workstream/templates/detail.html +360 -0
- workstream/templates/plan.html +210 -0
- workstream/test/__init__.py +0 -0
- workstream/test/conftest.py +221 -0
- workstream/test/fixtures/sample_sprint_note.md +10 -0
- workstream/test/fixtures/sample_workstream.md +41 -0
- workstream/test/test_backfill.py +180 -0
- workstream/test/test_batch_writeback.py +81 -0
- workstream/test/test_commands.py +938 -0
- workstream/test/test_config.py +54 -0
- workstream/test/test_focus_artifact.py +211 -0
- workstream/test/test_git.py +88 -0
- workstream/test/test_heuristics.py +136 -0
- workstream/test/test_hierarchy.py +231 -0
- workstream/test/test_init.py +452 -0
- workstream/test/test_inspect.py +143 -0
- workstream/test/test_llm.py +78 -0
- workstream/test/test_markdown.py +626 -0
- workstream/test/test_models.py +506 -0
- workstream/test/test_next.py +206 -0
- workstream/test/test_plan_index.py +83 -0
- workstream/test/test_provisioning.py +270 -0
- workstream/test/test_repo_discovery.py +181 -0
- workstream/test/test_resume.py +71 -0
- workstream/test/test_sweep.py +1196 -0
- workstream/test/test_sweep_state.py +86 -0
- workstream/test/test_thoughts.py +516 -0
- workstream/test/test_web.py +606 -0
- workstream/thoughts.py +505 -0
- workstream/web.py +444 -0
- workstream_cli-0.0.1.dist-info/LICENSE +21 -0
- workstream_cli-0.0.1.dist-info/METADATA +93 -0
- workstream_cli-0.0.1.dist-info/RECORD +86 -0
- workstream_cli-0.0.1.dist-info/WHEEL +4 -0
- 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
|