@pmelab/gtd 24.0.0 → 24.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,2160 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, unansweredQuestions, viewOf } from "./index.js"
|
|
3
|
+
import { FREE_TEXT_PLACEHOLDER, isAnswered, parseOpenQuestions } from "./qa.js"
|
|
4
|
+
import { getParseCount } from "./index.js"
|
|
5
|
+
|
|
6
|
+
const qa = steeringFormatFor("qa")!
|
|
7
|
+
|
|
8
|
+
const doc = (lines: readonly string[]): string => lines.join("\n")
|
|
9
|
+
|
|
10
|
+
describe("qa — structure (validate)", () => {
|
|
11
|
+
it("an empty document has no questions and no findings", () => {
|
|
12
|
+
expect(qa.validate("")).toEqual([])
|
|
13
|
+
expect(unansweredQuestions(qa, "")).toEqual([])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("an Open Questions section present but empty is valid", () => {
|
|
17
|
+
expect(qa.validate(doc(["# Plan", "", "## Open Questions", ""]))).toEqual([])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("a question with a free-form body (no options) validates clean", () => {
|
|
21
|
+
const content = doc([
|
|
22
|
+
"# Plan",
|
|
23
|
+
"",
|
|
24
|
+
"## Open Questions",
|
|
25
|
+
"",
|
|
26
|
+
"### Which operations?",
|
|
27
|
+
"",
|
|
28
|
+
"add and subtract.",
|
|
29
|
+
"",
|
|
30
|
+
])
|
|
31
|
+
expect(qa.validate(content)).toEqual([])
|
|
32
|
+
expect(unansweredQuestions(qa, content)).toEqual([
|
|
33
|
+
expect.objectContaining({ question: "Which operations?" }),
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("stops a questions section at the next H2 heading — a later '### ' under a different section is not a question", () => {
|
|
38
|
+
const content = doc([
|
|
39
|
+
"## Open Questions",
|
|
40
|
+
"",
|
|
41
|
+
"### Which operations?",
|
|
42
|
+
"",
|
|
43
|
+
"- [ ] A",
|
|
44
|
+
"- [ ] B",
|
|
45
|
+
"",
|
|
46
|
+
"## Implementation Notes",
|
|
47
|
+
"",
|
|
48
|
+
"### Not a question",
|
|
49
|
+
"",
|
|
50
|
+
"This should be ignored entirely.",
|
|
51
|
+
"",
|
|
52
|
+
])
|
|
53
|
+
expect(unansweredQuestions(qa, content)).toHaveLength(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("keeps a well-formed question while also collecting a bare-heading finding elsewhere in the same doc", () => {
|
|
57
|
+
const content = doc([
|
|
58
|
+
"## Open Questions",
|
|
59
|
+
"",
|
|
60
|
+
"###",
|
|
61
|
+
"",
|
|
62
|
+
"### Real question?",
|
|
63
|
+
"",
|
|
64
|
+
"- [ ] A",
|
|
65
|
+
"- [ ] B",
|
|
66
|
+
"",
|
|
67
|
+
])
|
|
68
|
+
const findings = qa.validate(content)
|
|
69
|
+
expect(findings).toHaveLength(1)
|
|
70
|
+
expect(unansweredQuestions(qa, content)).toEqual([
|
|
71
|
+
expect.objectContaining({ question: "Real question?" }),
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe("checkbox options", () => {
|
|
76
|
+
const q = (lines: readonly string[]): string =>
|
|
77
|
+
["## Open Questions", "", "### Which API?", ...lines, ""].join("\n")
|
|
78
|
+
|
|
79
|
+
it("parses two agent options plus a trailing free-text slot, none ticked = unanswered", () => {
|
|
80
|
+
const result = parseOpenQuestions(
|
|
81
|
+
q(["", "- [ ] REST", "- [ ] GraphQL", `- [ ] ${FREE_TEXT_PLACEHOLDER}`]),
|
|
82
|
+
)
|
|
83
|
+
const [question] = result.questions
|
|
84
|
+
expect(question!.options).toEqual([
|
|
85
|
+
{ checked: false, text: "REST", freeText: false, sourceLine: 4, endLine: 4, body: [] },
|
|
86
|
+
{ checked: false, text: "GraphQL", freeText: false, sourceLine: 5, endLine: 5, body: [] },
|
|
87
|
+
{ checked: false, text: "", freeText: true, sourceLine: 6, endLine: 6, body: [] },
|
|
88
|
+
])
|
|
89
|
+
expect(question!.answered).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("is answered when exactly one agent option is ticked", () => {
|
|
93
|
+
const result = parseOpenQuestions(
|
|
94
|
+
q(["", "- [ ] REST", "- [x] GraphQL", `- [ ] ${FREE_TEXT_PLACEHOLDER}`]),
|
|
95
|
+
)
|
|
96
|
+
expect(result.questions[0]!.answered).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("is unanswered when two options are ticked (ambiguous)", () => {
|
|
100
|
+
const result = parseOpenQuestions(
|
|
101
|
+
q(["", "- [x] REST", "- [x] GraphQL", `- [ ] ${FREE_TEXT_PLACEHOLDER}`]),
|
|
102
|
+
)
|
|
103
|
+
expect(result.questions[0]!.answered).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("is answered when the free-text slot is ticked WITH text, capturing that text", () => {
|
|
107
|
+
const result = parseOpenQuestions(q(["", "- [ ] REST", "- [ ] GraphQL", "- [x] use tRPC"]))
|
|
108
|
+
const question = result.questions[0]!
|
|
109
|
+
expect(question.answered).toBe(true)
|
|
110
|
+
const chosen = question.options.find((o) => o.checked)!
|
|
111
|
+
expect(chosen).toEqual({
|
|
112
|
+
checked: true,
|
|
113
|
+
text: "use tRPC",
|
|
114
|
+
freeText: true,
|
|
115
|
+
sourceLine: 6,
|
|
116
|
+
endLine: 6,
|
|
117
|
+
body: [],
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("is unanswered when the free-text slot is ticked but still the placeholder", () => {
|
|
122
|
+
const result = parseOpenQuestions(
|
|
123
|
+
q(["", "- [ ] REST", "- [ ] GraphQL", `- [x] ${FREE_TEXT_PLACEHOLDER}`]),
|
|
124
|
+
)
|
|
125
|
+
const question = result.questions[0]!
|
|
126
|
+
expect(question.answered).toBe(false)
|
|
127
|
+
expect(question.options[2]).toEqual({
|
|
128
|
+
checked: true,
|
|
129
|
+
text: "",
|
|
130
|
+
freeText: true,
|
|
131
|
+
sourceLine: 6,
|
|
132
|
+
endLine: 6,
|
|
133
|
+
body: [],
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("normalizes the placeholder case-insensitively, regardless of the constant's OWN casing — never assumes FREE_TEXT_PLACEHOLDER itself is already lowercase", () => {
|
|
138
|
+
const result = parseOpenQuestions(
|
|
139
|
+
q(["", "- [ ] REST", "- [ ] GraphQL", `- [x] ${FREE_TEXT_PLACEHOLDER.toUpperCase()}`]),
|
|
140
|
+
)
|
|
141
|
+
const question = result.questions[0]!
|
|
142
|
+
expect(question.answered).toBe(false)
|
|
143
|
+
expect(question.options[2]!.text).toBe("")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("accepts `* [X]` bullet/upper-case tick syntax", () => {
|
|
147
|
+
const result = parseOpenQuestions(q(["", "* [ ] REST", "* [X] GraphQL"]))
|
|
148
|
+
expect(result.questions[0]!.answered).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("only normalizes the placeholder on the LAST option", () => {
|
|
152
|
+
// A non-last option literally equal to the placeholder is NOT the free-text
|
|
153
|
+
// slot, so it is not normalized to "".
|
|
154
|
+
const result = parseOpenQuestions(
|
|
155
|
+
q(["", `- [ ] ${FREE_TEXT_PLACEHOLDER}`, "- [ ] real free text"]),
|
|
156
|
+
)
|
|
157
|
+
const [first, last] = result.questions[0]!.options
|
|
158
|
+
expect(first).toEqual({
|
|
159
|
+
checked: false,
|
|
160
|
+
text: FREE_TEXT_PLACEHOLDER,
|
|
161
|
+
freeText: false,
|
|
162
|
+
sourceLine: 4,
|
|
163
|
+
endLine: 4,
|
|
164
|
+
body: [],
|
|
165
|
+
})
|
|
166
|
+
expect(last!.freeText).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("carries no options for an answered-section question even if it has checkbox-looking lines", () => {
|
|
170
|
+
const content = ["## Answered Questions", "", "### Which API?", "", "Use tRPC.", ""].join(
|
|
171
|
+
"\n",
|
|
172
|
+
)
|
|
173
|
+
const question = parseOpenQuestions(content).questions[0]!
|
|
174
|
+
expect(question.options).toEqual([])
|
|
175
|
+
expect(question.answered).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe("option line span (endLine)", () => {
|
|
180
|
+
const q = (lines: readonly string[]): string =>
|
|
181
|
+
["## Open Questions", "", "### Which API?", ...lines, ""].join("\n")
|
|
182
|
+
|
|
183
|
+
it("a single-line option's endLine equals its own sourceLine", () => {
|
|
184
|
+
const result = parseOpenQuestions(q(["", "- [ ] REST", "- [ ] GraphQL"]))
|
|
185
|
+
const [rest, graphql] = result.questions[0]!.options
|
|
186
|
+
expect(rest).toMatchObject({ sourceLine: 4, endLine: 4 })
|
|
187
|
+
expect(graphql).toMatchObject({ sourceLine: 5, endLine: 5 })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("an indented wrap over two continuation lines extends endLine to the last one", () => {
|
|
191
|
+
const result = parseOpenQuestions(
|
|
192
|
+
q(["", "- [ ] REST, specifically", " a JSON:API-flavored", " REST endpoint set"]),
|
|
193
|
+
)
|
|
194
|
+
const [option] = result.questions[0]!.options
|
|
195
|
+
expect(option).toMatchObject({ sourceLine: 4, endLine: 6 })
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("an unindented (lazy) wrap gets the same span as an indented one", () => {
|
|
199
|
+
const result = parseOpenQuestions(
|
|
200
|
+
q(["", "- [ ] REST, specifically", "a JSON:API-flavored", "REST endpoint set"]),
|
|
201
|
+
)
|
|
202
|
+
const [option] = result.questions[0]!.options
|
|
203
|
+
expect(option).toMatchObject({ sourceLine: 4, endLine: 6 })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("the next checkbox line ends the previous option's span", () => {
|
|
207
|
+
const result = parseOpenQuestions(
|
|
208
|
+
q(["", "- [ ] REST, specifically", "a wrapped line", "- [ ] GraphQL"]),
|
|
209
|
+
)
|
|
210
|
+
const [rest, graphql] = result.questions[0]!.options
|
|
211
|
+
expect(rest).toMatchObject({ sourceLine: 4, endLine: 5 })
|
|
212
|
+
expect(graphql).toMatchObject({ sourceLine: 6, endLine: 6 })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("a blank line ends the span, so trailing prose after it stays out of the last option's span", () => {
|
|
216
|
+
const result = parseOpenQuestions(
|
|
217
|
+
q(["", "- [ ] _your answer_", "a wrapped answer", "", "some trailing prose"]),
|
|
218
|
+
)
|
|
219
|
+
const [option] = result.questions[0]!.options
|
|
220
|
+
expect(option).toMatchObject({ sourceLine: 4, endLine: 5 })
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("the last body line ends the span at the block end, with no overrun past the next heading", () => {
|
|
224
|
+
const content = [
|
|
225
|
+
"## Open Questions",
|
|
226
|
+
"",
|
|
227
|
+
"### Which API?",
|
|
228
|
+
"",
|
|
229
|
+
"- [ ] REST",
|
|
230
|
+
"a wrapped line",
|
|
231
|
+
"another wrapped line",
|
|
232
|
+
].join("\n")
|
|
233
|
+
const result = parseOpenQuestions(content)
|
|
234
|
+
const [option] = result.questions[0]!.options
|
|
235
|
+
expect(option).toMatchObject({ sourceLine: 4, endLine: 6 })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("answered/text include a wrapped ticked free-text option's whole paragraph span, not the checkbox line alone", () => {
|
|
239
|
+
const result = parseOpenQuestions(
|
|
240
|
+
q(["", "- [ ] REST", "- [x] use tRPC", "a wrapped continuation of the answer"]),
|
|
241
|
+
)
|
|
242
|
+
const question = result.questions[0]!
|
|
243
|
+
expect(question.answered).toBe(true)
|
|
244
|
+
const chosen = question.options.find((o) => o.checked)!
|
|
245
|
+
expect(chosen).toMatchObject({
|
|
246
|
+
text: "use tRPC a wrapped continuation of the answer",
|
|
247
|
+
sourceLine: 5,
|
|
248
|
+
endLine: 6,
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("a bare marker with no text on its OWN line, whose text starts on the next (indented) line, reads that continuation's text — where it read '' and refused before", () => {
|
|
253
|
+
const result = parseOpenQuestions(q(["", "- [ ]", " text here", "- [ ] _your answer_"]))
|
|
254
|
+
const [option] = result.questions[0]!.options
|
|
255
|
+
expect(option).toMatchObject({ text: "text here", sourceLine: 4, endLine: 5 })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("a ticked free-text option answered only on its continuation line reads that text and counts as ANSWERED — the paragraph span covers the whole wrap, not just the marker's own line", () => {
|
|
259
|
+
const result = parseOpenQuestions(q(["", "- [ ] REST", "- [x]", " my own answer"]))
|
|
260
|
+
const question = result.questions[0]!
|
|
261
|
+
const chosen = question.options.find((o) => o.checked)!
|
|
262
|
+
expect(chosen).toMatchObject({ text: "my own answer", freeText: true })
|
|
263
|
+
expect(question.answered).toBe(true)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("a '####' heading inside the section is not treated as a question block at all", () => {
|
|
268
|
+
const content = doc([
|
|
269
|
+
"## Open Questions",
|
|
270
|
+
"",
|
|
271
|
+
"#### Not a question depth",
|
|
272
|
+
"",
|
|
273
|
+
"### Real question?",
|
|
274
|
+
"",
|
|
275
|
+
"- [ ] A",
|
|
276
|
+
"- [ ] B",
|
|
277
|
+
"",
|
|
278
|
+
])
|
|
279
|
+
expect(unansweredQuestions(qa, content)).toEqual([
|
|
280
|
+
expect.objectContaining({ question: "Real question?" }),
|
|
281
|
+
])
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe("qa — answer completeness (unansweredQuestions)", () => {
|
|
286
|
+
const q = (lines: readonly string[]): string =>
|
|
287
|
+
doc(["## Open Questions", "", "### Which API?", ...lines, ""])
|
|
288
|
+
|
|
289
|
+
it("two options, none ticked, is unanswered", () => {
|
|
290
|
+
const content = q(["", "- [ ] REST", "- [ ] GraphQL", "- [ ] _your answer_"])
|
|
291
|
+
expect(unansweredQuestions(qa, content)).toHaveLength(1)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("exactly one ticked option is answered", () => {
|
|
295
|
+
const content = q(["", "- [ ] REST", "- [x] GraphQL", "- [ ] _your answer_"])
|
|
296
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it("two ticked options (ambiguous) is unanswered", () => {
|
|
300
|
+
const content = q(["", "- [x] REST", "- [x] GraphQL", "- [ ] _your answer_"])
|
|
301
|
+
expect(unansweredQuestions(qa, content)).toHaveLength(1)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it("the free-text slot ticked WITH real text is answered", () => {
|
|
305
|
+
const content = q(["", "- [ ] REST", "- [ ] GraphQL", "- [x] use tRPC"])
|
|
306
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it("the free-text slot ticked but left as the unfilled placeholder is unanswered", () => {
|
|
310
|
+
const content = q(["", "- [ ] REST", "- [ ] GraphQL", "- [x] _your answer_"])
|
|
311
|
+
expect(unansweredQuestions(qa, content)).toHaveLength(1)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("accepts '* [X]' bullet/upper-case tick syntax", () => {
|
|
315
|
+
const content = q(["", "* [ ] REST", "* [X] GraphQL"])
|
|
316
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it("only the LAST option is ever the free-text slot — a non-last option literally equal to the placeholder is not normalized", () => {
|
|
320
|
+
const content = q(["", "- [ ] _your answer_", "- [x] real free text"])
|
|
321
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("an answered-section question carries no options even with checkbox-looking lines, and is never 'unanswered'", () => {
|
|
325
|
+
const content = doc(["## Answered Questions", "", "### Which API?", "", "Use tRPC.", ""])
|
|
326
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it("a bare marker with no text on its own line, text on an indented continuation, stays unanswered when unticked", () => {
|
|
330
|
+
const content = q(["", "- [ ]", " text here", "- [ ] _your answer_"])
|
|
331
|
+
expect(unansweredQuestions(qa, content)).toHaveLength(1)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it("a ticked free-text option answered only on its continuation line reads that text and counts as answered — the paragraph span covers the whole wrap", () => {
|
|
335
|
+
const content = q(["", "- [ ] REST", "- [x]", " my own answer"])
|
|
336
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("returns [] once every open question is answered", () => {
|
|
340
|
+
const content = doc([
|
|
341
|
+
"## Open Questions",
|
|
342
|
+
"",
|
|
343
|
+
"### A?",
|
|
344
|
+
"",
|
|
345
|
+
"- [x] Yes",
|
|
346
|
+
"- [ ] No",
|
|
347
|
+
"",
|
|
348
|
+
"### B?",
|
|
349
|
+
"",
|
|
350
|
+
"- [ ] Yes",
|
|
351
|
+
"- [x] No",
|
|
352
|
+
"",
|
|
353
|
+
])
|
|
354
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it("lists every unanswered open question with its heading line", () => {
|
|
358
|
+
const content = doc([
|
|
359
|
+
"Plan.",
|
|
360
|
+
"",
|
|
361
|
+
"## Open Questions",
|
|
362
|
+
"",
|
|
363
|
+
"### Which backend?",
|
|
364
|
+
"",
|
|
365
|
+
"- [ ] SQLite",
|
|
366
|
+
"- [ ] Postgres",
|
|
367
|
+
"",
|
|
368
|
+
])
|
|
369
|
+
expect(unansweredQuestions(qa, content)).toEqual([
|
|
370
|
+
expect.objectContaining({ question: "Which backend?", headingLine: 4 }),
|
|
371
|
+
])
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
describe("qa — section ordering", () => {
|
|
376
|
+
it("reports a finding when a '##' section precedes '## Open Questions'", () => {
|
|
377
|
+
const content = doc([
|
|
378
|
+
"## Implementation Notes",
|
|
379
|
+
"",
|
|
380
|
+
"some notes.",
|
|
381
|
+
"",
|
|
382
|
+
"## Open Questions",
|
|
383
|
+
"",
|
|
384
|
+
"### Which operations?",
|
|
385
|
+
"",
|
|
386
|
+
"add and subtract.",
|
|
387
|
+
"",
|
|
388
|
+
])
|
|
389
|
+
expect(qa.validate(content).map((f) => f.message)).toEqual([
|
|
390
|
+
"A '##' section appears before '## Open Questions', which must come first",
|
|
391
|
+
])
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it("reports a finding when a '##' section follows '## Answered Questions'", () => {
|
|
395
|
+
const content = doc([
|
|
396
|
+
"## Answered Questions",
|
|
397
|
+
"",
|
|
398
|
+
"### Already resolved?",
|
|
399
|
+
"",
|
|
400
|
+
"Yes.",
|
|
401
|
+
"",
|
|
402
|
+
"## Implementation Notes",
|
|
403
|
+
"",
|
|
404
|
+
"some notes.",
|
|
405
|
+
"",
|
|
406
|
+
])
|
|
407
|
+
expect(qa.validate(content).map((f) => f.message)).toEqual([
|
|
408
|
+
"A '##' section appears after '## Answered Questions', which must come last",
|
|
409
|
+
])
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it("reports no ordering finding with only '## Open Questions' present", () => {
|
|
413
|
+
expect(
|
|
414
|
+
qa.validate(doc(["## Open Questions", "", "### Which operations?", "", "add.", ""])),
|
|
415
|
+
).toEqual([])
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it("reports at most one 'before' finding even with multiple sections preceding Open Questions", () => {
|
|
419
|
+
const content = doc([
|
|
420
|
+
"## Implementation Notes",
|
|
421
|
+
"",
|
|
422
|
+
"some notes.",
|
|
423
|
+
"",
|
|
424
|
+
"## Constraints",
|
|
425
|
+
"",
|
|
426
|
+
"some constraints.",
|
|
427
|
+
"",
|
|
428
|
+
"## Open Questions",
|
|
429
|
+
"",
|
|
430
|
+
"### Which operations?",
|
|
431
|
+
"",
|
|
432
|
+
"add and subtract.",
|
|
433
|
+
"",
|
|
434
|
+
])
|
|
435
|
+
expect(qa.validate(content).map((f) => f.message)).toEqual([
|
|
436
|
+
"A '##' section appears before '## Open Questions', which must come first",
|
|
437
|
+
])
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it("reports both findings when '## Answered Questions' comes before '## Open Questions'", () => {
|
|
441
|
+
const content = doc([
|
|
442
|
+
"## Answered Questions",
|
|
443
|
+
"",
|
|
444
|
+
"### Already resolved?",
|
|
445
|
+
"",
|
|
446
|
+
"Yes.",
|
|
447
|
+
"",
|
|
448
|
+
"## Open Questions",
|
|
449
|
+
"",
|
|
450
|
+
"### Which operations?",
|
|
451
|
+
"",
|
|
452
|
+
"add and subtract.",
|
|
453
|
+
"",
|
|
454
|
+
])
|
|
455
|
+
expect(qa.validate(content).map((f) => f.message)).toEqual([
|
|
456
|
+
"A '##' section appears before '## Open Questions', which must come first",
|
|
457
|
+
"A '##' section appears after '## Answered Questions', which must come last",
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it("reports no ordering finding with only '## Answered Questions' present", () => {
|
|
462
|
+
const content = doc(["## Answered Questions", "", "### Already resolved?", "", "Yes.", ""])
|
|
463
|
+
expect(qa.validate(content)).toEqual([])
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it("reports no ordering finding when neither section is present", () => {
|
|
467
|
+
expect(qa.validate(doc(["## Implementation Notes", "", "some notes.", ""]))).toEqual([])
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it("reports no finding for lead prose and a level-1 title above '## Open Questions'", () => {
|
|
471
|
+
const content = doc([
|
|
472
|
+
"# Plan",
|
|
473
|
+
"",
|
|
474
|
+
"Some lead prose describing the plan.",
|
|
475
|
+
"",
|
|
476
|
+
"## Open Questions",
|
|
477
|
+
"",
|
|
478
|
+
"### Which operations?",
|
|
479
|
+
"",
|
|
480
|
+
"add and subtract.",
|
|
481
|
+
"",
|
|
482
|
+
])
|
|
483
|
+
expect(qa.validate(content)).toEqual([])
|
|
484
|
+
})
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
describe("qa — outline (option span, endLine, footnotes, ordering)", () => {
|
|
488
|
+
it("marks a prose (option-less) open question as unanswered, and answered-section questions as answered", () => {
|
|
489
|
+
const content = doc([
|
|
490
|
+
"## Open Questions",
|
|
491
|
+
"",
|
|
492
|
+
"### Free-form?",
|
|
493
|
+
"",
|
|
494
|
+
"some prose.",
|
|
495
|
+
"",
|
|
496
|
+
"## Answered Questions",
|
|
497
|
+
"",
|
|
498
|
+
"### Resolved?",
|
|
499
|
+
"",
|
|
500
|
+
"yes.",
|
|
501
|
+
"",
|
|
502
|
+
])
|
|
503
|
+
const outline = viewOf(qa, content).outline
|
|
504
|
+
expect(outline[0]!.name).toContain("[unanswered]")
|
|
505
|
+
expect(outline[1]!.name).toContain("[answered]")
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it("marks an open question with exactly one ticked option as answered, listing options as children", () => {
|
|
509
|
+
const content = doc([
|
|
510
|
+
"## Open Questions",
|
|
511
|
+
"",
|
|
512
|
+
"### Which API?",
|
|
513
|
+
"",
|
|
514
|
+
"- [x] REST",
|
|
515
|
+
"- [ ] GraphQL",
|
|
516
|
+
"",
|
|
517
|
+
])
|
|
518
|
+
const outline = viewOf(qa, content).outline
|
|
519
|
+
expect(outline[0]!.name).toContain("[answered]")
|
|
520
|
+
expect(outline[0]!.children).toHaveLength(2)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it("returns no nodes when there is no Open Questions section", () => {
|
|
524
|
+
expect(viewOf(qa, "# Plan\n\nBuild a thing.\n").outline).toEqual([])
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it("omits 'children' entirely for a question with no options", () => {
|
|
528
|
+
const content = doc(["## Open Questions", "", "### Free-form?", "", "prose.", ""])
|
|
529
|
+
expect(viewOf(qa, content).outline[0]).not.toHaveProperty("children")
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it("ends the last question's range at its own last block, not the trailing blank run", () => {
|
|
533
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [ ] A", "- [ ] B", "", "", ""])
|
|
534
|
+
const node = viewOf(qa, content).outline[0]!
|
|
535
|
+
expect(node.range.end.line).toBe(5)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it("ends an interior question's range at its own last block too, not at the next heading minus one", () => {
|
|
539
|
+
const content = doc([
|
|
540
|
+
"## Open Questions",
|
|
541
|
+
"",
|
|
542
|
+
"### Q1?",
|
|
543
|
+
"",
|
|
544
|
+
"- [ ] A",
|
|
545
|
+
"- [ ] B",
|
|
546
|
+
"",
|
|
547
|
+
"",
|
|
548
|
+
"### Q2?",
|
|
549
|
+
"",
|
|
550
|
+
"- [ ] C",
|
|
551
|
+
"- [ ] D",
|
|
552
|
+
"",
|
|
553
|
+
])
|
|
554
|
+
const node = viewOf(qa, content).outline[0]!
|
|
555
|
+
expect(node.range.end.line).toBe(5)
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it("clamps the last question's range.end.line to the true last line when the fixture has no trailing newline", () => {
|
|
559
|
+
const content = "## Open Questions\n\n### Q1?\n\n- [ ] A"
|
|
560
|
+
const node = viewOf(qa, content).outline[0]!
|
|
561
|
+
expect(node.range.end.line).toBe(4)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it("places a footnote leaf under the option whose span holds its marker", () => {
|
|
565
|
+
const content = doc([
|
|
566
|
+
"## Open Questions",
|
|
567
|
+
"",
|
|
568
|
+
"### Q1?",
|
|
569
|
+
"",
|
|
570
|
+
"- [ ] A[^fn1]",
|
|
571
|
+
"- [ ] B",
|
|
572
|
+
"",
|
|
573
|
+
"[^fn1]:",
|
|
574
|
+
" Detail about option A that is longer than eighty characters for wrapping.",
|
|
575
|
+
"",
|
|
576
|
+
])
|
|
577
|
+
const option = viewOf(qa, content).outline[0]!.children![0]!
|
|
578
|
+
expect(option.children).toHaveLength(1)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it("places a footnote leaf under the question when its marker is in question-body prose", () => {
|
|
582
|
+
const content = doc([
|
|
583
|
+
"## Open Questions",
|
|
584
|
+
"",
|
|
585
|
+
"### Q1?",
|
|
586
|
+
"",
|
|
587
|
+
"prose with a marker[^fn1].",
|
|
588
|
+
"",
|
|
589
|
+
"- [ ] A",
|
|
590
|
+
"- [ ] B",
|
|
591
|
+
"",
|
|
592
|
+
"[^fn1]:",
|
|
593
|
+
" Detail that is longer than eighty characters so it wraps across lines nicely.",
|
|
594
|
+
"",
|
|
595
|
+
])
|
|
596
|
+
const question = viewOf(qa, content).outline[0]!
|
|
597
|
+
expect(question.children!.some((c) => c.name.includes("fn1") === false)).toBe(true)
|
|
598
|
+
// the option children (leaf, no footnote) plus one footnote child on the question itself
|
|
599
|
+
expect(question.children).toHaveLength(3)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it("does not set leaf:true on an option node that carries footnote children", () => {
|
|
603
|
+
const content = doc([
|
|
604
|
+
"## Open Questions",
|
|
605
|
+
"",
|
|
606
|
+
"### Q1?",
|
|
607
|
+
"",
|
|
608
|
+
"- [ ] A[^fn1]",
|
|
609
|
+
"- [ ] B",
|
|
610
|
+
"",
|
|
611
|
+
"[^fn1]:",
|
|
612
|
+
" Detail about option A that is longer than eighty characters for wrapping.",
|
|
613
|
+
"",
|
|
614
|
+
])
|
|
615
|
+
const option = viewOf(qa, content).outline[0]!.children![0]!
|
|
616
|
+
expect(option).not.toHaveProperty("leaf")
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it("still sets leaf:true on an option node with no footnote children", () => {
|
|
620
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [ ] A", "- [ ] B", ""])
|
|
621
|
+
const option = viewOf(qa, content).outline[0]!.children![0]!
|
|
622
|
+
expect(option).toMatchObject({ leaf: true })
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it("a '- [ ]'-shaped line quoted inside a footnote definition's own body is not a dropped option", () => {
|
|
626
|
+
const content = doc([
|
|
627
|
+
"## Open Questions",
|
|
628
|
+
"",
|
|
629
|
+
"### Q1?",
|
|
630
|
+
"",
|
|
631
|
+
"- [ ] A[^fn1]",
|
|
632
|
+
"",
|
|
633
|
+
"[^fn1]:",
|
|
634
|
+
" Example: `- [ ] not a real option, just quoted inside a footnote body here`.",
|
|
635
|
+
"",
|
|
636
|
+
])
|
|
637
|
+
expect(qa.validate(content)).toEqual([])
|
|
638
|
+
})
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
describe("qa — sections and questions come from heading NODES, not string search", () => {
|
|
642
|
+
it("a '## Open Questions' line quoted inside a fenced code block does not count as the section", () => {
|
|
643
|
+
const content = doc(["Some prose.", "", "```", "## Open Questions", "", "### fake?", "```", ""])
|
|
644
|
+
expect(qa.validate(content)).toEqual([])
|
|
645
|
+
expect(unansweredQuestions(qa, content)).toEqual([])
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it("a fenced block using '~~~' delimiters is recognized the same way", () => {
|
|
649
|
+
const content = doc(["Some prose.", "", "~~~", "## Open Questions", "~~~", ""])
|
|
650
|
+
expect(qa.validate(content)).toEqual([])
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
describe("qa — strict indentation reading", () => {
|
|
655
|
+
it("a '###' heading indented two spaces still counts as a question", () => {
|
|
656
|
+
const content = doc(["## Open Questions", "", " ### Real question?", "", "- [ ] A", ""])
|
|
657
|
+
expect(qa.validate(content)).toEqual([])
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it("a '###' heading indented four spaces is reported as a positioned refusal, not silently dropped", () => {
|
|
661
|
+
const content = doc(["## Open Questions", "", " ### Dropped question?", "", "text", ""])
|
|
662
|
+
const findings = qa.validate(content)
|
|
663
|
+
expect(findings.some((f) => f.message.includes("not a question heading"))).toBe(true)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it("a '- [ ]' option indented four spaces is reported as a positioned refusal", () => {
|
|
667
|
+
const content = doc([
|
|
668
|
+
"## Open Questions",
|
|
669
|
+
"",
|
|
670
|
+
"### Real question?",
|
|
671
|
+
"",
|
|
672
|
+
" - [ ] Dropped option",
|
|
673
|
+
"",
|
|
674
|
+
])
|
|
675
|
+
const findings = qa.validate(content)
|
|
676
|
+
expect(findings.some((f) => f.message.includes("not an option"))).toBe(true)
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it("a 4+-space NESTED option under a real option is still reported, not silently absorbed", () => {
|
|
680
|
+
const content = doc([
|
|
681
|
+
"## Open Questions",
|
|
682
|
+
"",
|
|
683
|
+
"### Real question?",
|
|
684
|
+
"",
|
|
685
|
+
"- [ ] Parent option",
|
|
686
|
+
" - [ ] Nested option",
|
|
687
|
+
"",
|
|
688
|
+
])
|
|
689
|
+
const findings = qa.validate(content)
|
|
690
|
+
expect(findings.some((f) => f.message.includes("not an option"))).toBe(true)
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it("a genuinely shallow (under 4 spaces) nested option is untouched — no finding", () => {
|
|
694
|
+
const content = doc([
|
|
695
|
+
"## Open Questions",
|
|
696
|
+
"",
|
|
697
|
+
"### Real question?",
|
|
698
|
+
"",
|
|
699
|
+
"- [ ] Parent option",
|
|
700
|
+
" - [ ] Shallow nested option",
|
|
701
|
+
"",
|
|
702
|
+
])
|
|
703
|
+
expect(qa.validate(content)).toEqual([])
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
it("a 4+-space heading folded into a preceding option's own lazy continuation is still reported, and the question it would start stays missing", () => {
|
|
707
|
+
const content = doc([
|
|
708
|
+
"## Open Questions",
|
|
709
|
+
"",
|
|
710
|
+
"### A?",
|
|
711
|
+
"",
|
|
712
|
+
"- [ ] one",
|
|
713
|
+
" ### B?",
|
|
714
|
+
"",
|
|
715
|
+
"- [ ] two",
|
|
716
|
+
])
|
|
717
|
+
const findings = qa.validate(content)
|
|
718
|
+
expect(findings).toHaveLength(1)
|
|
719
|
+
expect(findings[0]!.message).toContain("### B?")
|
|
720
|
+
expect(unansweredQuestions(qa, content)).toEqual([expect.objectContaining({ question: "A?" })])
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
describe("qa — strict-reading ignores indented lines that aren't heading- or option-shaped", () => {
|
|
725
|
+
it("a plain indented (4+ space) prose line inside the section is not reported at all", () => {
|
|
726
|
+
const content = doc([
|
|
727
|
+
"## Open Questions",
|
|
728
|
+
"",
|
|
729
|
+
"### Real question?",
|
|
730
|
+
"",
|
|
731
|
+
"some prose.",
|
|
732
|
+
" just an indented continuation, not heading- or option-shaped",
|
|
733
|
+
"",
|
|
734
|
+
])
|
|
735
|
+
expect(qa.validate(content)).toEqual([])
|
|
736
|
+
})
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
describe("qa — actions (options, footnotes)", () => {
|
|
740
|
+
it("offers 'pick this option' on an unticked option, and unticks the previously-ticked sibling", () => {
|
|
741
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [x] A", "- [ ] B", ""])
|
|
742
|
+
const actions = qa.actions(content, {
|
|
743
|
+
start: { line: 5, character: 2 },
|
|
744
|
+
end: { line: 5, character: 2 },
|
|
745
|
+
})
|
|
746
|
+
const pick = actions.find((a) => a.title === "gtd: pick this option")!
|
|
747
|
+
expect(pick.edits).toHaveLength(2)
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it("offers 'uncheck this option' on the already-ticked option", () => {
|
|
751
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [x] A", "- [ ] B", ""])
|
|
752
|
+
const actions = qa.actions(content, {
|
|
753
|
+
start: { line: 4, character: 2 },
|
|
754
|
+
end: { line: 4, character: 2 },
|
|
755
|
+
})
|
|
756
|
+
expect(actions.some((a) => a.title === "gtd: uncheck this option")).toBe(true)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it("has a footnote-only pointerAt", () => {
|
|
760
|
+
const content = doc([
|
|
761
|
+
"## Open Questions",
|
|
762
|
+
"",
|
|
763
|
+
"### Q1?",
|
|
764
|
+
"",
|
|
765
|
+
"- [ ] A[^fn1]",
|
|
766
|
+
"",
|
|
767
|
+
"[^fn1]:",
|
|
768
|
+
" Detail that is longer than eighty characters so it definitely wraps here.",
|
|
769
|
+
"",
|
|
770
|
+
])
|
|
771
|
+
const pointer = qa.pointerAt!(content, { line: 4, character: 8 })
|
|
772
|
+
expect(pointer).toBeDefined()
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it("'add a footnote' lands after the last line of a contiguous option list, not between items", () => {
|
|
776
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [ ] A", "- [ ] B", ""])
|
|
777
|
+
const actions = qa.actions(content, {
|
|
778
|
+
start: { line: 4, character: 5 },
|
|
779
|
+
end: { line: 4, character: 5 },
|
|
780
|
+
})
|
|
781
|
+
const footnote = actions.find((a) => a.title === "gtd: add a footnote")!
|
|
782
|
+
expect(footnote).toBeDefined()
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
it("in a question with no options at all, 'add a footnote' still lands after that question's own block", () => {
|
|
786
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "just prose.", ""])
|
|
787
|
+
const actions = qa.actions(content, {
|
|
788
|
+
start: { line: 4, character: 3 },
|
|
789
|
+
end: { line: 4, character: 3 },
|
|
790
|
+
})
|
|
791
|
+
expect(actions.some((a) => a.title === "gtd: add a footnote")).toBe(true)
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it("outside every question (lead prose above '## Open Questions'), still offers 'add a footnote'", () => {
|
|
795
|
+
const content = doc([
|
|
796
|
+
"Some lead prose.",
|
|
797
|
+
"",
|
|
798
|
+
"## Open Questions",
|
|
799
|
+
"",
|
|
800
|
+
"### Q1?",
|
|
801
|
+
"",
|
|
802
|
+
"text",
|
|
803
|
+
"",
|
|
804
|
+
])
|
|
805
|
+
const actions = qa.actions(content, {
|
|
806
|
+
start: { line: 0, character: 3 },
|
|
807
|
+
end: { line: 0, character: 3 },
|
|
808
|
+
})
|
|
809
|
+
expect(actions.some((a) => a.title === "gtd: add a footnote")).toBe(true)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it("offers the footnote action under the shared FOOTNOTE_ACTION_TITLE constant", () => {
|
|
813
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [ ] A", ""])
|
|
814
|
+
const actions = qa.actions(content, {
|
|
815
|
+
start: { line: 4, character: 5 },
|
|
816
|
+
end: { line: 4, character: 5 },
|
|
817
|
+
})
|
|
818
|
+
const footnote = actions.find((a) => a.title === FOOTNOTE_ACTION_TITLE)!
|
|
819
|
+
expect(footnote).toBeDefined()
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
it("offers 'add a footnote' LAST, after the option action, on an open question's option", () => {
|
|
823
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [ ] A", ""])
|
|
824
|
+
const actions = qa.actions(content, {
|
|
825
|
+
start: { line: 4, character: 2 },
|
|
826
|
+
end: { line: 4, character: 2 },
|
|
827
|
+
})
|
|
828
|
+
expect(actions.map((a) => a.title)).toEqual(["gtd: pick this option", FOOTNOTE_ACTION_TITLE])
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it("is refused with the cursor inside an existing marker's span", () => {
|
|
832
|
+
const content = doc([
|
|
833
|
+
"## Open Questions",
|
|
834
|
+
"",
|
|
835
|
+
"### Q1?",
|
|
836
|
+
"",
|
|
837
|
+
"- [ ] A[^fn1]",
|
|
838
|
+
"",
|
|
839
|
+
"[^fn1]:",
|
|
840
|
+
" Detail that is longer than eighty characters so it definitely wraps here.",
|
|
841
|
+
"",
|
|
842
|
+
])
|
|
843
|
+
const actions = qa.actions(content, {
|
|
844
|
+
start: { line: 4, character: 8 },
|
|
845
|
+
end: { line: 4, character: 8 },
|
|
846
|
+
})
|
|
847
|
+
expect(actions.some((a) => a.title === "gtd: add a footnote")).toBe(false)
|
|
848
|
+
})
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
describe("qa — clearTicks is a deliberate no-op", () => {
|
|
852
|
+
it("never clears its own answer tick — qa's ticks ARE its answers", () => {
|
|
853
|
+
const content = doc(["## Open Questions", "", "### Q1?", "", "- [x] A", "- [ ] B", ""])
|
|
854
|
+
expect(qa.clearTicks(content)).toBe(content)
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
describe("isAnswered — the ONE predicate exported for a client to reuse, never re-derive (T5)", () => {
|
|
859
|
+
it("is answered when exactly one option is ticked and it isn't the free-text slot", () => {
|
|
860
|
+
expect(
|
|
861
|
+
isAnswered([
|
|
862
|
+
{ checked: true, text: "Option A", freeText: false },
|
|
863
|
+
{ checked: false, text: "Option B", freeText: false },
|
|
864
|
+
]),
|
|
865
|
+
).toBe(true)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it("is unanswered when zero options are ticked", () => {
|
|
869
|
+
expect(
|
|
870
|
+
isAnswered([
|
|
871
|
+
{ checked: false, text: "Option A", freeText: false },
|
|
872
|
+
{ checked: false, text: "Option B", freeText: false },
|
|
873
|
+
]),
|
|
874
|
+
).toBe(false)
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
it("is unanswered when two or more options are ticked at once — the exact divergence a client re-deriving 'take the first checked option' would miss", () => {
|
|
878
|
+
expect(
|
|
879
|
+
isAnswered([
|
|
880
|
+
{ checked: true, text: "Option A", freeText: false },
|
|
881
|
+
{ checked: true, text: "Option B", freeText: false },
|
|
882
|
+
]),
|
|
883
|
+
).toBe(false)
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it("is unanswered when the ticked option is the free-text slot with empty text", () => {
|
|
887
|
+
expect(isAnswered([{ checked: true, text: "", freeText: true }])).toBe(false)
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it("is answered when the ticked option is the free-text slot WITH text", () => {
|
|
891
|
+
expect(isAnswered([{ checked: true, text: "a real answer", freeText: true }])).toBe(true)
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
describe("qa.view", () => {
|
|
896
|
+
const CONTENT = [
|
|
897
|
+
"Plan.",
|
|
898
|
+
"",
|
|
899
|
+
"## Open Questions",
|
|
900
|
+
"",
|
|
901
|
+
"### First?",
|
|
902
|
+
"",
|
|
903
|
+
"- [ ] Option A",
|
|
904
|
+
"- [ ] Option B",
|
|
905
|
+
"",
|
|
906
|
+
"## Answered Questions",
|
|
907
|
+
"",
|
|
908
|
+
"### Second?",
|
|
909
|
+
"",
|
|
910
|
+
"Already decided.",
|
|
911
|
+
"",
|
|
912
|
+
].join("\n")
|
|
913
|
+
|
|
914
|
+
it("exposes both open and answered questions, in document order — title carries the actual question, detail the whole body's collapsed text", () => {
|
|
915
|
+
const view = qa.view(CONTENT)
|
|
916
|
+
const questionNodes = view.nodes.filter((n) => n.status !== undefined)
|
|
917
|
+
// "First?"'s own body is ONLY its option list — excluded from the body
|
|
918
|
+
// walk (that's `children`'s own job) — so its `detail` is "".
|
|
919
|
+
expect(questionNodes.map((q) => [q.status, q.title, q.detail])).toEqual([
|
|
920
|
+
["open", "First?", ""],
|
|
921
|
+
["answered", "Second?", "Already decided."],
|
|
922
|
+
])
|
|
923
|
+
expect(questionNodes[0]!.children!.map((o) => o.title)).toEqual(["Option A", "Option B"])
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
it("prepends the plan's own lead prose (before '## Open Questions') as a paragraph node, ahead of every question", () => {
|
|
927
|
+
const view = qa.view(CONTENT)
|
|
928
|
+
expect(view.nodes[0]).toMatchObject({ title: "Plan.", anchor: { kind: "paragraph", line: 0 } })
|
|
929
|
+
expect(view.nodes[0]!.status).toBeUndefined()
|
|
930
|
+
expect(view.nodes.slice(1).every((n) => n.status !== undefined)).toBe(true)
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
it("is built from one parse of the document, not one per element", () => {
|
|
934
|
+
const uniqueContent = [
|
|
935
|
+
"Plan two.",
|
|
936
|
+
"",
|
|
937
|
+
"## Open Questions",
|
|
938
|
+
"",
|
|
939
|
+
"### Solo?",
|
|
940
|
+
"",
|
|
941
|
+
"- [ ] Only option",
|
|
942
|
+
"",
|
|
943
|
+
].join("\n")
|
|
944
|
+
const before = getParseCount()
|
|
945
|
+
qa.view(uniqueContent)
|
|
946
|
+
expect(getParseCount()).toBe(before + 1)
|
|
947
|
+
})
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
describe("qa.view — spec-feedback regression: raw HTML stays inert text, never an empty detailInline", () => {
|
|
951
|
+
it("an answered question's own block-level <div> body carries one text node on detailInline, never []", () => {
|
|
952
|
+
const content = ["## Answered Questions", "", "### Second?", "", "<div>raw</div>", ""].join(
|
|
953
|
+
"\n",
|
|
954
|
+
)
|
|
955
|
+
const view = qa.view(content)
|
|
956
|
+
const question = view.nodes.find((n) => n.status !== undefined)!
|
|
957
|
+
expect(question.detail).toBe("<div>raw</div>")
|
|
958
|
+
expect(question.detailInline).toEqual([{ kind: "text", value: "<div>raw</div>" }])
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
it("a nested list item's own text is not dropped from an answered question's own detailInline", () => {
|
|
962
|
+
const content = [
|
|
963
|
+
"## Answered Questions",
|
|
964
|
+
"",
|
|
965
|
+
"### Second?",
|
|
966
|
+
"",
|
|
967
|
+
"- item one",
|
|
968
|
+
"- item two",
|
|
969
|
+
" - nested",
|
|
970
|
+
"",
|
|
971
|
+
].join("\n")
|
|
972
|
+
const view = qa.view(content)
|
|
973
|
+
const question = view.nodes.find((n) => n.status !== undefined)!
|
|
974
|
+
// `detail` itself is title-based (`bodyText`/`blockTitle`), which has
|
|
975
|
+
// never included a nested item's own text — unchanged, not this
|
|
976
|
+
// regression's own concern. `detailInline` is the flattened path this
|
|
977
|
+
// regression fixes, and must carry it.
|
|
978
|
+
const joined = (question.detailInline ?? []).map((n) => ("value" in n ? n.value : "")).join("")
|
|
979
|
+
expect(joined).toContain("nested")
|
|
980
|
+
})
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
describe("qa.view — prose-only projection (T2, no Open/Answered Questions section at all)", () => {
|
|
984
|
+
it("yields one paragraph node per paragraph, and no questions", () => {
|
|
985
|
+
const content = ["First paragraph of the plan.", "", "Second paragraph, more detail.", ""].join(
|
|
986
|
+
"\n",
|
|
987
|
+
)
|
|
988
|
+
const view = qa.view(content)
|
|
989
|
+
expect(view.nodes.every((n) => n.status === undefined)).toBe(true)
|
|
990
|
+
expect(view.nodes.map((n) => n.title)).toEqual([
|
|
991
|
+
"First paragraph of the plan.",
|
|
992
|
+
"Second paragraph, more detail.",
|
|
993
|
+
])
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
it("each paragraph node's anchor is a real, server-computed {kind:'paragraph', line} at the paragraph's own start line", () => {
|
|
997
|
+
const content = ["Line zero paragraph.", "", "Line two paragraph.", ""].join("\n")
|
|
998
|
+
const view = qa.view(content)
|
|
999
|
+
expect(view.nodes.map((n) => n.anchor)).toEqual([
|
|
1000
|
+
{ kind: "paragraph", line: 0 },
|
|
1001
|
+
{ kind: "paragraph", line: 2 },
|
|
1002
|
+
])
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
it("a paragraph anchor round-trips through annotate/resolve — attaching a note at the projected line actually lands", () => {
|
|
1006
|
+
const content = ["A paragraph worth commenting on.", ""].join("\n")
|
|
1007
|
+
const result = qa.annotate(content, { kind: "paragraph", line: 0 }, "a real comment")
|
|
1008
|
+
expect(result.ok).toBe(true)
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
it("a paragraph already carrying a footnote at its own start line surfaces it as that node's own note, for editing rather than a second note", () => {
|
|
1012
|
+
const content = [
|
|
1013
|
+
"A paragraph with a note attached.[^fn1]",
|
|
1014
|
+
"",
|
|
1015
|
+
"[^fn1]: the reviewer's own comment",
|
|
1016
|
+
"",
|
|
1017
|
+
].join("\n")
|
|
1018
|
+
const view = qa.view(content)
|
|
1019
|
+
expect(view.nodes[0]?.note).toBe("the reviewer's own comment")
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
it("a document with an Open Questions section is NOT treated as prose-only, even with prose before it", () => {
|
|
1023
|
+
const content = [
|
|
1024
|
+
"Some intro prose.",
|
|
1025
|
+
"",
|
|
1026
|
+
"## Open Questions",
|
|
1027
|
+
"",
|
|
1028
|
+
"### First?",
|
|
1029
|
+
"",
|
|
1030
|
+
"- [ ] Option A",
|
|
1031
|
+
"",
|
|
1032
|
+
].join("\n")
|
|
1033
|
+
const view = qa.view(content)
|
|
1034
|
+
// NOT prose-only: the real question node is still there — but its own
|
|
1035
|
+
// intro prose is now ALSO a node (a separate, `undefined`-status
|
|
1036
|
+
// paragraph node), never dropped.
|
|
1037
|
+
expect(view.nodes.map((n) => n.status)).toEqual([undefined, "open"])
|
|
1038
|
+
expect(view.nodes[0]).toMatchObject({
|
|
1039
|
+
title: "Some intro prose.",
|
|
1040
|
+
anchor: { kind: "paragraph", line: 0 },
|
|
1041
|
+
})
|
|
1042
|
+
})
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
describe("qa.view — block nodes (package 02, T1/T2)", () => {
|
|
1046
|
+
it("a heading, a nested list, a fenced code block, a blockquote and two paragraphs yield six block nodes, in document order", () => {
|
|
1047
|
+
const content = [
|
|
1048
|
+
"# Heading",
|
|
1049
|
+
"",
|
|
1050
|
+
"- Item one",
|
|
1051
|
+
" - Nested item",
|
|
1052
|
+
"",
|
|
1053
|
+
"```",
|
|
1054
|
+
"line one",
|
|
1055
|
+
"line two",
|
|
1056
|
+
"```",
|
|
1057
|
+
"",
|
|
1058
|
+
"> A blockquote.",
|
|
1059
|
+
"",
|
|
1060
|
+
"Paragraph one.",
|
|
1061
|
+
"",
|
|
1062
|
+
"Paragraph two.",
|
|
1063
|
+
"",
|
|
1064
|
+
].join("\n")
|
|
1065
|
+
const view = qa.view(content)
|
|
1066
|
+
expect(view.nodes.map((n) => n.block?.kind)).toEqual([
|
|
1067
|
+
"heading",
|
|
1068
|
+
"list",
|
|
1069
|
+
"code",
|
|
1070
|
+
"blockquote",
|
|
1071
|
+
"paragraph",
|
|
1072
|
+
"paragraph",
|
|
1073
|
+
])
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
it("yields block nodes for the prose BEFORE the questions section and for every block AFTER it, and none for any block inside a question's own span", () => {
|
|
1077
|
+
// A question's own body span runs up to the NEXT heading of any depth
|
|
1078
|
+
// (`questionEndLines`/`splitQuestionBlocks`'s existing rule) — so genuine
|
|
1079
|
+
// trailing content past the LAST question needs a heading of its own
|
|
1080
|
+
// (here a `####`, neither a section heading nor a question heading) to
|
|
1081
|
+
// stop that span; without one it reads as part of the last question's own
|
|
1082
|
+
// body, which is exactly what this test's OWN "none inside a question's
|
|
1083
|
+
// span" half already covers via "Already decided."/"Option A".
|
|
1084
|
+
const content = [
|
|
1085
|
+
"Lead prose.",
|
|
1086
|
+
"",
|
|
1087
|
+
"## Open Questions",
|
|
1088
|
+
"",
|
|
1089
|
+
"### First?",
|
|
1090
|
+
"",
|
|
1091
|
+
"- [ ] Option A",
|
|
1092
|
+
"",
|
|
1093
|
+
"## Answered Questions",
|
|
1094
|
+
"",
|
|
1095
|
+
"### Second?",
|
|
1096
|
+
"",
|
|
1097
|
+
"Already decided.",
|
|
1098
|
+
"",
|
|
1099
|
+
"#### Note",
|
|
1100
|
+
"",
|
|
1101
|
+
"Trailing prose.",
|
|
1102
|
+
"",
|
|
1103
|
+
].join("\n")
|
|
1104
|
+
const view = qa.view(content)
|
|
1105
|
+
const proseTitles = view.nodes.filter((n) => n.status === undefined).map((n) => n.title)
|
|
1106
|
+
expect(proseTitles).toEqual(["Lead prose.", "Note", "Trailing prose."])
|
|
1107
|
+
// "Already decided." is the ANSWERED question's own body text — never a
|
|
1108
|
+
// second, competing block node alongside that question's own node.
|
|
1109
|
+
expect(proseTitles).not.toContain("Already decided.")
|
|
1110
|
+
expect(proseTitles).not.toContain("Option A")
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
it("neither the '## Open Questions' nor the '## Answered Questions' heading is ever itself a block node", () => {
|
|
1114
|
+
const content = [
|
|
1115
|
+
"## Open Questions",
|
|
1116
|
+
"",
|
|
1117
|
+
"### First?",
|
|
1118
|
+
"",
|
|
1119
|
+
"- [ ] Option A",
|
|
1120
|
+
"",
|
|
1121
|
+
"## Answered Questions",
|
|
1122
|
+
"",
|
|
1123
|
+
"### Second?",
|
|
1124
|
+
"",
|
|
1125
|
+
"Already decided.",
|
|
1126
|
+
"",
|
|
1127
|
+
].join("\n")
|
|
1128
|
+
const view = qa.view(content)
|
|
1129
|
+
expect(view.nodes.map((n) => n.title)).not.toContain("Open Questions")
|
|
1130
|
+
expect(view.nodes.map((n) => n.title)).not.toContain("Answered Questions")
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
it("a prose-only document yields the same block nodes and zero question nodes", () => {
|
|
1134
|
+
const content = ["# Heading", "", "Paragraph.", ""].join("\n")
|
|
1135
|
+
const view = qa.view(content)
|
|
1136
|
+
expect(view.nodes.every((n) => n.status === undefined)).toBe(true)
|
|
1137
|
+
expect(view.nodes.map((n) => n.block?.kind)).toEqual(["heading", "paragraph"])
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
it("a heading node carries block.kind 'heading' and its real depth", () => {
|
|
1141
|
+
const content = ["### A level-3 heading", ""].join("\n")
|
|
1142
|
+
const view = qa.view(content)
|
|
1143
|
+
expect(view.nodes[0]?.block).toMatchObject({ kind: "heading", depth: 3 })
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
it("a list node carries block.kind 'list', its ordered flag, and an items tree whose nesting matches the source — two levels deep for a list nested two deep", () => {
|
|
1147
|
+
const content = ["- Top item", " - Nested item", ""].join("\n")
|
|
1148
|
+
const view = qa.view(content)
|
|
1149
|
+
expect(view.nodes[0]?.block).toMatchObject({
|
|
1150
|
+
kind: "list",
|
|
1151
|
+
ordered: false,
|
|
1152
|
+
items: [{ text: "Top item", items: [{ text: "Nested item" }] }],
|
|
1153
|
+
})
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
it("an ordered list node carries ordered: true", () => {
|
|
1157
|
+
const content = ["1. First", "2. Second", ""].join("\n")
|
|
1158
|
+
const view = qa.view(content)
|
|
1159
|
+
expect(view.nodes[0]?.block).toMatchObject({ kind: "list", ordered: true })
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
it("a list item with a nested list AND a trailing paragraph of its own yields text containing neither the nested item's text nor its '-' marker, with that item present exactly once in the items tree", () => {
|
|
1163
|
+
const content = ["- Top", "", " - Nested", "", " Tail para.", ""].join("\n")
|
|
1164
|
+
const view = qa.view(content)
|
|
1165
|
+
const topItem = view.nodes[0]?.block?.items?.[0]
|
|
1166
|
+
expect(topItem?.text).toBe("Top Tail para.")
|
|
1167
|
+
expect(topItem?.text).not.toContain("Nested")
|
|
1168
|
+
expect(topItem?.text).not.toContain("-")
|
|
1169
|
+
expect(topItem?.items).toEqual([
|
|
1170
|
+
{ text: "Nested", inline: [{ kind: "text", value: "Nested" }] },
|
|
1171
|
+
])
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it("a task-list item carries its checked state on the item", () => {
|
|
1175
|
+
const content = ["- [ ] Not done", "- [x] Done", ""].join("\n")
|
|
1176
|
+
const view = qa.view(content)
|
|
1177
|
+
expect(view.nodes[0]?.block?.items).toEqual([
|
|
1178
|
+
{ text: "Not done", checked: false, inline: [{ kind: "text", value: "Not done" }] },
|
|
1179
|
+
{ text: "Done", checked: true, inline: [{ kind: "text", value: "Done" }] },
|
|
1180
|
+
])
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
it("a fenced code block carries block.kind 'code', its language from the info string, and its body verbatim — leading whitespace intact", () => {
|
|
1184
|
+
const content = ["```ts", " const x = 1", "const y = 2", "```", ""].join("\n")
|
|
1185
|
+
const view = qa.view(content)
|
|
1186
|
+
expect(view.nodes[0]?.block).toMatchObject({
|
|
1187
|
+
kind: "code",
|
|
1188
|
+
language: "ts",
|
|
1189
|
+
text: " const x = 1\nconst y = 2",
|
|
1190
|
+
})
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
it("a fenced code block with no info string carries no language field", () => {
|
|
1194
|
+
const content = ["```", "plain", "```", ""].join("\n")
|
|
1195
|
+
const view = qa.view(content)
|
|
1196
|
+
expect(view.nodes[0]?.block?.language).toBeUndefined()
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
it("a fenced code block with an empty body still carries a non-empty title", () => {
|
|
1200
|
+
const content = ["```", "```", ""].join("\n")
|
|
1201
|
+
const view = qa.view(content)
|
|
1202
|
+
expect(view.nodes[0]?.block).toMatchObject({ kind: "code", text: "" })
|
|
1203
|
+
expect(view.nodes[0]?.title.length).toBeGreaterThan(0)
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
it("a blockquote carries block.kind 'blockquote' and its text", () => {
|
|
1207
|
+
const content = ["> Quoted wisdom.", ""].join("\n")
|
|
1208
|
+
const view = qa.view(content)
|
|
1209
|
+
expect(view.nodes[0]?.block).toMatchObject({ kind: "blockquote", text: "Quoted wisdom." })
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
it("a blockquote spanning two paragraphs carries text with no '>' character in it", () => {
|
|
1213
|
+
const content = ["> First line.", ">", "> Second para.", ""].join("\n")
|
|
1214
|
+
const view = qa.view(content)
|
|
1215
|
+
expect(view.nodes[0]?.block?.text).not.toContain(">")
|
|
1216
|
+
expect(view.nodes[0]?.block?.text).toBe("First line. Second para.")
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
it("a soft-broken blockquote (one paragraph, no blank '>' line) carries title and block.text with no '>' character in it", () => {
|
|
1220
|
+
const content = ["> quoted line", "> more", ""].join("\n")
|
|
1221
|
+
const view = qa.view(content)
|
|
1222
|
+
expect(view.nodes[0]?.title).not.toContain(">")
|
|
1223
|
+
expect(view.nodes[0]?.block?.text).not.toContain(">")
|
|
1224
|
+
expect(view.nodes[0]?.block).toMatchObject({ kind: "blockquote", text: "quoted line more" })
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
it("a literal '>' typed mid-prose inside a blockquote survives into title", () => {
|
|
1228
|
+
const content = ["> a > b", ""].join("\n")
|
|
1229
|
+
const view = qa.view(content)
|
|
1230
|
+
expect(view.nodes[0]?.title).toBe("a > b")
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
it("an inline link inside a multi-line blockquote keeps its [label](url) syntax intact", () => {
|
|
1234
|
+
const content = ["> see [label](url)", "> more text", ""].join("\n")
|
|
1235
|
+
const view = qa.view(content)
|
|
1236
|
+
expect(view.nodes[0]?.title).toBe("see [label](url) more text")
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
it("every node still carries a non-empty title", () => {
|
|
1240
|
+
const content = [
|
|
1241
|
+
"# Heading",
|
|
1242
|
+
"",
|
|
1243
|
+
"- Item",
|
|
1244
|
+
"",
|
|
1245
|
+
"```",
|
|
1246
|
+
"code",
|
|
1247
|
+
"```",
|
|
1248
|
+
"",
|
|
1249
|
+
"> Quote.",
|
|
1250
|
+
"",
|
|
1251
|
+
"Paragraph.",
|
|
1252
|
+
"",
|
|
1253
|
+
].join("\n")
|
|
1254
|
+
const view = qa.view(content)
|
|
1255
|
+
expect(view.nodes.every((n) => n.title.length > 0)).toBe(true)
|
|
1256
|
+
})
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
describe("qa.view — question body nodes (Task 2: a question's body projects as blocks, not a line)", () => {
|
|
1260
|
+
const questionNodeOf = (content: string) =>
|
|
1261
|
+
qa.view(content).nodes.find((n) => n.status !== undefined)!
|
|
1262
|
+
|
|
1263
|
+
it("a question whose body is a paragraph, a fenced code block and a list projects three body nodes carrying those kinds, in document order", () => {
|
|
1264
|
+
const content = [
|
|
1265
|
+
"## Open Questions",
|
|
1266
|
+
"",
|
|
1267
|
+
"### Which approach?",
|
|
1268
|
+
"",
|
|
1269
|
+
"Some body prose.",
|
|
1270
|
+
"",
|
|
1271
|
+
"```",
|
|
1272
|
+
"example code",
|
|
1273
|
+
"```",
|
|
1274
|
+
"",
|
|
1275
|
+
"* a plain bullet",
|
|
1276
|
+
"* another bullet",
|
|
1277
|
+
"",
|
|
1278
|
+
"- [ ] Option A",
|
|
1279
|
+
"",
|
|
1280
|
+
].join("\n")
|
|
1281
|
+
const question = questionNodeOf(content)
|
|
1282
|
+
expect(question.body?.map((n) => n.block?.kind)).toEqual(["paragraph", "code", "list"])
|
|
1283
|
+
})
|
|
1284
|
+
|
|
1285
|
+
it("the option list never appears in body, and body blocks never appear in children", () => {
|
|
1286
|
+
const content = [
|
|
1287
|
+
"## Open Questions",
|
|
1288
|
+
"",
|
|
1289
|
+
"### Which approach?",
|
|
1290
|
+
"",
|
|
1291
|
+
"Body prose.",
|
|
1292
|
+
"",
|
|
1293
|
+
"- [ ] Option A",
|
|
1294
|
+
"- [ ] Option B",
|
|
1295
|
+
"",
|
|
1296
|
+
].join("\n")
|
|
1297
|
+
const question = questionNodeOf(content)
|
|
1298
|
+
expect(question.body?.map((n) => n.title)).toEqual(["Body prose."])
|
|
1299
|
+
expect(question.children?.map((n) => n.title)).toEqual(["Option A", "Option B"])
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
it("a plain (non-task) bullet sharing the SAME list as real options is excluded from children but still surfaces in body — never dropped entirely", () => {
|
|
1303
|
+
const content = [
|
|
1304
|
+
"## Open Questions",
|
|
1305
|
+
"",
|
|
1306
|
+
"### Which approach?",
|
|
1307
|
+
"",
|
|
1308
|
+
"Body prose that matters.",
|
|
1309
|
+
"",
|
|
1310
|
+
"- [ ] Option A",
|
|
1311
|
+
"- a plain bullet that is NOT an option",
|
|
1312
|
+
"- [ ] Option B",
|
|
1313
|
+
"",
|
|
1314
|
+
].join("\n")
|
|
1315
|
+
const question = questionNodeOf(content)
|
|
1316
|
+
// The two real options are still `children`, exactly as before.
|
|
1317
|
+
expect(question.children?.map((n) => n.title)).toEqual(["Option A", "Option B"])
|
|
1318
|
+
// The plain bullet is excluded from `children` (it's not an option) —
|
|
1319
|
+
// but it must not vanish outright: it belongs in `body`, alongside the
|
|
1320
|
+
// question's other prose, not nowhere.
|
|
1321
|
+
expect(question.body?.map((n) => n.title)).toEqual([
|
|
1322
|
+
"Body prose that matters.",
|
|
1323
|
+
"a plain bullet that is NOT an option",
|
|
1324
|
+
])
|
|
1325
|
+
expect(question.detail).toContain("a plain bullet that is NOT an option")
|
|
1326
|
+
})
|
|
1327
|
+
|
|
1328
|
+
it("detail on a question node is the whole body's collapsed text — a body wrapped across four source lines reads back complete", () => {
|
|
1329
|
+
const content = [
|
|
1330
|
+
"## Open Questions",
|
|
1331
|
+
"",
|
|
1332
|
+
"### Which approach?",
|
|
1333
|
+
"",
|
|
1334
|
+
"This body prose deliberately wraps across several",
|
|
1335
|
+
"source lines so the whole thing must be collapsed",
|
|
1336
|
+
"into one detail string rather than only the first",
|
|
1337
|
+
"of its own four lines.",
|
|
1338
|
+
"",
|
|
1339
|
+
"- [ ] Option A",
|
|
1340
|
+
"",
|
|
1341
|
+
].join("\n")
|
|
1342
|
+
const question = questionNodeOf(content)
|
|
1343
|
+
expect(question.detail).toBe(
|
|
1344
|
+
"This body prose deliberately wraps across several source lines so the whole thing must be collapsed into one detail string rather than only the first of its own four lines.",
|
|
1345
|
+
)
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
it("a question with no body at all yields body: [] and detail: ''", () => {
|
|
1349
|
+
const content = ["## Open Questions", "", "### Which approach?", "", "- [ ] Option A", ""].join(
|
|
1350
|
+
"\n",
|
|
1351
|
+
)
|
|
1352
|
+
const question = questionNodeOf(content)
|
|
1353
|
+
expect(question.body).toEqual([])
|
|
1354
|
+
expect(question.detail).toBe("")
|
|
1355
|
+
})
|
|
1356
|
+
|
|
1357
|
+
it("every body node carries a real {kind: 'paragraph', line} anchor at its own start line", () => {
|
|
1358
|
+
const content = [
|
|
1359
|
+
"## Open Questions",
|
|
1360
|
+
"",
|
|
1361
|
+
"### Which approach?",
|
|
1362
|
+
"",
|
|
1363
|
+
"Body prose.",
|
|
1364
|
+
"",
|
|
1365
|
+
"- [ ] Option A",
|
|
1366
|
+
"",
|
|
1367
|
+
].join("\n")
|
|
1368
|
+
const question = questionNodeOf(content)
|
|
1369
|
+
expect(question.body).toEqual([
|
|
1370
|
+
expect.objectContaining({ anchor: { kind: "paragraph", line: 4 } }),
|
|
1371
|
+
])
|
|
1372
|
+
})
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
/** Applies edits back-to-front — a local copy of the file's own `applyEdits` (defined further down, after `qa.apply`'s own describe block) so this earlier suite can assert on the resulting document text too. */
|
|
1376
|
+
const applyEditsLocal = (
|
|
1377
|
+
content: string,
|
|
1378
|
+
edits: readonly {
|
|
1379
|
+
readonly range: {
|
|
1380
|
+
readonly start: { readonly line: number; readonly character: number }
|
|
1381
|
+
readonly end: { readonly line: number; readonly character: number }
|
|
1382
|
+
}
|
|
1383
|
+
readonly newText: string
|
|
1384
|
+
}[],
|
|
1385
|
+
): string => {
|
|
1386
|
+
const lines = content.split("\n")
|
|
1387
|
+
const toOffset = (pos: { readonly line: number; readonly character: number }): number => {
|
|
1388
|
+
let offset = 0
|
|
1389
|
+
for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
1390
|
+
return offset + pos.character
|
|
1391
|
+
}
|
|
1392
|
+
const sorted = [...edits].sort((a, b) => toOffset(b.range.start) - toOffset(a.range.start))
|
|
1393
|
+
let result = content
|
|
1394
|
+
for (const edit of sorted) {
|
|
1395
|
+
result =
|
|
1396
|
+
result.slice(0, toOffset(edit.range.start)) +
|
|
1397
|
+
edit.newText +
|
|
1398
|
+
result.slice(toOffset(edit.range.end))
|
|
1399
|
+
}
|
|
1400
|
+
return result
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
describe("qa.annotate/note — block anchors carry structure beyond paragraphs (package 02, T3)", () => {
|
|
1404
|
+
it("annotate with a paragraph anchor at a heading's start line attaches the marker at the end of that heading's own line and the definition after the heading's own block", () => {
|
|
1405
|
+
const content = ["# Heading", "", "Body text.", ""].join("\n")
|
|
1406
|
+
const result = qa.annotate(content, { kind: "paragraph", line: 0 }, "a note on the heading")
|
|
1407
|
+
expect(result.ok).toBe(true)
|
|
1408
|
+
if (!result.ok) return
|
|
1409
|
+
const markerEdit = result.edits.find((e) => e.range.start.line === 0)
|
|
1410
|
+
expect(markerEdit?.newText).toContain("[^")
|
|
1411
|
+
const applied = applyEditsLocal(content, result.edits)
|
|
1412
|
+
expect(applied.split("\n")[0]).toBe(`# Heading${markerEdit?.newText}`)
|
|
1413
|
+
expect(qa.validate(applied).length).toBe(0)
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
it("annotate with a paragraph anchor at a list's start line and at a blockquote's start line each succeed, and the resulting document still passes validate with zero findings", () => {
|
|
1417
|
+
const listContent = ["- Item one", "- Item two", ""].join("\n")
|
|
1418
|
+
const listResult = qa.annotate(listContent, { kind: "paragraph", line: 0 }, "note on list")
|
|
1419
|
+
expect(listResult.ok).toBe(true)
|
|
1420
|
+
if (listResult.ok) {
|
|
1421
|
+
expect(qa.validate(applyEditsLocal(listContent, listResult.edits)).length).toBe(0)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const quoteContent = ["> A quote.", ""].join("\n")
|
|
1425
|
+
const quoteResult = qa.annotate(quoteContent, { kind: "paragraph", line: 0 }, "note on quote")
|
|
1426
|
+
expect(quoteResult.ok).toBe(true)
|
|
1427
|
+
if (quoteResult.ok) {
|
|
1428
|
+
expect(qa.validate(applyEditsLocal(quoteContent, quoteResult.edits)).length).toBe(0)
|
|
1429
|
+
}
|
|
1430
|
+
})
|
|
1431
|
+
|
|
1432
|
+
it("a block node that already carries a footnote marker on its start line surfaces that note as `note`, for every block kind — not just paragraphs", () => {
|
|
1433
|
+
const content = [
|
|
1434
|
+
"# Heading with a note[^fn1]",
|
|
1435
|
+
"",
|
|
1436
|
+
"[^fn1]: the reviewer's own comment",
|
|
1437
|
+
"",
|
|
1438
|
+
].join("\n")
|
|
1439
|
+
const view = qa.view(content)
|
|
1440
|
+
expect(view.nodes[0]?.block?.kind).toBe("heading")
|
|
1441
|
+
expect(view.nodes[0]?.note).toBe("the reviewer's own comment")
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
// `annotate` deliberately keeps no code-block branch (Task 3's own point):
|
|
1445
|
+
// `footnoteAttachEdits` puts a marker at the end of the anchor line, which
|
|
1446
|
+
// for a code block IS the opening fence — attaching there WOULD corrupt
|
|
1447
|
+
// it. Nothing here prevents that call from succeeding; the server still
|
|
1448
|
+
// emits the code block's own `{kind:"paragraph", line}` anchor like every
|
|
1449
|
+
// other node (below), and it is the CLIENT's job (`ProseBlock`, Task 4)
|
|
1450
|
+
// to never offer the seam that would produce this call in the first place.
|
|
1451
|
+
it("a code block still carries a real {kind:'paragraph', line} anchor — the same kind every other block carries, no separate anchor kind for code", () => {
|
|
1452
|
+
const content = ["```", "line one", "```", ""].join("\n")
|
|
1453
|
+
const view = qa.view(content)
|
|
1454
|
+
expect(view.nodes[0]).toMatchObject({
|
|
1455
|
+
block: { kind: "code" },
|
|
1456
|
+
anchor: { kind: "paragraph", line: 0 },
|
|
1457
|
+
})
|
|
1458
|
+
})
|
|
1459
|
+
})
|
|
1460
|
+
|
|
1461
|
+
describe("qa — option text span (Task 1: one span, read and written by one helper)", () => {
|
|
1462
|
+
it("an option wrapped across three source lines reads back as its whole text, whitespace-collapsed and marker-stripped", () => {
|
|
1463
|
+
const content = [
|
|
1464
|
+
"## Open Questions",
|
|
1465
|
+
"",
|
|
1466
|
+
"### Which API?",
|
|
1467
|
+
"",
|
|
1468
|
+
"- [ ] REST, specifically a JSON:API-flavored",
|
|
1469
|
+
" REST endpoint set that keeps every response",
|
|
1470
|
+
" shaped consistently across the whole service.",
|
|
1471
|
+
"",
|
|
1472
|
+
].join("\n")
|
|
1473
|
+
const [option] = parseOpenQuestions(content).questions[0]!.options
|
|
1474
|
+
expect(option!.text).toBe(
|
|
1475
|
+
"REST, specifically a JSON:API-flavored REST endpoint set that keeps every response shaped consistently across the whole service.",
|
|
1476
|
+
)
|
|
1477
|
+
})
|
|
1478
|
+
|
|
1479
|
+
it("parse → apply({text}) → parse on a question whose existing free-text answer wraps across lines returns exactly the new answer — no tail of the old answer left behind, no duplication", () => {
|
|
1480
|
+
const content = [
|
|
1481
|
+
"## Open Questions",
|
|
1482
|
+
"",
|
|
1483
|
+
"### Which API?",
|
|
1484
|
+
"",
|
|
1485
|
+
"- [ ] REST",
|
|
1486
|
+
"- [x] a long previous answer that wraps",
|
|
1487
|
+
" across two whole continuation lines here",
|
|
1488
|
+
"",
|
|
1489
|
+
].join("\n")
|
|
1490
|
+
const before = parseOpenQuestions(content).questions[0]!
|
|
1491
|
+
expect(before.options[1]!.text).toBe(
|
|
1492
|
+
"a long previous answer that wraps across two whole continuation lines here",
|
|
1493
|
+
)
|
|
1494
|
+
const result = qa.apply(
|
|
1495
|
+
content,
|
|
1496
|
+
{ kind: "option", questionIndex: 0, index: 1 },
|
|
1497
|
+
{ checked: true, text: "a short new answer" },
|
|
1498
|
+
)
|
|
1499
|
+
expect(result.ok).toBe(true)
|
|
1500
|
+
if (!result.ok) return
|
|
1501
|
+
const applied = applyEditsLocal(content, result.edits)
|
|
1502
|
+
const after = parseOpenQuestions(applied).questions[0]!
|
|
1503
|
+
expect(after.options[1]!.text).toBe("a short new answer")
|
|
1504
|
+
expect(applied).not.toContain("wraps")
|
|
1505
|
+
expect(applied).not.toContain("continuation lines")
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
it("an option carrying a nested list under it keeps that nested list byte-for-byte after a free-text save", () => {
|
|
1509
|
+
const content = [
|
|
1510
|
+
"## Open Questions",
|
|
1511
|
+
"",
|
|
1512
|
+
"### Which API?",
|
|
1513
|
+
"",
|
|
1514
|
+
"- [x] the previous answer",
|
|
1515
|
+
" - a nested detail that must survive",
|
|
1516
|
+
" - another nested detail",
|
|
1517
|
+
"- [ ] GraphQL",
|
|
1518
|
+
"",
|
|
1519
|
+
].join("\n")
|
|
1520
|
+
const result = qa.apply(
|
|
1521
|
+
content,
|
|
1522
|
+
{ kind: "option", questionIndex: 0, index: 0 },
|
|
1523
|
+
{ checked: true, text: "a brand new answer" },
|
|
1524
|
+
)
|
|
1525
|
+
expect(result.ok).toBe(true)
|
|
1526
|
+
if (!result.ok) return
|
|
1527
|
+
const applied = applyEditsLocal(content, result.edits)
|
|
1528
|
+
expect(applied).toContain(" - a nested detail that must survive")
|
|
1529
|
+
expect(applied).toContain(" - another nested detail")
|
|
1530
|
+
expect(applied).toContain("- [x] a brand new answer")
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
it("an option whose content starts on the line AFTER its marker reads its text and accepts a text edit, where it read '' and refused before", () => {
|
|
1534
|
+
const content = [
|
|
1535
|
+
"## Open Questions",
|
|
1536
|
+
"",
|
|
1537
|
+
"### Which API?",
|
|
1538
|
+
"",
|
|
1539
|
+
"- [ ]",
|
|
1540
|
+
" content on the next line",
|
|
1541
|
+
"",
|
|
1542
|
+
].join("\n")
|
|
1543
|
+
const before = parseOpenQuestions(content).questions[0]!.options[0]!
|
|
1544
|
+
expect(before.text).toBe("content on the next line")
|
|
1545
|
+
const result = qa.apply(
|
|
1546
|
+
content,
|
|
1547
|
+
{ kind: "option", questionIndex: 0, index: 0 },
|
|
1548
|
+
{ checked: true, text: "a replacement" },
|
|
1549
|
+
)
|
|
1550
|
+
expect(result.ok).toBe(true)
|
|
1551
|
+
if (!result.ok) return
|
|
1552
|
+
const applied = applyEditsLocal(content, result.edits)
|
|
1553
|
+
expect(parseOpenQuestions(applied).questions[0]!.options[0]).toMatchObject({
|
|
1554
|
+
checked: true,
|
|
1555
|
+
text: "a replacement",
|
|
1556
|
+
})
|
|
1557
|
+
})
|
|
1558
|
+
|
|
1559
|
+
// A bare, truly content-less "- [ ]" is never even recognized as a task
|
|
1560
|
+
// item by the underlying GFM parser (its own micromark task-list-check
|
|
1561
|
+
// tokenizer requires trailing content or a following sibling to fire at
|
|
1562
|
+
// all — `item.checked` stays `null` and `optionListItems` excludes it
|
|
1563
|
+
// upstream), so `optionTextSpan`'s "no paragraph" branch can't be reached
|
|
1564
|
+
// through real parsed markdown. The `qa.apply` "erasing a free-text
|
|
1565
|
+
// answer" test (below) covers the reachable shape of this same guard: an
|
|
1566
|
+
// erase writes `FREE_TEXT_PLACEHOLDER` rather than truly empty text,
|
|
1567
|
+
// specifically so this anchor never goes stale.
|
|
1568
|
+
})
|
|
1569
|
+
|
|
1570
|
+
describe("qa — option impacts (nested content under an option)", () => {
|
|
1571
|
+
it("a nested plain bullet list under an option parses into that option's own body, and the option's own text stays the label alone", () => {
|
|
1572
|
+
const content = [
|
|
1573
|
+
"## Open Questions",
|
|
1574
|
+
"",
|
|
1575
|
+
"### Which option?",
|
|
1576
|
+
"",
|
|
1577
|
+
"- [ ] Ship it behind a flag",
|
|
1578
|
+
" - Rollback is one edit, not a revert",
|
|
1579
|
+
" - Leaves a config key nobody removes",
|
|
1580
|
+
"- [ ] Ship it directly",
|
|
1581
|
+
"",
|
|
1582
|
+
].join("\n")
|
|
1583
|
+
const [first, second] = parseOpenQuestions(content).questions[0]!.options
|
|
1584
|
+
expect(first!.text).toBe("Ship it behind a flag")
|
|
1585
|
+
expect(first!.body).toHaveLength(1)
|
|
1586
|
+
expect(first!.body[0]!.block?.kind).toBe("list")
|
|
1587
|
+
expect(first!.body[0]!.block?.items?.map((item) => item.text)).toEqual([
|
|
1588
|
+
"Rollback is one edit, not a revert",
|
|
1589
|
+
"Leaves a config key nobody removes",
|
|
1590
|
+
])
|
|
1591
|
+
expect(second!.text).toBe("Ship it directly")
|
|
1592
|
+
expect(second!.body).toEqual([])
|
|
1593
|
+
})
|
|
1594
|
+
|
|
1595
|
+
it("a second paragraph under an option parses into its body the same way a nested list does", () => {
|
|
1596
|
+
const content = [
|
|
1597
|
+
"## Open Questions",
|
|
1598
|
+
"",
|
|
1599
|
+
"### Which option?",
|
|
1600
|
+
"",
|
|
1601
|
+
"- [ ] Ship it behind a flag",
|
|
1602
|
+
"",
|
|
1603
|
+
" A second paragraph of impacts, indented under the option.",
|
|
1604
|
+
"- [ ] Ship it directly",
|
|
1605
|
+
"",
|
|
1606
|
+
].join("\n")
|
|
1607
|
+
const [first] = parseOpenQuestions(content).questions[0]!.options
|
|
1608
|
+
expect(first!.body.map((n) => n.title)).toEqual([
|
|
1609
|
+
"A second paragraph of impacts, indented under the option.",
|
|
1610
|
+
])
|
|
1611
|
+
})
|
|
1612
|
+
|
|
1613
|
+
it("a nested plain bullet never becomes a sub-option — optionListItems is unchanged", () => {
|
|
1614
|
+
const content = [
|
|
1615
|
+
"## Open Questions",
|
|
1616
|
+
"",
|
|
1617
|
+
"### Which option?",
|
|
1618
|
+
"",
|
|
1619
|
+
"- [ ] Ship it behind a flag",
|
|
1620
|
+
" - Rollback is one edit, not a revert",
|
|
1621
|
+
"- [ ] Ship it directly",
|
|
1622
|
+
"",
|
|
1623
|
+
].join("\n")
|
|
1624
|
+
expect(parseOpenQuestions(content).questions[0]!.options.map((o) => o.text)).toEqual([
|
|
1625
|
+
"Ship it behind a flag",
|
|
1626
|
+
"Ship it directly",
|
|
1627
|
+
])
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1630
|
+
it("endLine already spans the nested content, so a footnote marker written inside an option's impacts resolves to that option", () => {
|
|
1631
|
+
const content = [
|
|
1632
|
+
"## Open Questions",
|
|
1633
|
+
"",
|
|
1634
|
+
"### Which option?",
|
|
1635
|
+
"",
|
|
1636
|
+
"- [ ] Ship it behind a flag",
|
|
1637
|
+
" - Rollback is one edit, not a revert[^fn1]",
|
|
1638
|
+
"- [ ] Ship it directly",
|
|
1639
|
+
"",
|
|
1640
|
+
"[^fn1]: a note on the impact itself",
|
|
1641
|
+
"",
|
|
1642
|
+
].join("\n")
|
|
1643
|
+
const outline = qa.outline(content)
|
|
1644
|
+
const option = outline[0]!.children![0]!
|
|
1645
|
+
expect(option.children!.some((c) => c.name.includes("a note on the impact itself"))).toBe(true)
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1648
|
+
it("a free-text save leaves every other option's nested impacts byte-identical, and moves nothing outside the saved option's own first paragraph", () => {
|
|
1649
|
+
// The WRITTEN option (index 2, the free-text slot) itself carries a
|
|
1650
|
+
// nested bullet under its label — the one shape in which widening
|
|
1651
|
+
// `optionTextSpan`'s `end` from the first paragraph to the whole list
|
|
1652
|
+
// item (the exact corruption its own doc comment and the Requirement's
|
|
1653
|
+
// Risk line call out) would delete something THIS test can catch: a
|
|
1654
|
+
// regression there would swallow "a nested note the human left before
|
|
1655
|
+
// writing an answer" into the replacement text or delete it outright.
|
|
1656
|
+
const content = [
|
|
1657
|
+
"## Open Questions",
|
|
1658
|
+
"",
|
|
1659
|
+
"### Which option?",
|
|
1660
|
+
"",
|
|
1661
|
+
"- [ ] Ship it behind a flag",
|
|
1662
|
+
" - Rollback is one edit, not a revert",
|
|
1663
|
+
" - Leaves a config key nobody removes",
|
|
1664
|
+
"- [ ] Ship it directly",
|
|
1665
|
+
" - No rollback path at all",
|
|
1666
|
+
`- [ ] ${FREE_TEXT_PLACEHOLDER}`,
|
|
1667
|
+
" - a nested note the human left before writing an answer",
|
|
1668
|
+
"",
|
|
1669
|
+
].join("\n")
|
|
1670
|
+
const result = qa.apply(
|
|
1671
|
+
content,
|
|
1672
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
1673
|
+
{ checked: true, text: "go with the flag" },
|
|
1674
|
+
)
|
|
1675
|
+
expect(result.ok).toBe(true)
|
|
1676
|
+
if (!result.ok) return
|
|
1677
|
+
const applied = applyEditsLocal(content, result.edits)
|
|
1678
|
+
// One `toBe` against the WHOLE document, not a handful of `toContain`
|
|
1679
|
+
// substring checks: only the free-text slot's own label line changes
|
|
1680
|
+
// (`- [ ] _your answer_` → `- [x] go with the flag`) — every other byte,
|
|
1681
|
+
// every other option's nested content, and the written option's OWN
|
|
1682
|
+
// nested bullet all stay exactly where they were.
|
|
1683
|
+
expect(applied).toBe(
|
|
1684
|
+
[
|
|
1685
|
+
"## Open Questions",
|
|
1686
|
+
"",
|
|
1687
|
+
"### Which option?",
|
|
1688
|
+
"",
|
|
1689
|
+
"- [ ] Ship it behind a flag",
|
|
1690
|
+
" - Rollback is one edit, not a revert",
|
|
1691
|
+
" - Leaves a config key nobody removes",
|
|
1692
|
+
"- [ ] Ship it directly",
|
|
1693
|
+
" - No rollback path at all",
|
|
1694
|
+
"- [x] go with the flag",
|
|
1695
|
+
" - a nested note the human left before writing an answer",
|
|
1696
|
+
"",
|
|
1697
|
+
].join("\n"),
|
|
1698
|
+
)
|
|
1699
|
+
})
|
|
1700
|
+
|
|
1701
|
+
it("the view carries an option's impacts on its own node's body, and the free-text slot's option node carries no body", () => {
|
|
1702
|
+
const content = [
|
|
1703
|
+
"## Open Questions",
|
|
1704
|
+
"",
|
|
1705
|
+
"### Which option?",
|
|
1706
|
+
"",
|
|
1707
|
+
"- [ ] Ship it behind a flag",
|
|
1708
|
+
" - Rollback is one edit, not a revert",
|
|
1709
|
+
`- [ ] ${FREE_TEXT_PLACEHOLDER}`,
|
|
1710
|
+
"",
|
|
1711
|
+
].join("\n")
|
|
1712
|
+
const question = qa.view(content).nodes.find((n) => n.status !== undefined)!
|
|
1713
|
+
const [first, freeText] = question.children!
|
|
1714
|
+
expect(first!.body!.map((n) => n.title)).toEqual(["Rollback is one edit, not a revert"])
|
|
1715
|
+
expect(freeText!.body).toBeUndefined()
|
|
1716
|
+
})
|
|
1717
|
+
|
|
1718
|
+
it("the outline's option node carries its impacts' collapsed text as detail, with no extra node per impact", () => {
|
|
1719
|
+
const content = [
|
|
1720
|
+
"## Open Questions",
|
|
1721
|
+
"",
|
|
1722
|
+
"### Which option?",
|
|
1723
|
+
"",
|
|
1724
|
+
"- [ ] Ship it behind a flag",
|
|
1725
|
+
" - Rollback is one edit, not a revert",
|
|
1726
|
+
" - Leaves a config key nobody removes",
|
|
1727
|
+
"- [ ] Ship it directly",
|
|
1728
|
+
"",
|
|
1729
|
+
].join("\n")
|
|
1730
|
+
const outline = qa.outline(content)
|
|
1731
|
+
const [first, second] = outline[0]!.children!
|
|
1732
|
+
expect(first!.detail).toBe(
|
|
1733
|
+
"Rollback is one edit, not a revert Leaves a config key nobody removes",
|
|
1734
|
+
)
|
|
1735
|
+
expect(first!.leaf).toBe(true)
|
|
1736
|
+
expect(first!.children).toBeUndefined()
|
|
1737
|
+
expect(second!.detail).toBeUndefined()
|
|
1738
|
+
})
|
|
1739
|
+
})
|
|
1740
|
+
|
|
1741
|
+
describe("qa.annotate", () => {
|
|
1742
|
+
const CONTENT = ["## Open Questions", "", "### First?", "", "- [ ] Option A", ""].join("\n")
|
|
1743
|
+
|
|
1744
|
+
it("accepts a question anchor", () => {
|
|
1745
|
+
expect(qa.annotate(CONTENT, { kind: "question", index: 0 }, "a real note").ok).toBe(true)
|
|
1746
|
+
})
|
|
1747
|
+
|
|
1748
|
+
it("annotates the heading of an answered question, definition after the entry", () => {
|
|
1749
|
+
const content = [
|
|
1750
|
+
"## Open Questions",
|
|
1751
|
+
"",
|
|
1752
|
+
"### First?",
|
|
1753
|
+
"",
|
|
1754
|
+
"- [ ] Option A",
|
|
1755
|
+
"",
|
|
1756
|
+
"## Answered Questions",
|
|
1757
|
+
"",
|
|
1758
|
+
"### Second?",
|
|
1759
|
+
"",
|
|
1760
|
+
"- [x] Option B",
|
|
1761
|
+
"",
|
|
1762
|
+
].join("\n")
|
|
1763
|
+
const result = qa.annotate(content, { kind: "question", index: 1 }, "reconsider this")
|
|
1764
|
+
expect(result.ok).toBe(true)
|
|
1765
|
+
if (!result.ok) return
|
|
1766
|
+
const lines = applyEditsLocal(content, result.edits).split("\n")
|
|
1767
|
+
const heading = lines.findIndex((l) => l.startsWith("### Second?"))
|
|
1768
|
+
expect(lines[heading]).toMatch(/\[\^[^\]]+\]$/)
|
|
1769
|
+
expect(lines[heading - 1]).not.toMatch(/\[\^/)
|
|
1770
|
+
const def = lines.findIndex((l) => /^\[\^[^\]]+\]:/.test(l))
|
|
1771
|
+
expect(def).toBeGreaterThan(heading)
|
|
1772
|
+
expect(lines.slice(def).join("\n")).toContain("reconsider this")
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
it("accepts an option anchor", () => {
|
|
1776
|
+
expect(
|
|
1777
|
+
qa.annotate(CONTENT, { kind: "option", questionIndex: 0, index: 0 }, "a real note").ok,
|
|
1778
|
+
).toBe(true)
|
|
1779
|
+
})
|
|
1780
|
+
|
|
1781
|
+
it("accepts a paragraph anchor in a prose-only document", () => {
|
|
1782
|
+
expect(
|
|
1783
|
+
qa.annotate("Just some prose.\n", { kind: "paragraph", line: 0 }, "a real note").ok,
|
|
1784
|
+
).toBe(true)
|
|
1785
|
+
})
|
|
1786
|
+
|
|
1787
|
+
it("rejects an anchor that no longer resolves, rather than silently dropping it", () => {
|
|
1788
|
+
expect(qa.annotate(CONTENT, { kind: "question", index: 5 }, "note")).toEqual({
|
|
1789
|
+
ok: false,
|
|
1790
|
+
reason: "anchor-not-found",
|
|
1791
|
+
})
|
|
1792
|
+
expect(qa.annotate(CONTENT, { kind: "chunk", index: 0 }, "note")).toEqual({
|
|
1793
|
+
ok: false,
|
|
1794
|
+
reason: "anchor-not-found",
|
|
1795
|
+
})
|
|
1796
|
+
})
|
|
1797
|
+
|
|
1798
|
+
it("attaches the given text verbatim, and the resulting document passes its own format's validator (T2's last criterion)", () => {
|
|
1799
|
+
const result = qa.annotate(
|
|
1800
|
+
CONTENT,
|
|
1801
|
+
{ kind: "option", questionIndex: 0, index: 0 },
|
|
1802
|
+
"a real reason a human actually typed",
|
|
1803
|
+
)
|
|
1804
|
+
expect(result.ok).toBe(true)
|
|
1805
|
+
if (!result.ok) return
|
|
1806
|
+
const lines = CONTENT.split("\n")
|
|
1807
|
+
const toOffset = (pos: { readonly line: number; readonly character: number }): number => {
|
|
1808
|
+
let offset = 0
|
|
1809
|
+
for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
1810
|
+
return offset + pos.character
|
|
1811
|
+
}
|
|
1812
|
+
const sorted = [...result.edits].sort(
|
|
1813
|
+
(a, b) => toOffset(b.range.start) - toOffset(a.range.start),
|
|
1814
|
+
)
|
|
1815
|
+
let applied = CONTENT
|
|
1816
|
+
for (const edit of sorted) {
|
|
1817
|
+
applied =
|
|
1818
|
+
applied.slice(0, toOffset(edit.range.start)) +
|
|
1819
|
+
edit.newText +
|
|
1820
|
+
applied.slice(toOffset(edit.range.end))
|
|
1821
|
+
}
|
|
1822
|
+
expect(applied).toContain("a real reason a human actually typed")
|
|
1823
|
+
// qa.validate below already proves this raises no "has an empty body"
|
|
1824
|
+
// finding — stronger than checking for the retired placeholder text.
|
|
1825
|
+
expect(qa.validate(applied)).toEqual([])
|
|
1826
|
+
})
|
|
1827
|
+
})
|
|
1828
|
+
|
|
1829
|
+
/** Applies edits back-to-front (as `ui/Write.ts#applySteeringEdits` does) — local to this test file so `apply` tests can assert on the resulting document text, mirroring the `annotate` describe block above's own inline `toOffset`/splice pattern. */
|
|
1830
|
+
const applyEdits = (
|
|
1831
|
+
content: string,
|
|
1832
|
+
edits: readonly {
|
|
1833
|
+
readonly range: {
|
|
1834
|
+
readonly start: { readonly line: number; readonly character: number }
|
|
1835
|
+
readonly end: { readonly line: number; readonly character: number }
|
|
1836
|
+
}
|
|
1837
|
+
readonly newText: string
|
|
1838
|
+
}[],
|
|
1839
|
+
): string => {
|
|
1840
|
+
const lines = content.split("\n")
|
|
1841
|
+
const toOffset = (pos: { readonly line: number; readonly character: number }): number => {
|
|
1842
|
+
let offset = 0
|
|
1843
|
+
for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
1844
|
+
return offset + pos.character
|
|
1845
|
+
}
|
|
1846
|
+
const sorted = [...edits].sort((a, b) => toOffset(b.range.start) - toOffset(a.range.start))
|
|
1847
|
+
let result = content
|
|
1848
|
+
for (const edit of sorted) {
|
|
1849
|
+
result =
|
|
1850
|
+
result.slice(0, toOffset(edit.range.start)) +
|
|
1851
|
+
edit.newText +
|
|
1852
|
+
result.slice(toOffset(edit.range.end))
|
|
1853
|
+
}
|
|
1854
|
+
return result
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
describe("qa.apply", () => {
|
|
1858
|
+
const CONTENT = [
|
|
1859
|
+
"## Open Questions",
|
|
1860
|
+
"",
|
|
1861
|
+
"### First?",
|
|
1862
|
+
"",
|
|
1863
|
+
"- [x] Option A",
|
|
1864
|
+
"- [ ] Option B",
|
|
1865
|
+
`- [ ] ${FREE_TEXT_PLACEHOLDER}`,
|
|
1866
|
+
"",
|
|
1867
|
+
].join("\n")
|
|
1868
|
+
|
|
1869
|
+
it("radio: ticking one option unticks an already-ticked sibling", () => {
|
|
1870
|
+
const result = qa.apply(
|
|
1871
|
+
CONTENT,
|
|
1872
|
+
{ kind: "option", questionIndex: 0, index: 1 },
|
|
1873
|
+
{
|
|
1874
|
+
checked: true,
|
|
1875
|
+
},
|
|
1876
|
+
)
|
|
1877
|
+
expect(result.ok).toBe(true)
|
|
1878
|
+
if (!result.ok) return
|
|
1879
|
+
const applied = applyEdits(CONTENT, result.edits)
|
|
1880
|
+
const { questions } = parseOpenQuestions(applied)
|
|
1881
|
+
expect(questions[0]!.options.map((o) => o.checked)).toEqual([false, true, false])
|
|
1882
|
+
})
|
|
1883
|
+
|
|
1884
|
+
it("ticking the free-text slot with text sets both the tick and the label in one edit set", () => {
|
|
1885
|
+
const result = qa.apply(
|
|
1886
|
+
CONTENT,
|
|
1887
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
1888
|
+
{
|
|
1889
|
+
checked: true,
|
|
1890
|
+
text: "my real answer",
|
|
1891
|
+
},
|
|
1892
|
+
)
|
|
1893
|
+
expect(result.ok).toBe(true)
|
|
1894
|
+
if (!result.ok) return
|
|
1895
|
+
const applied = applyEdits(CONTENT, result.edits)
|
|
1896
|
+
const { questions } = parseOpenQuestions(applied)
|
|
1897
|
+
expect(questions[0]!.options[2]).toMatchObject({ checked: true, text: "my real answer" })
|
|
1898
|
+
// radio semantics fired too: Option A (already ticked) is now unticked
|
|
1899
|
+
expect(questions[0]!.options[0]!.checked).toBe(false)
|
|
1900
|
+
})
|
|
1901
|
+
|
|
1902
|
+
it("trims trailing whitespace off a typed free-text answer, so the written label stays an oxfmt fixed point", () => {
|
|
1903
|
+
const result = qa.apply(
|
|
1904
|
+
CONTENT,
|
|
1905
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
1906
|
+
{
|
|
1907
|
+
checked: true,
|
|
1908
|
+
text: "my real answer ",
|
|
1909
|
+
},
|
|
1910
|
+
)
|
|
1911
|
+
expect(result.ok).toBe(true)
|
|
1912
|
+
if (!result.ok) return
|
|
1913
|
+
const applied = applyEdits(CONTENT, result.edits)
|
|
1914
|
+
const { questions } = parseOpenQuestions(applied)
|
|
1915
|
+
expect(questions[0]!.options[2]).toMatchObject({ checked: true, text: "my real answer" })
|
|
1916
|
+
expect(applied).not.toMatch(/ +\n/)
|
|
1917
|
+
})
|
|
1918
|
+
|
|
1919
|
+
it("collapses interior newlines in a typed free-text answer to a single line, so the written label stays an oxfmt fixed point", () => {
|
|
1920
|
+
const result = qa.apply(
|
|
1921
|
+
CONTENT,
|
|
1922
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
1923
|
+
{
|
|
1924
|
+
checked: true,
|
|
1925
|
+
text: "line one\nline two",
|
|
1926
|
+
},
|
|
1927
|
+
)
|
|
1928
|
+
expect(result.ok).toBe(true)
|
|
1929
|
+
if (!result.ok) return
|
|
1930
|
+
const applied = applyEdits(CONTENT, result.edits)
|
|
1931
|
+
const { questions } = parseOpenQuestions(applied)
|
|
1932
|
+
expect(questions[0]!.options[2]).toMatchObject({ checked: true, text: "line one line two" })
|
|
1933
|
+
// The option's own line count must stay exactly one — a real newline
|
|
1934
|
+
// spliced into the label would split it into two markdown lines, one of
|
|
1935
|
+
// them unindented free-floating text no longer inside the list item.
|
|
1936
|
+
expect(applied.split("\n").filter((line) => line.includes("line one"))).toHaveLength(1)
|
|
1937
|
+
})
|
|
1938
|
+
|
|
1939
|
+
it("erasing a free-text answer ({checked: false, text: \"\"}) restores the placeholder, and the option is still writable afterward — never a dead 'anchor-not-found' anchor", () => {
|
|
1940
|
+
// Seed a question whose free-text slot already carries a real typed
|
|
1941
|
+
// answer — mirrors what `ticking the free-text slot with text` above
|
|
1942
|
+
// just wrote.
|
|
1943
|
+
const answered = applyEdits(
|
|
1944
|
+
CONTENT,
|
|
1945
|
+
(() => {
|
|
1946
|
+
const first = qa.apply(
|
|
1947
|
+
CONTENT,
|
|
1948
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
1949
|
+
{ checked: true, text: "my typed answer" },
|
|
1950
|
+
)
|
|
1951
|
+
if (!first.ok) throw new Error("setup apply failed")
|
|
1952
|
+
return first.edits
|
|
1953
|
+
})(),
|
|
1954
|
+
)
|
|
1955
|
+
expect(parseOpenQuestions(answered).questions[0]!.options[2]).toMatchObject({
|
|
1956
|
+
checked: true,
|
|
1957
|
+
text: "my typed answer",
|
|
1958
|
+
})
|
|
1959
|
+
|
|
1960
|
+
const erase = qa.apply(
|
|
1961
|
+
answered,
|
|
1962
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
1963
|
+
{ checked: false, text: "" },
|
|
1964
|
+
)
|
|
1965
|
+
expect(erase.ok).toBe(true)
|
|
1966
|
+
if (!erase.ok) return
|
|
1967
|
+
const erased = applyEdits(answered, erase.edits)
|
|
1968
|
+
// Package 03's own bug: an empty label ("- [ ] ") leaves nothing for
|
|
1969
|
+
// `optionContentOffset` to find, so this must NOT literally write "".
|
|
1970
|
+
expect(erased).toContain(`- [ ] ${FREE_TEXT_PLACEHOLDER}`)
|
|
1971
|
+
expect(parseOpenQuestions(erased).questions[0]!.options[2]).toMatchObject({
|
|
1972
|
+
checked: false,
|
|
1973
|
+
text: "",
|
|
1974
|
+
})
|
|
1975
|
+
|
|
1976
|
+
// The anchor must still resolve — a real retype after an erase, the
|
|
1977
|
+
// round trip a human erasing-then-retyping on the phone depends on.
|
|
1978
|
+
const retype = qa.apply(
|
|
1979
|
+
erased,
|
|
1980
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
1981
|
+
{ checked: true, text: "a new answer" },
|
|
1982
|
+
)
|
|
1983
|
+
expect(retype.ok).toBe(true)
|
|
1984
|
+
if (!retype.ok) return
|
|
1985
|
+
const retyped = applyEdits(erased, retype.edits)
|
|
1986
|
+
expect(parseOpenQuestions(retyped).questions[0]!.options[2]).toMatchObject({
|
|
1987
|
+
checked: true,
|
|
1988
|
+
text: "a new answer",
|
|
1989
|
+
})
|
|
1990
|
+
})
|
|
1991
|
+
|
|
1992
|
+
it("a stale option index refuses anchor-not-found", () => {
|
|
1993
|
+
expect(
|
|
1994
|
+
qa.apply(CONTENT, { kind: "option", questionIndex: 0, index: 99 }, { checked: true }),
|
|
1995
|
+
).toEqual({ ok: false, reason: "anchor-not-found" })
|
|
1996
|
+
})
|
|
1997
|
+
|
|
1998
|
+
it("a hunk/chunk anchor — not this format's own kind — refuses anchor-not-found", () => {
|
|
1999
|
+
expect(qa.apply(CONTENT, { kind: "chunk", index: 0 }, { checked: true })).toEqual({
|
|
2000
|
+
ok: false,
|
|
2001
|
+
reason: "anchor-not-found",
|
|
2002
|
+
})
|
|
2003
|
+
expect(qa.apply(CONTENT, { kind: "hunk", chunkIndex: 0, index: 0 }, { checked: true })).toEqual(
|
|
2004
|
+
{ ok: false, reason: "anchor-not-found" },
|
|
2005
|
+
)
|
|
2006
|
+
})
|
|
2007
|
+
})
|
|
2008
|
+
|
|
2009
|
+
describe("qa — Task 4: the real projection over an 80-column-wrapped design document", () => {
|
|
2010
|
+
// Hand-wrapped at 80 columns, the exact shape oxfmt's own hard wrap
|
|
2011
|
+
// produces for a question body and an option written in this workflow's
|
|
2012
|
+
// prescribed prose style — the first document this suite feeds through
|
|
2013
|
+
// the REAL `qa.view`/`parseOpenQuestions`, rather than a hand-built node.
|
|
2014
|
+
const CONTENT = [
|
|
2015
|
+
"## Open Questions",
|
|
2016
|
+
"",
|
|
2017
|
+
"### Which storage backend should the new cache layer use?",
|
|
2018
|
+
"",
|
|
2019
|
+
"The cache needs to survive a process restart without losing recently",
|
|
2020
|
+
"written entries, and it must stay readable by every worker process at",
|
|
2021
|
+
"once rather than being pinned to whichever one wrote it.",
|
|
2022
|
+
"",
|
|
2023
|
+
"- [ ] Redis, since it already runs in every environment this service",
|
|
2024
|
+
" deploys to and the team already operates it for the session",
|
|
2025
|
+
" store, so there is no new infrastructure to stand up.",
|
|
2026
|
+
"- [ ] SQLite on a shared volume, trading some write concurrency for",
|
|
2027
|
+
" zero additional infrastructure and a format every worker can",
|
|
2028
|
+
" already read directly off disk.",
|
|
2029
|
+
`- [ ] ${FREE_TEXT_PLACEHOLDER}`,
|
|
2030
|
+
"",
|
|
2031
|
+
].join("\n")
|
|
2032
|
+
|
|
2033
|
+
it("the question body and every option render complete on the question screen (view.nodes[].body), not merely the first source line", () => {
|
|
2034
|
+
const question = qa.view(CONTENT).nodes.find((n) => n.status !== undefined)!
|
|
2035
|
+
expect(question.body).toEqual([
|
|
2036
|
+
expect.objectContaining({
|
|
2037
|
+
title:
|
|
2038
|
+
"The cache needs to survive a process restart without losing recently written entries, and it must stay readable by every worker process at once rather than being pinned to whichever one wrote it.",
|
|
2039
|
+
}),
|
|
2040
|
+
])
|
|
2041
|
+
expect(question.detail).toBe(question.body![0]!.title)
|
|
2042
|
+
expect(question.children!.map((o) => o.title)).toEqual([
|
|
2043
|
+
"Redis, since it already runs in every environment this service deploys to and the team already operates it for the session store, so there is no new infrastructure to stand up.",
|
|
2044
|
+
"SQLite on a shared volume, trading some write concurrency for zero additional infrastructure and a format every worker can already read directly off disk.",
|
|
2045
|
+
"",
|
|
2046
|
+
])
|
|
2047
|
+
})
|
|
2048
|
+
|
|
2049
|
+
it("a free-text answer long enough to wrap survives a reopen-and-save round trip byte-for-byte apart from the answer itself", () => {
|
|
2050
|
+
const longAnswer =
|
|
2051
|
+
"Go with Redis for now — it is already deployed everywhere this service runs and the team has years of operational experience keeping it healthy, so there is nothing new to learn."
|
|
2052
|
+
const firstSave = qa.apply(
|
|
2053
|
+
CONTENT,
|
|
2054
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
2055
|
+
{ checked: true, text: longAnswer },
|
|
2056
|
+
)
|
|
2057
|
+
expect(firstSave.ok).toBe(true)
|
|
2058
|
+
if (!firstSave.ok) return
|
|
2059
|
+
const afterFirstSave = applyEdits(CONTENT, firstSave.edits)
|
|
2060
|
+
const reopened = parseOpenQuestions(afterFirstSave).questions[0]!.options[2]!
|
|
2061
|
+
expect(reopened.text).toBe(longAnswer)
|
|
2062
|
+
|
|
2063
|
+
const newAnswer = "Actually, SQLite on the shared volume — one less service to operate."
|
|
2064
|
+
const secondSave = qa.apply(
|
|
2065
|
+
afterFirstSave,
|
|
2066
|
+
{ kind: "option", questionIndex: 0, index: 2 },
|
|
2067
|
+
{ checked: true, text: newAnswer },
|
|
2068
|
+
)
|
|
2069
|
+
expect(secondSave.ok).toBe(true)
|
|
2070
|
+
if (!secondSave.ok) return
|
|
2071
|
+
const afterSecondSave = applyEdits(afterFirstSave, secondSave.edits)
|
|
2072
|
+
const final = parseOpenQuestions(afterSecondSave).questions[0]!.options[2]!
|
|
2073
|
+
expect(final.text).toBe(newAnswer)
|
|
2074
|
+
expect(afterSecondSave).not.toContain("years of operational experience")
|
|
2075
|
+
expect(afterSecondSave).not.toContain("nothing new to learn")
|
|
2076
|
+
})
|
|
2077
|
+
})
|
|
2078
|
+
|
|
2079
|
+
const THREAD_QA_DOC = (agentLast: boolean): string =>
|
|
2080
|
+
[
|
|
2081
|
+
"## Open Questions",
|
|
2082
|
+
"",
|
|
2083
|
+
"### Which one?",
|
|
2084
|
+
"",
|
|
2085
|
+
"- [ ] A [^t1]",
|
|
2086
|
+
"- [ ] B",
|
|
2087
|
+
"",
|
|
2088
|
+
"[^t1]:",
|
|
2089
|
+
" - H: why A?",
|
|
2090
|
+
...(agentLast ? [" - A: because", " wrapped on two lines"] : []),
|
|
2091
|
+
"",
|
|
2092
|
+
"[^once]: one-shot note",
|
|
2093
|
+
"",
|
|
2094
|
+
].join("\n")
|
|
2095
|
+
|
|
2096
|
+
describe("qa — thread outline", () => {
|
|
2097
|
+
it("qa nests a thread under the option holding its first marker, with waiting-on detail", () => {
|
|
2098
|
+
const outline = qa.outline(THREAD_QA_DOC(true))
|
|
2099
|
+
const option = outline[0]!.children!.find((c) => c.name.includes("A"))!
|
|
2100
|
+
const thread = option.children!.find((c) => c.name === "[^t1]")!
|
|
2101
|
+
expect(thread.detail).toBe("waiting on you")
|
|
2102
|
+
})
|
|
2103
|
+
it("says waiting on the agent when the human spoke last", () => {
|
|
2104
|
+
const outline = qa.outline(THREAD_QA_DOC(false))
|
|
2105
|
+
const option = outline[0]!.children!.find((c) => c.name.includes("A"))!
|
|
2106
|
+
expect(option.children!.find((c) => c.name === "[^t1]")!.detail).toBe("waiting on the agent")
|
|
2107
|
+
})
|
|
2108
|
+
it("a one-shot footnote adds no thread node", () => {
|
|
2109
|
+
const names = JSON.stringify(qa.outline(THREAD_QA_DOC(true)))
|
|
2110
|
+
expect(names).not.toContain('"[^once]"')
|
|
2111
|
+
})
|
|
2112
|
+
})
|
|
2113
|
+
|
|
2114
|
+
describe("qa view — threads", () => {
|
|
2115
|
+
const doc = [
|
|
2116
|
+
"Intro prose.[^t1]",
|
|
2117
|
+
"",
|
|
2118
|
+
"## Open Questions",
|
|
2119
|
+
"",
|
|
2120
|
+
"### Which option?[^t2]",
|
|
2121
|
+
"",
|
|
2122
|
+
"- [ ] Option A",
|
|
2123
|
+
"- [ ] _your answer_",
|
|
2124
|
+
"",
|
|
2125
|
+
"[^t1]:",
|
|
2126
|
+
" - H: prose ask",
|
|
2127
|
+
" - A: why?",
|
|
2128
|
+
"",
|
|
2129
|
+
"[^t2]:",
|
|
2130
|
+
" - H: q ask",
|
|
2131
|
+
"",
|
|
2132
|
+
].join("\n")
|
|
2133
|
+
|
|
2134
|
+
it("a prose block and a question with a thread expose `thread` and no `note`", () => {
|
|
2135
|
+
const nodes = steeringFormatFor("qa")!.view(doc).nodes
|
|
2136
|
+
const prose = nodes[0]!
|
|
2137
|
+
expect(prose.note).toBeUndefined()
|
|
2138
|
+
expect(prose.thread).toEqual({
|
|
2139
|
+
name: "t1",
|
|
2140
|
+
entries: [
|
|
2141
|
+
{ author: "me", text: "prose ask" },
|
|
2142
|
+
{ author: "agent", text: "why?" },
|
|
2143
|
+
],
|
|
2144
|
+
waitingOn: "human",
|
|
2145
|
+
})
|
|
2146
|
+
const question = nodes.find((n) => n.anchor.kind === "question")!
|
|
2147
|
+
expect(question.note).toBeUndefined()
|
|
2148
|
+
expect(question.thread).toEqual({
|
|
2149
|
+
name: "t2",
|
|
2150
|
+
entries: [{ author: "me", text: "q ask" }],
|
|
2151
|
+
waitingOn: "agent",
|
|
2152
|
+
})
|
|
2153
|
+
})
|
|
2154
|
+
|
|
2155
|
+
it("a one-shot footnote on a prose block still fills `note`", () => {
|
|
2156
|
+
const nodes = steeringFormatFor("qa")!.view("Intro.[^n1]\n\n[^n1]: plain\n").nodes
|
|
2157
|
+
expect(nodes[0]!.note).toBe("plain")
|
|
2158
|
+
expect(nodes[0]!.thread).toBeUndefined()
|
|
2159
|
+
})
|
|
2160
|
+
})
|