| 1 | import io |
| 2 | import tempfile |
| 3 | import unittest |
| 4 | from pathlib import Path |
| 5 | from unittest import mock |
| 6 | |
| 7 | import scripts.generate_rollups as generate_rollups |
| 8 | import scripts.generate_yearly_narrative as generate_yearly_narrative |
| 9 | |
| 10 | WORKSPACE_ROOT = Path(".test-workspaces") |
| 11 | |
| 12 | |
| 13 | def make_summary( |
| 14 | *, |
| 15 | week: str, |
| 16 | date: str, |
| 17 | top_repo: str, |
| 18 | summary: str, |
| 19 | signal: str, |
| 20 | noise: str, |
| 21 | gaps: str, |
| 22 | conclusion: str, |
| 23 | tags: tuple[str, ...] = ("ai", "agents", "developer-tooling"), |
| 24 | repo_mentions: tuple[str, ...] = (), |
| 25 | ) -> str: |
| 26 | year = int(week[:4]) |
| 27 | rendered_tags = ", ".join(tags) |
| 28 | linked_mentions = " ".join( |
| 29 | f"[{repo}](https://github.com/{repo}) is part of the weekly conversation." |
| 30 | for repo in repo_mentions |
| 31 | ) |
| 32 | notable_new = f"A fresh set of launches landed. {linked_mentions}".strip() |
| 33 | return f'''--- |
| 34 | title: "Week {int(week[-2:])}, {year} Analysis" |
| 35 | date: {date} |
| 36 | week: "{week}" |
| 37 | year: {year} |
| 38 | tags: [{rendered_tags}] |
| 39 | categories: [weekly] |
| 40 | repos_featured: 10 |
| 41 | top_repo: "{top_repo}" |
| 42 | quality_score: 80 |
| 43 | summary: "{summary}" |
| 44 | stars_tracked: 1000 |
| 45 | --- |
| 46 | |
| 47 | ## This Week's Trends |
| 48 | |
| 49 | Trend analysis for {week}. Developer activity concentrated around practical tooling and infrastructure work. |
| 50 | |
| 51 | ## Where Industry Meets Code |
| 52 | |
| 53 | No press data available for this automated test summary. Developer activity tells a coherent story on its own. |
| 54 | |
| 55 | ## Signal & Noise |
| 56 | |
| 57 | {signal} {noise} |
| 58 | |
| 59 | ## Blind Spots |
| 60 | |
| 61 | {gaps} |
| 62 | |
| 63 | ## The Week Ahead |
| 64 | |
| 65 | {conclusion} |
| 66 | |
| 67 | ## Key References |
| 68 | |
| 69 | ### Notable Projects |
| 70 | |
| 71 | {notable_new} |
| 72 | |
| 73 | ### Press & Industry |
| 74 | |
| 75 | No press data was provided this week. |
| 76 | ''' |
| 77 | |
| 78 | |
| 79 | def temporary_workspace() -> tempfile.TemporaryDirectory[str]: |
| 80 | WORKSPACE_ROOT.mkdir(exist_ok=True) |
| 81 | return tempfile.TemporaryDirectory(dir=WORKSPACE_ROOT.resolve()) |
| 82 | |
| 83 | |
| 84 | class GenerateRollupsTests(unittest.TestCase): |
| 85 | def test_generate_rollups_creates_monthly_and_yearly_pages(self) -> None: |
| 86 | with temporary_workspace() as tmpdir: |
| 87 | base = Path(tmpdir) |
| 88 | analyzed_dir = base / "data" / "analyzed" |
| 89 | content_root = base / "content" |
| 90 | analyzed_dir.mkdir(parents=True) |
| 91 | |
| 92 | (analyzed_dir / "2026-W21-summary.md").write_text( |
| 93 | make_summary( |
| 94 | week="2026-W21", |
| 95 | date="2026-05-18T12:07:20+00:00", |
| 96 | top_repo="octo/signal-kit", |
| 97 | summary="Practical agent tooling led the week.", |
| 98 | signal="Teams preferred operational automation over generic hype.", |
| 99 | noise="Exploit-heavy projects still added editorial noise.", |
| 100 | gaps="Reliable momentum data remained missing.", |
| 101 | conclusion="The strongest projects made automation safer to adopt.", |
| 102 | ), |
| 103 | encoding="utf-8", |
| 104 | ) |
| 105 | |
| 106 | written = generate_rollups.generate_rollups(analyzed_dir, content_root) |
| 107 | |
| 108 | monthly_path = content_root / "monthly" / "2026" / "05.md" |
| 109 | yearly_path = content_root / "yearly" / "2026.md" |
| 110 | self.assertEqual(written, [monthly_path, yearly_path]) |
| 111 | |
| 112 | monthly = monthly_path.read_text(encoding="utf-8") |
| 113 | self.assertIn("Define the Month — May 2026", monthly) |
| 114 | self.assertIn('categories: ["monthly"]', monthly) |
| 115 | self.assertIn('weeks_covered: ["2026-W21"]', monthly) |
| 116 | self.assertIn("total_repos_featured: 1", monthly) |
| 117 | self.assertIn("---\n\n## Month Synthesis", monthly) |
| 118 | self.assertIn("## Month Overview", monthly) |
| 119 | self.assertIn("### Week 2026-W21", monthly) |
| 120 | self.assertIn("[Week 21, 2026](/weekly/2026/W21/)", monthly) |
| 121 | self.assertIn("[octo/signal-kit](https://github.com/octo/signal-kit)", monthly) |
| 122 | |
| 123 | yearly = yearly_path.read_text(encoding="utf-8") |
| 124 | self.assertIn("The Ecosystem Reorganizes", yearly) |
| 125 | self.assertIn('categories: ["yearly"]', yearly) |
| 126 | self.assertIn('months_covered: ["2026-05"]', yearly) |
| 127 | self.assertIn('format: "narrative"', yearly) |
| 128 | self.assertIn("## Year in Review", yearly) |
| 129 | self.assertIn("Practical agent tooling led the week.", yearly) |
| 130 | self.assertNotIn("## Arc", yearly) |
| 131 | |
| 132 | def test_generate_rollups_is_append_only_for_existing_pages(self) -> None: |
| 133 | with temporary_workspace() as tmpdir: |
| 134 | base = Path(tmpdir) |
| 135 | analyzed_dir = base / "data" / "analyzed" |
| 136 | content_root = base / "content" |
| 137 | analyzed_dir.mkdir(parents=True) |
| 138 | |
| 139 | (analyzed_dir / "2026-W21-summary.md").write_text( |
| 140 | make_summary( |
| 141 | week="2026-W21", |
| 142 | date="2026-05-18T12:07:20+00:00", |
| 143 | top_repo="octo/signal-kit", |
| 144 | summary="Practical agent tooling led the week.", |
| 145 | signal="Teams preferred operational automation over generic hype.", |
| 146 | noise="Exploit-heavy projects still added editorial noise.", |
| 147 | gaps="Reliable momentum data remained missing.", |
| 148 | conclusion="The strongest projects made automation safer to adopt.", |
| 149 | tags=("alpha",), |
| 150 | repo_mentions=("octo/shared-kit",), |
| 151 | ), |
| 152 | encoding="utf-8", |
| 153 | ) |
| 154 | generate_rollups.generate_rollups(analyzed_dir, content_root) |
| 155 | monthly_path = content_root / "monthly" / "2026" / "05.md" |
| 156 | yearly_path = content_root / "yearly" / "2026.md" |
| 157 | first_monthly = monthly_path.read_text(encoding="utf-8") |
| 158 | first_yearly = yearly_path.read_text(encoding="utf-8") |
| 159 | |
| 160 | (analyzed_dir / "2026-W22-summary.md").write_text( |
| 161 | make_summary( |
| 162 | week="2026-W22", |
| 163 | date="2026-05-25T12:07:20+00:00", |
| 164 | top_repo="octo/steady-watch", |
| 165 | summary="Observability and release safety gained more traction.", |
| 166 | signal="Teams doubled down on measurable automation and release health.", |
| 167 | noise="Wrapper projects still outnumbered differentiated platforms.", |
| 168 | gaps="Defensive tooling still lagged behind orchestration tools.", |
| 169 | conclusion="The durable winners reduced toil without hiding trade-offs.", |
| 170 | tags=("beta",), |
| 171 | repo_mentions=("octo/shared-kit", "octo/deploy-guard"), |
| 172 | ), |
| 173 | encoding="utf-8", |
| 174 | ) |
| 175 | |
| 176 | generate_rollups.generate_rollups(analyzed_dir, content_root) |
| 177 | second_monthly = monthly_path.read_text(encoding="utf-8") |
| 178 | second_yearly = yearly_path.read_text(encoding="utf-8") |
| 179 | |
| 180 | self.assertIn('weeks_covered: ["2026-W21", "2026-W22"]', second_monthly) |
| 181 | self.assertIn("total_repos_featured: 4", second_monthly) |
| 182 | self.assertIn('months_covered: ["2026-05"]', second_yearly) |
| 183 | for expected in [ |
| 184 | "### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)", |
| 185 | "- [octo/signal-kit](https://github.com/octo/signal-kit) led the published weekly analysis for 2026-W21.", |
| 186 | "- Signal: Teams preferred operational automation over generic hype.", |
| 187 | "- Gap to watch: Reliable momentum data remained missing.", |
| 188 | "- Recurring themes so far: alpha.", |
| 189 | ]: |
| 190 | self.assertIn(expected, second_monthly) |
| 191 | self.assertIn("- Recurring themes so far: alpha, beta.", second_monthly) |
| 192 | self.assertIn('format: "narrative"', second_yearly) |
| 193 | self.assertIn("## Year in Review", second_yearly) |
| 194 | self.assertIn("Observability and release safety gained more traction.", second_yearly) |
| 195 | self.assertNotIn("## Arc", second_yearly) |
| 196 | self.assertEqual(second_monthly.count("### Week 2026-W21"), 4) |
| 197 | self.assertEqual(second_monthly.count("### Week 2026-W22"), 4) |
| 198 | self.assertEqual(second_yearly.count("## Year in Review"), 1) |
| 199 | self.assertNotEqual(first_monthly, second_monthly) |
| 200 | self.assertNotEqual(first_yearly, second_yearly) |
| 201 | |
| 202 | def test_generate_yearly_narrative_standalone_writes_narrative_format(self) -> None: |
| 203 | with temporary_workspace() as tmpdir: |
| 204 | base = Path(tmpdir) |
| 205 | content_root = base / "content" |
| 206 | monthly_dir = content_root / "monthly" / "2026" |
| 207 | monthly_dir.mkdir(parents=True) |
| 208 | |
| 209 | (monthly_dir / "05.md").write_text( |
| 210 | """--- |
| 211 | title: "May 2026 Rollup" |
| 212 | date: "2026-05-25T11:56:08+00:00" |
| 213 | month: 5 |
| 214 | year: 2026 |
| 215 | categories: ["monthly"] |
| 216 | weeks_covered: ["2026-W21", "2026-W22"] |
| 217 | total_repos_featured: 32 |
| 218 | --- |
| 219 | |
| 220 | ## Month Overview |
| 221 | |
| 222 | ### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/) |
| 223 | - Summary: May defined the shift from maturing agent infrastructure toward a visible agent skills economy. |
| 224 | - Repositories featured this week: 17 |
| 225 | - Recurring themes so far: agent-skills, mcp, small-models. |
| 226 | |
| 227 | ## Trends Observed |
| 228 | |
| 229 | ### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/) |
| 230 | - Signal: Agent skills kept widening as a distribution format. |
| 231 | - Noise: Coordinated star-farming distorted discovery. |
| 232 | |
| 233 | ## Key Takeaways |
| 234 | |
| 235 | ### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/) |
| 236 | - Gap to watch: Agent execution security remained underbuilt. |
| 237 | - Closing read: Skills were likely to spread into more teams. |
| 238 | """, |
| 239 | encoding="utf-8", |
| 240 | ) |
| 241 | (monthly_dir / "06.md").write_text( |
| 242 | """--- |
| 243 | title: "June 2026 Rollup" |
| 244 | date: "2026-06-08T12:40:47+00:00" |
| 245 | month: 6 |
| 246 | year: 2026 |
| 247 | categories: ["monthly"] |
| 248 | weeks_covered: ["2026-W23", "2026-W24"] |
| 249 | total_repos_featured: 36 |
| 250 | --- |
| 251 | |
| 252 | ## Month Overview |
| 253 | |
| 254 | ### Week 2026-W23 — [Week 23, 2026](/weekly/2026/W23/) |
| 255 | - Summary: June pushed agent skills into East Asian workflows, self-hosted AI workspaces, and role-specific verticalization. |
| 256 | - Repositories featured this week: 36 |
| 257 | - Recurring themes so far: agent-skills, self-hosted-ai, coding-agents. |
| 258 | |
| 259 | ## Trends Observed |
| 260 | |
| 261 | ### Week 2026-W23 — [Week 23, 2026](/weekly/2026/W23/) |
| 262 | - Signal: Agent skills globalized quickly while local-sovereignty tooling gained traction. |
| 263 | - Noise: Fork inflation replaced the earlier star-farming wave. |
| 264 | |
| 265 | ## Key Takeaways |
| 266 | |
| 267 | ### Week 2026-W23 — [Week 23, 2026](/weekly/2026/W23/) |
| 268 | - Gap to watch: Prompt-injection and skills supply-chain security still lacked a category winner. |
| 269 | - Closing read: Expect more vertical skills packs and more local-first AI tooling. |
| 270 | """, |
| 271 | encoding="utf-8", |
| 272 | ) |
| 273 | |
| 274 | written = generate_yearly_narrative.generate_yearly_narratives(content_root) |
| 275 | yearly_path = content_root / "yearly" / "2026.md" |
| 276 | |
| 277 | self.assertEqual(written, [yearly_path]) |
| 278 | yearly = yearly_path.read_text(encoding="utf-8") |
| 279 | self.assertIn("When Agents Became Infrastructure", yearly) |
| 280 | self.assertIn('format: "narrative"', yearly) |
| 281 | self.assertIn("## Year in Review", yearly) |
| 282 | self.assertIn("split-screen story", yearly) |
| 283 | self.assertIn("globalized", yearly) |
| 284 | self.assertIn("What was confirmed:", yearly) |
| 285 | self.assertIn("What weakened:", yearly) |
| 286 | self.assertNotIn("## Arc", yearly) |
| 287 | self.assertNotIn( |
| 288 | "agent-skills: infrastructure > economy > globalization > verticalization", yearly |
| 289 | ) |
| 290 | |
| 291 | def test_generate_yearly_narrative_prefers_month_synthesis_artifacts(self) -> None: |
| 292 | with temporary_workspace() as tmpdir: |
| 293 | base = Path(tmpdir) |
| 294 | content_root = base / "content" |
| 295 | monthly_dir = content_root / "monthly" / "2026" |
| 296 | analyzed_dir = base / "data" / "analyzed" |
| 297 | monthly_dir.mkdir(parents=True) |
| 298 | analyzed_dir.mkdir(parents=True) |
| 299 | |
| 300 | (monthly_dir / "05.md").write_text( |
| 301 | """--- |
| 302 | title: "May 2026 Rollup" |
| 303 | date: "2026-05-25T11:56:08+00:00" |
| 304 | month: 5 |
| 305 | year: 2026 |
| 306 | categories: ["monthly"] |
| 307 | weeks_covered: ["2026-W21", "2026-W22"] |
| 308 | total_repos_featured: 32 |
| 309 | --- |
| 310 | |
| 311 | ## Month Overview |
| 312 | |
| 313 | ### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/) |
| 314 | - Summary: Fallback monthly summary that should not drive the yearly opening. |
| 315 | - Repositories featured this week: 17 |
| 316 | - Recurring themes so far: agent-skills, mcp. |
| 317 | """, |
| 318 | encoding="utf-8", |
| 319 | ) |
| 320 | (analyzed_dir / "2026-05-month-synthesis.md").write_text( |
| 321 | """--- |
| 322 | title: "May 2026 Monthly Synthesis" |
| 323 | date: "2026-05-25T11:56:08+00:00" |
| 324 | month: 5 |
| 325 | year: 2026 |
| 326 | --- |
| 327 | |
| 328 | ## Month Synthesis |
| 329 | |
| 330 | May made it clear that teams were no longer evaluating agent skills as demos; they were treating them as operating infrastructure with distribution consequences. |
| 331 | |
| 332 | The strongest thread was a shift from raw capability talk toward packaging, trust, and fit inside real workflows. |
| 333 | """, |
| 334 | encoding="utf-8", |
| 335 | ) |
| 336 | |
| 337 | written = generate_yearly_narrative.generate_yearly_narratives(content_root) |
| 338 | yearly_path = content_root / "yearly" / "2026.md" |
| 339 | |
| 340 | self.assertEqual(written, [yearly_path]) |
| 341 | yearly = yearly_path.read_text(encoding="utf-8") |
| 342 | self.assertIn("operating infrastructure with distribution consequences", yearly) |
| 343 | self.assertNotIn( |
| 344 | "Fallback monthly summary that should not drive the yearly opening", yearly |
| 345 | ) |
| 346 | |
| 347 | def test_generate_rollups_replaces_placeholder_and_preserves_unknown_sections(self) -> None: |
| 348 | with temporary_workspace() as tmpdir: |
| 349 | base = Path(tmpdir) |
| 350 | analyzed_dir = base / "data" / "analyzed" |
| 351 | content_root = base / "content" |
| 352 | analyzed_dir.mkdir(parents=True) |
| 353 | monthly_path = content_root / "monthly" / "2026" / "05.md" |
| 354 | monthly_path.parent.mkdir(parents=True, exist_ok=True) |
| 355 | monthly_path.write_text( |
| 356 | '---\ntitle: "May 2026 Rollup"\n---\n\n## Month Overview\n\n_No updates yet._\n\n## Legacy Notes\n\nKeep this section.\n', |
| 357 | encoding="utf-8", |
| 358 | ) |
| 359 | |
| 360 | (analyzed_dir / "2026-W21-summary.md").write_text( |
| 361 | make_summary( |
| 362 | week="2026-W21", |
| 363 | date="2026-05-18T12:07:20+00:00", |
| 364 | top_repo="octo/signal-kit", |
| 365 | summary="Practical agent tooling led the week.", |
| 366 | signal="Teams preferred operational automation over generic hype.", |
| 367 | noise="Exploit-heavy projects still added editorial noise.", |
| 368 | gaps="Reliable momentum data remained missing.", |
| 369 | conclusion="The strongest projects made automation safer to adopt.", |
| 370 | ), |
| 371 | encoding="utf-8", |
| 372 | ) |
| 373 | |
| 374 | generate_rollups.generate_rollups(analyzed_dir, content_root) |
| 375 | monthly = monthly_path.read_text(encoding="utf-8") |
| 376 | |
| 377 | self.assertNotIn("_No updates yet._\n\n### Week 2026-W21", monthly) |
| 378 | self.assertIn("### Week 2026-W21", monthly) |
| 379 | self.assertIn("## Legacy Notes\n\nKeep this section.", monthly) |
| 380 | |
| 381 | def test_generate_rollups_returns_empty_when_no_summaries_exist(self) -> None: |
| 382 | with temporary_workspace() as tmpdir: |
| 383 | base = Path(tmpdir) |
| 384 | analyzed_dir = base / "data" / "analyzed" |
| 385 | content_root = base / "content" |
| 386 | analyzed_dir.mkdir(parents=True) |
| 387 | |
| 388 | self.assertEqual(generate_rollups.generate_rollups(analyzed_dir, content_root), []) |
| 389 | stderr = io.StringIO() |
| 390 | with mock.patch("sys.stderr", stderr): |
| 391 | self.assertEqual( |
| 392 | generate_rollups.main( |
| 393 | ["--analyzed-dir", str(analyzed_dir), "--content-root", str(content_root)] |
| 394 | ), |
| 395 | 0, |
| 396 | ) |
| 397 | self.assertIn("No weekly summaries found", stderr.getvalue()) |
| 398 | |
| 399 | def test_parse_args_defaults_resolve_from_project_root(self) -> None: |
| 400 | args = generate_rollups.parse_args([]) |
| 401 | |
| 402 | self.assertEqual( |
| 403 | args.analyzed_dir, Path(generate_rollups.PROJECT_ROOT / "data" / "analyzed") |
| 404 | ) |
| 405 | self.assertEqual(args.content_root, Path(generate_rollups.PROJECT_ROOT / "content")) |
| 406 | |
| 407 | def test_generate_rolling_report_creates_last_month(self) -> None: |
| 408 | with temporary_workspace() as tmpdir: |
| 409 | base = Path(tmpdir) |
| 410 | content_root = base / "content" |
| 411 | weekly_dir = content_root / "weekly" / "2026" |
| 412 | weekly_dir.mkdir(parents=True) |
| 413 | |
| 414 | for wnum, date in [ |
| 415 | ("21", "2026-05-21T12:00:00+00:00"), |
| 416 | ("22", "2026-05-25T12:00:00+00:00"), |
| 417 | ("23", "2026-06-06T12:00:00+00:00"), |
| 418 | ("24", "2026-06-08T12:00:00+00:00"), |
| 419 | ]: |
| 420 | (weekly_dir / f"W{wnum}.md").write_text( |
| 421 | make_summary( |
| 422 | week=f"2026-W{wnum}", |
| 423 | date=date, |
| 424 | top_repo=f"octo/repo-{wnum}", |
| 425 | summary=f"Summary for W{wnum}.", |
| 426 | signal=f"Signal for W{wnum}.", |
| 427 | noise=f"Noise for W{wnum}.", |
| 428 | gaps=f"Gaps for W{wnum}.", |
| 429 | conclusion=f"Conclusion for W{wnum}.", |
| 430 | tags=("ai", "agents") if wnum in ("21", "22") else ("ai", "new-tag"), |
| 431 | ), |
| 432 | encoding="utf-8", |
| 433 | ) |
| 434 | |
| 435 | result = generate_rollups.generate_rolling_report(content_root) |
| 436 | |
| 437 | self.assertIsNotNone(result) |
| 438 | self.assertEqual(result, content_root / "rolling" / "last-month.md") |
| 439 | self.assertTrue(result.exists()) |
| 440 | |
| 441 | report = result.read_text(encoding="utf-8") |
| 442 | self.assertIn("title: Rolling 4-Week Context", report) |
| 443 | self.assertIn("updated: 2026-W24", report) |
| 444 | self.assertIn("weeks: [W21, W22, W23, W24]", report) |
| 445 | self.assertIn("## Active Trends", report) |
| 446 | self.assertIn("## Trend Velocity", report) |
| 447 | self.assertIn("## Open Predictions", report) |
| 448 | self.assertIn("## Noise Patterns", report) |
| 449 | self.assertIn("Signal for W", report) |
| 450 | self.assertIn("Noise for W", report) |
| 451 | self.assertIn("[W24] Gaps for W24.", report) |
| 452 | |
| 453 | def test_generate_rolling_report_returns_none_when_no_content(self) -> None: |
| 454 | with temporary_workspace() as tmpdir: |
| 455 | base = Path(tmpdir) |
| 456 | content_root = base / "content" |
| 457 | content_root.mkdir(parents=True) |
| 458 | |
| 459 | result = generate_rollups.generate_rolling_report(content_root) |
| 460 | self.assertIsNone(result) |
| 461 | |
| 462 | def test_rolling_flag_triggers_rolling_report(self) -> None: |
| 463 | with temporary_workspace() as tmpdir: |
| 464 | base = Path(tmpdir) |
| 465 | analyzed_dir = base / "data" / "analyzed" |
| 466 | content_root = base / "content" |
| 467 | analyzed_dir.mkdir(parents=True) |
| 468 | weekly_dir = content_root / "weekly" / "2026" |
| 469 | weekly_dir.mkdir(parents=True) |
| 470 | |
| 471 | summary_text = make_summary( |
| 472 | week="2026-W21", |
| 473 | date="2026-05-21T12:00:00+00:00", |
| 474 | top_repo="octo/signal-kit", |
| 475 | summary="Test summary.", |
| 476 | signal="Test signal.", |
| 477 | noise="Test noise.", |
| 478 | gaps="Test gaps.", |
| 479 | conclusion="Test conclusion.", |
| 480 | ) |
| 481 | (analyzed_dir / "2026-W21-summary.md").write_text(summary_text, encoding="utf-8") |
| 482 | (weekly_dir / "W21.md").write_text(summary_text, encoding="utf-8") |
| 483 | |
| 484 | ret = generate_rollups.main( |
| 485 | [ |
| 486 | "--analyzed-dir", |
| 487 | str(analyzed_dir), |
| 488 | "--content-root", |
| 489 | str(content_root), |
| 490 | "--rolling", |
| 491 | ] |
| 492 | ) |
| 493 | self.assertEqual(ret, 0) |
| 494 | self.assertTrue((content_root / "rolling" / "last-month.md").exists()) |
| 495 | |
| 496 | def test_velocity_classification(self) -> None: |
| 497 | tags_by_week = [ |
| 498 | ("2026-W21", {"ai", "agents", "old-tag"}), |
| 499 | ("2026-W22", {"ai", "agents"}), |
| 500 | ("2026-W23", {"ai", "new-thing"}), |
| 501 | ("2026-W24", {"ai", "new-thing"}), |
| 502 | ] |
| 503 | velocity = generate_rollups._classify_velocity(tags_by_week) |
| 504 | self.assertEqual(velocity["ai"], "accelerating") |
| 505 | self.assertEqual(velocity["old-tag"], "dying") |
| 506 | self.assertIn(velocity["new-thing"], ("accelerating", "new")) |
| 507 | |
| 508 | def test_extract_summary_in_yearly_frontmatter(self) -> None: |
| 509 | """Yearly pages must include a summary field in frontmatter.""" |
| 510 | with temporary_workspace() as tmpdir: |
| 511 | base = Path(tmpdir) |
| 512 | analyzed_dir = base / "data" / "analyzed" |
| 513 | content_root = base / "content" |
| 514 | analyzed_dir.mkdir(parents=True) |
| 515 | |
| 516 | (analyzed_dir / "2026-W21-summary.md").write_text( |
| 517 | make_summary( |
| 518 | week="2026-W21", |
| 519 | date="2026-05-18T12:07:20+00:00", |
| 520 | top_repo="octo/signal-kit", |
| 521 | summary="Practical agent tooling led the week.", |
| 522 | signal="Teams preferred operational automation over generic hype.", |
| 523 | noise="Exploit-heavy projects still added editorial noise.", |
| 524 | gaps="Reliable momentum data remained missing.", |
| 525 | conclusion="The strongest projects made automation safer to adopt.", |
| 526 | ), |
| 527 | encoding="utf-8", |
| 528 | ) |
| 529 | |
| 530 | generate_rollups.generate_rollups(analyzed_dir, content_root) |
| 531 | yearly_path = content_root / "yearly" / "2026.md" |
| 532 | yearly = yearly_path.read_text(encoding="utf-8") |
| 533 | self.assertIn("summary:", yearly) |
| 534 | # Extract summary value and verify length constraint |
| 535 | for line in yearly.splitlines(): |
| 536 | if line.strip().startswith("summary:"): |
| 537 | summary_value = line.split(":", 1)[1].strip().strip('"') |
| 538 | self.assertLessEqual(len(summary_value), 155) |
| 539 | break |
| 540 | else: |
| 541 | self.fail("summary field not found in yearly frontmatter") |
| 542 | |
| 543 | |
| 544 | class ExtractSummaryTests(unittest.TestCase): |
| 545 | def test_short_sentence_returned_as_is(self) -> None: |
| 546 | result = generate_yearly_narrative._extract_summary("Short sentence.") |
| 547 | self.assertEqual(result, "Short sentence.") |
| 548 | |
| 549 | def test_exclamation_mark_terminates_sentence(self) -> None: |
| 550 | result = generate_yearly_narrative._extract_summary("Wow! More text follows here.") |
| 551 | self.assertEqual(result, "Wow!") |
| 552 | |
| 553 | def test_question_mark_terminates_sentence(self) -> None: |
| 554 | result = generate_yearly_narrative._extract_summary("Why not? The rest is irrelevant.") |
| 555 | self.assertEqual(result, "Why not?") |
| 556 | |
| 557 | def test_truncation_respects_max_length(self) -> None: |
| 558 | long = "A" * 200 + "." |
| 559 | result = generate_yearly_narrative._extract_summary(long, max_length=50) |
| 560 | self.assertLessEqual(len(result), 50) |
| 561 | self.assertTrue(result.endswith("…")) |
| 562 | |
| 563 | def test_truncation_at_word_boundary(self) -> None: |
| 564 | sentence = "This is a moderately long sentence that should be truncated at a word boundary when it exceeds the maximum allowed length for meta descriptions." |
| 565 | result = generate_yearly_narrative._extract_summary(sentence, max_length=60) |
| 566 | self.assertLessEqual(len(result), 60) |
| 567 | self.assertTrue(result.endswith("…")) |
| 568 | self.assertFalse(result[-2].isspace()) |
| 569 | |
| 570 | def test_leading_whitespace_stripped(self) -> None: |
| 571 | result = generate_yearly_narrative._extract_summary(" Leading spaces. More text.") |
| 572 | self.assertEqual(result, "Leading spaces.") |
| 573 | |
| 574 | def test_fallback_when_no_sentence_terminator(self) -> None: |
| 575 | result = generate_yearly_narrative._extract_summary("No punctuation at all") |
| 576 | self.assertEqual(result, "No punctuation at all") |
| 577 | |
| 578 | def test_fallback_truncation_for_long_text_without_terminator(self) -> None: |
| 579 | long = "word " * 50 |
| 580 | result = generate_yearly_narrative._extract_summary(long, max_length=30) |
| 581 | self.assertLessEqual(len(result), 30) |
| 582 | self.assertTrue(result.endswith("…")) |
| 583 | |
| 584 | |
| 585 | if __name__ == "__main__": |
| 586 | unittest.main() |