clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

main
lazyopenspec / internal / spec / parse_test.go
parse_test.go 15.9K
1package spec23import (4 "strings"5 "testing"6)78// md joins lines with "\n" so table cases stay readable.9func md(lines ...string) string { return strings.Join(lines, "\n") + "\n" }1011func TestParseTasks(t *testing.T) {12 tests := []struct {13 name string14 src string15 want TaskStats16 }{17 {"empty", "", TaskStats{}},18 {"no checkboxes", md("## 1. Group", "", "Some prose."), TaskStats{}},19 {"basic", md("## 1. Group", "", "- [ ] 1.1 a", "- [x] 1.2 b"), TaskStats{Done: 1, Total: 2}},20 {"uppercase X is done", md("- [X] 1.1 a", "- [ ] 1.2 b"), TaskStats{Done: 1, Total: 2}},21 {"star bullets count", md("* [x] a", "* [ ] b"), TaskStats{Done: 1, Total: 2}},22 {"plus bullets ignored", md("+ [x] a", "- [ ] b"), TaskStats{Done: 0, Total: 1}},23 {24 "indented sub-checkboxes ignored",25 md("- [ ] 2.2 parent", " - [x] sub done", " - [ ] sub open", "\t- [x] tab sub", "- [x] 2.3 next"),26 TaskStats{Done: 1, Total: 2},27 },28 {29 "checkboxes inside fenced code count (CLI does not mask fences)",30 md("- [x] real", "```markdown", "- [ ] example", "- [x] example done", "```", "~~~", "* [ ] tilde", "~~~"),31 TaskStats{Done: 2, Total: 4},32 },33 {"other marks are not tasks", md("- [~] partial", "- [-] skipped", "- [ ] two spaces", "- [ ] real"), TaskStats{Total: 1}},34 {"no space after bullet", md("-[ ] a", "*[x] b"), TaskStats{}},35 {"several spaces after bullet", md("- [x] a", "*\t[ ] b"), TaskStats{Done: 1, Total: 2}},36 {"no description still counts", md("- [ ]", "- [x]"), TaskStats{Done: 1, Total: 2}},37 {"no space after bracket", md("- [x]1.1 squashed"), TaskStats{Done: 1, Total: 1}},38 {"numbered list ignored", md("1. [x] a", "2. [ ] b"), TaskStats{}},39 {"CRLF line endings", "- [x] a\r\n- [ ] b\r\n- [X] c\r\n", TaskStats{Done: 2, Total: 3}},40 {"no trailing newline", "- [x] a\n- [ ] b", TaskStats{Done: 1, Total: 2}},41 }42 for _, tt := range tests {43 t.Run(tt.name, func(t *testing.T) {44 if got := ParseTasks(tt.src); got != tt.want {45 t.Errorf("ParseTasks() = %+v, want %+v", got, tt.want)46 }47 })48 }49}5051func TestTaskStatsComplete(t *testing.T) {52 tests := []struct {53 in TaskStats54 want bool55 }{56 {TaskStats{}, false},57 {TaskStats{Done: 0, Total: 3}, false},58 {TaskStats{Done: 2, Total: 3}, false},59 {TaskStats{Done: 3, Total: 3}, true},60 }61 for _, tt := range tests {62 if got := tt.in.Complete(); got != tt.want {63 t.Errorf("%+v.Complete() = %v, want %v", tt.in, got, tt.want)64 }65 }66}6768func TestParseDelta(t *testing.T) {69 req := func(name string) []string {70 return []string{"### Requirement: " + name, "The system SHALL " + name + ".", "", "#### Scenario: s", "- **WHEN** a", "- **THEN** b", ""}71 }72 join := func(parts ...[]string) string {73 var all []string74 for _, p := range parts {75 all = append(all, p...)76 }77 return md(all...)78 }79 h := func(s ...string) []string { return s }8081 tests := []struct {82 name string83 src string84 want DeltaStats85 }{86 {"empty", "", DeltaStats{}},87 {"added", join(h("## ADDED Requirements", ""), req("A"), req("B")), DeltaStats{Added: 2}},88 {"modified", join(h("## MODIFIED Requirements", ""), req("A")), DeltaStats{Modified: 1}},89 {90 "removed header form",91 md("## REMOVED Requirements", "", "### Requirement: Remember me", "**Reason**: risk", "**Migration**: none"),92 DeltaStats{Removed: 1},93 },94 {95 "removed alt bullet form",96 md("## REMOVED Requirements", "", "- `### Requirement: Remember me`", "- `### Requirement: Legacy tokens`"),97 DeltaStats{Removed: 2},98 },99 {100 "removed alt bullet form without backticks",101 md("## REMOVED Requirements", "- ### Requirement: Remember me"),102 DeltaStats{Removed: 1},103 },104 {105 "renamed with leading dash",106 md("## RENAMED Requirements", "", "- FROM: `### Requirement: Password login`", "- TO: `### Requirement: Password authentication`"),107 DeltaStats{Renamed: 1},108 },109 {110 "renamed without leading dash",111 md("## RENAMED Requirements", "", "FROM: `### Requirement: Password login`", "TO: `### Requirement: Password authentication`"),112 DeltaStats{Renamed: 1},113 },114 {115 "renamed indented, no backticks",116 md("## RENAMED Requirements", " - FROM: ### Requirement: A", " - TO: ### Requirement: B"),117 DeltaStats{Renamed: 1},118 },119 {120 "two rename pairs",121 md("## RENAMED Requirements", "- FROM: `### Requirement: A`", "- TO: `### Requirement: B`", "", "- FROM: `### Requirement: C`", "- TO: `### Requirement: D`"),122 DeltaStats{Renamed: 2},123 },124 {125 "case-insensitive section headers",126 join(h("## added requirements"), req("A"), h("## Modified Requirements"), req("B"), h("## removed REQUIREMENTS", "### Requirement: C", "## Renamed Requirements", "- FROM: `### Requirement: D`", "- TO: `### Requirement: E`")),127 DeltaStats{Added: 1, Modified: 1, Removed: 1, Renamed: 1},128 },129 {"section header trailing whitespace", join(h("## ADDED Requirements "), req("A")), DeltaStats{Added: 1}},130 {"case-insensitive requirement header", md("## ADDED Requirements", "### requirement: lower", "### REQUIREMENT: upper"), DeltaStats{Added: 2}},131 {"no space after ###", md("## ADDED Requirements", "###Requirement: tight"), DeltaStats{Added: 1}},132 {"H4 requirement is not a requirement", md("## ADDED Requirements", "#### Requirement: too deep"), DeltaStats{}},133 {"bullet form does not count under ADDED", md("## ADDED Requirements", "- `### Requirement: A`"), DeltaStats{}},134 {"requirement header under RENAMED is not a rename", md("## RENAMED Requirements", "### Requirement: A"), DeltaStats{}},135 {136 "requirements before any delta section are ignored",137 join(h("# Delta for auth", ""), req("Preamble"), h("## ADDED Requirements"), req("A")),138 DeltaStats{Added: 1},139 },140 {141 "unrelated ## section ends the delta section",142 join(h("## ADDED Requirements"), req("A"), h("## Notes"), req("Stray")),143 DeltaStats{Added: 1},144 },145 {146 "requirement headers inside fenced code do not count",147 join(h("## ADDED Requirements"), req("A"), h("```markdown", "### Requirement: Example only", "## REMOVED Requirements", "### Requirement: Also example", "```", "~~~", "### Requirement: Tilde example", "~~~")),148 DeltaStats{Added: 1},149 },150 {151 "section header inside fence does not switch section",152 md("## MODIFIED Requirements", "```", "## ADDED Requirements", "```", "### Requirement: A"),153 DeltaStats{Modified: 1},154 },155 {156 "all four operations",157 join(h("## ADDED Requirements"), req("A"), h("## MODIFIED Requirements"), req("B"), req("C"),158 h("## REMOVED Requirements", "### Requirement: D", "- `### Requirement: E`", "## RENAMED Requirements", "- FROM: `### Requirement: F`", "- TO: `### Requirement: G`")),159 DeltaStats{Added: 1, Modified: 2, Removed: 2, Renamed: 1},160 },161 {"CRLF line endings", "## ADDED Requirements\r\n### Requirement: A\r\nThe system SHALL a.\r\n", DeltaStats{Added: 1}},162 }163 for _, tt := range tests {164 t.Run(tt.name, func(t *testing.T) {165 got := ParseDelta(tt.src)166 if got != tt.want {167 t.Errorf("ParseDelta() = %+v, want %+v", got, tt.want)168 }169 if got.Total() != tt.want.Total() {170 t.Errorf("Total() = %d, want %d", got.Total(), tt.want.Total())171 }172 })173 }174}175176// The OpenSpec CLI (requirement-blocks.js parseRenamedPairs and177// change-parser.js parseRenames) only counts a rename when a178// "FROM: `### Requirement: X`" line is followed by a matching179// "TO: `### Requirement: Y`" line, and both regexes are case-sensitive and180// require the "### Requirement:" part. Verified: `openspec show <change>181// --json` reports deltaCount 0 for this input.182func TestParseDeltaRenamedNeedsFromToPair(t *testing.T) {183 src := md("## RENAMED Requirements", "",184 "- FROM: `### Requirement: Password login`",185 "- From: the old wording was ambiguous")186 if got := ParseDelta(src); got.Renamed != 0 {187 t.Errorf("Renamed = %d, want 0 (no FROM/TO pair)", got.Renamed)188 }189}190191// CommonMark (and the CLI's MarkdownParser.buildCodeFenceMask) only closes a192// fence with the same marker character and at least the same length, so a193// ``` block nested inside a ~~~ (or ````) block stays masked.194func TestParseNestedFenceStaysMasked(t *testing.T) {195 example := []string{"~~~markdown", "```", "### Requirement: Fake inside nested fence", "#### Scenario: fake", "```", "~~~"}196 spec := md(append([]string{"# S", "## Purpose", "p", "## Requirements", "### Requirement: Real", "The system SHALL x.", "#### Scenario: s"}, example...)...)197 if got := ParseSpec(spec); got.Requirements != 1 || got.Scenarios != 1 {198 t.Errorf("ParseSpec() = %+v, want 1 requirement and 1 scenario", got)199 }200 delta := md(append([]string{"## ADDED Requirements", "### Requirement: Real"}, example...)...)201 if got := ParseDelta(delta); got.Added != 1 {202 t.Errorf("ParseDelta().Added = %d, want 1", got.Added)203 }204 four := md("## ADDED Requirements", "### Requirement: Real", "````md", "```", "### Requirement: Fake", "```", "````")205 if got := ParseDelta(four); got.Added != 1 {206 t.Errorf("ParseDelta(```` fence).Added = %d, want 1", got.Added)207 }208}209210func TestParseSpec(t *testing.T) {211 tests := []struct {212 name string213 src string214 want SpecMeta215 }{216 {"empty", "", SpecMeta{}},217 {218 "standard layout",219 md("# Data Export Specification", "", "## Purpose", "", "Lets owners export.", "Second line.", "",220 "## Requirements", "", "### Requirement: CSV export", "The system SHALL export CSV.", "",221 "#### Scenario: Export", "- **WHEN** x", "- **THEN** y", "", "#### Scenario: Escape", "- **WHEN** x", "",222 "### Requirement: JSON export", "The system SHALL export JSON.", "", "#### Scenario: JSON", "- **WHEN** x"),223 SpecMeta{Title: "Data Export Specification", Purpose: "Lets owners export.", Requirements: 2, Scenarios: 3},224 },225 {226 "archive skeleton without blank lines",227 md("# notifications Specification", "", "## Purpose", "TBD - created by archiving change x. Update Purpose after archive.",228 "## Requirements", "### Requirement: A", "The system SHALL a.", "", "#### Scenario: s", "- **WHEN** x"),229 SpecMeta{Title: "notifications Specification", Purpose: "TBD - created by archiving change x. Update Purpose after archive.", Requirements: 1, Scenarios: 1},230 },231 {232 "### Purpose works like ## Purpose",233 md("# T", "## Overview", "### Purpose", "Nested purpose.", "## Requirements", "### Requirement: A"),234 SpecMeta{Title: "T", Purpose: "Nested purpose.", Requirements: 1},235 },236 {237 "purpose header is case-insensitive",238 md("# T", "## PURPOSE", "", " Indented purpose text. "),239 SpecMeta{Title: "T", Purpose: "Indented purpose text."},240 },241 {"no purpose section", md("# T", "## Requirements", "### Requirement: A"), SpecMeta{Title: "T", Requirements: 1}},242 {243 "purpose not taken from other sections",244 md("# T", "## Background", "Not the purpose.", "## Purpose", "", "Real purpose."),245 SpecMeta{Title: "T", Purpose: "Real purpose."},246 },247 {"empty purpose section", md("# T", "## Purpose", "", "## Requirements", "Body text."), SpecMeta{Title: "T"}},248 {"only first H1 is the title", md("# First", "# Second"), SpecMeta{Title: "First"}},249 {"no title", md("## Purpose", "p"), SpecMeta{Purpose: "p"}},250 {251 "requirement header is case-insensitive and needs ###",252 md("## Requirements", "### requirement: lower", "###Requirement: tight", "#### Requirement: too deep", "## Requirement: too shallow"),253 SpecMeta{Requirements: 2, Scenarios: 1},254 },255 {256 "H5 is not a scenario",257 md("## Requirements", "### Requirement: A", "#### Scenario: s", "##### Detail"),258 SpecMeta{Requirements: 1, Scenarios: 1},259 },260 {261 "requirement and scenario headers inside fenced code do not count",262 md("# T", "## Purpose", "p", "## Requirements", "### Requirement: Real", "The system SHALL x.", "#### Scenario: s",263 "```markdown", "### Requirement: Example", "#### Scenario: Example", "```",264 "~~~", "### Requirement: Tilde example", "~~~"),265 SpecMeta{Title: "T", Purpose: "p", Requirements: 1, Scenarios: 1},266 },267 {268 "heading inside fence does not become the title or purpose",269 md("```", "# Not a title", "## Purpose", "```", "# Title", "## Purpose", "Real."),270 SpecMeta{Title: "Title", Purpose: "Real."},271 },272 {273 "CRLF line endings",274 "# T\r\n## Purpose\r\nP.\r\n## Requirements\r\n### Requirement: A\r\n#### Scenario: s\r\n",275 SpecMeta{Title: "T", Purpose: "P.", Requirements: 1, Scenarios: 1},276 },277 }278 for _, tt := range tests {279 t.Run(tt.name, func(t *testing.T) {280 if got := ParseSpec(tt.src); got != tt.want {281 t.Errorf("ParseSpec() = %+v, want %+v", got, tt.want)282 }283 })284 }285}286287// The CLI counts requirements as the children of the "Requirements" section288// only (markdown-parser.js parseRequirements); a requirement header under a289// later "## Notes" section is a validation ERROR and is not counted.290// Verified: `openspec spec list --json` reports requirementCount 1 here.291func TestParseSpecIgnoresRequirementsOutsideRequirementsSection(t *testing.T) {292 src := md("# T", "## Purpose", "p", "## Requirements", "### Requirement: Real", "The system SHALL x.",293 "## Notes", "### Requirement: Stray", "The system SHALL not count this.")294 if got := ParseSpec(src); got.Requirements != 1 {295 t.Errorf("Requirements = %d, want 1", got.Requirements)296 }297}298299func TestParseSummary(t *testing.T) {300 tests := []struct {301 name string302 src string303 want string304 }{305 {"empty", "", ""},306 {"only headings", md("## Why", "## What Changes"), ""},307 {"line under Why", md("## Why", "", "Because sessions last too long.", "", "## What Changes", "- Shorten"), "Because sessions last too long."},308 {309 "prefers Why over earlier prose",310 md("# Change: Full-text task search", "", "Intro paragraph before why.", "", "## Why", "", "The real reason."),311 "The real reason.",312 },313 {"Why later in the document", md("## What Changes", "- Add search", "", "## Why", "Reason."), "Reason."},314 {"Why header is case-insensitive and any level", md("### WHY", "Reason."), "Reason."},315 {"falls back to first prose line", md("## What Changes", "", "- Add search endpoint", "- Rank results"), "Add search endpoint"},316 {"empty Why falls back", md("## Why", "", "## What Changes", "- Add search"), "Add search"},317 {"only first line of Why paragraph", md("## Why", "First line.", "Second line."), "First line."},318 {"skips HTML comments", md("## Why", "<!-- Explain the motivation -->", "Reason."), "Reason."},319 {"strips bullet and quote markers", md("## Why", "> - quoted bullet"), "quoted bullet"},320 {"strips star bullet", md("## Why", "* starred"), "starred"},321 {"trims indentation", md("## Why", " indented reason "), "indented reason"},322 {"ignores fenced code", md("## Why", "```", "code line", "```", "Prose."), "Prose."},323 {"CRLF line endings", "## Why\r\n\r\nReason.\r\n", "Reason."},324 }325 for _, tt := range tests {326 t.Run(tt.name, func(t *testing.T) {327 if got := ParseSummary(tt.src); got != tt.want {328 t.Errorf("ParseSummary() = %q, want %q", got, tt.want)329 }330 })331 }332}333334func TestParseSummaryKeepsLeadingBold(t *testing.T) {335 src := md("## Why", "", "**Security:** sessions last too long.")336 if got, want := ParseSummary(src), "**Security:** sessions last too long."; got != want {337 t.Errorf("ParseSummary() = %q, want %q", got, want)338 }339}340341func TestParseSchema(t *testing.T) {342 tests := []struct {343 name string344 src string345 want string346 }{347 {"empty", "", ""},348 {"standard", md("schema: spec-driven", "created: 2026-10-13"), "spec-driven"},349 {"not first line", md("created: 2026-10-13", "schema: workspace-planning"), "workspace-planning"},350 {"double quoted", md(`schema: "spec-driven"`), "spec-driven"},351 {"single quoted", md(`schema: 'spec-driven'`), "spec-driven"},352 {"no space after colon", md("schema:custom"), "custom"},353 {"missing", md("created: 2026-10-13"), ""},354 {"commented out", md("# schema: spec-driven"), ""},355 {"different key with same prefix", md("schemas: x"), ""},356 {"first wins", md("schema: a", "schema: b"), "a"},357 {"CRLF", "schema: spec-driven\r\ncreated: 2026-10-13\r\n", "spec-driven"},358 }359 for _, tt := range tests {360 t.Run(tt.name, func(t *testing.T) {361 if got := ParseSchema(tt.src); got != tt.want {362 t.Errorf("ParseSchema() = %q, want %q", got, tt.want)363 }364 })365 }366}367368func TestParseSchemaStripsInlineComment(t *testing.T) {369 if got := ParseSchema("schema: spec-driven # default\n"); got != "spec-driven" {370 t.Errorf("ParseSchema() = %q, want %q", got, "spec-driven")371 }372}