reqdeck 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- reqdeck-0.2.0/CHANGELOG.md +54 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/PKG-INFO +68 -3
- {reqdeck-0.1.0 → reqdeck-0.2.0}/README.md +66 -1
- {reqdeck-0.1.0 → reqdeck-0.2.0}/pyproject.toml +2 -2
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/__init__.py +1 -1
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/cli.py +20 -11
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/config.py +23 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/init.py +6 -1
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/model.py +118 -7
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/README.md +2 -2
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/viewer.py +123 -9
- {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-cli/SKILL.md +23 -2
- {reqdeck-0.1.0 → reqdeck-0.2.0}/tests/test_reqdeck.py +88 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/tests/test_tui.py +39 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/.gitignore +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/LICENSE +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/.reqdeck.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/README.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/Sources/ReminderScheduler.swift +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/Sources/TaskItem.swift +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/Tests/ReminderSchedulerTests.swift +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/OPEN-QUESTIONS.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/product-brief.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/app.csv +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/app.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/if-sync.csv +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/if-sync.details.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/if-sync.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/stories.csv +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/stories.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/system.csv +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/system.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/widget.csv +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/widget.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/__main__.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/editor.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/markers.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/ops.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/questions.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/render.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/store.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/templates/OPEN-QUESTIONS.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/__init__.py +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/atom-one-dark.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/atom-one-light.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-frappe.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-latte.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-macchiato.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-mocha.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/dracula.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/flexoki.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/gruvbox.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/monokai.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/nord.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/rose-pine-dawn.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/rose-pine-moon.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/rose-pine.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/solarized-dark.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/solarized-light.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/textual-dark.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/textual-light.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/tokyo-night.toml +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-method/SKILL.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-method/references/markers.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-method/references/writing-requirements.md +0 -0
- {reqdeck-0.1.0 → reqdeck-0.2.0}/tests/test_init.py +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
What changed in each release of reqdeck. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
Every change a user would notice gets a line under **Unreleased**, in the commit that makes
|
|
8
|
+
it. To release: rename **Unreleased** to the version and date, open a new empty
|
|
9
|
+
**Unreleased** above it, bump the version in `reqdeck/__init__.py` and
|
|
10
|
+
`.claude-plugin/plugin.json`, re-record the README's media with `scripts/record-demo.sh`,
|
|
11
|
+
then tag `vX.Y.Z` — and paste the section into the GitHub release.
|
|
12
|
+
|
|
13
|
+
## [Unreleased]
|
|
14
|
+
|
|
15
|
+
## [0.2.0] - 2026-10-17
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- The TUI reloads from disk every 30 seconds, so what an agent or an editor writes shows up
|
|
20
|
+
on its own. `Reloading` spins at the left of the hint line while it runs; a reload that
|
|
21
|
+
finds nothing changed redraws nothing, and one that does keeps the reading pane where it
|
|
22
|
+
was scrolled. `"reload"` in `~/.config/reqdeck/tui.json` sets the seconds, `0` turns it off.
|
|
23
|
+
- Every requirement shows how far it is built, beside its review status: Done, Untested,
|
|
24
|
+
Partial, Not implemented or Unsatisfied, judged over it and everything that satisfies it, so
|
|
25
|
+
a user story is Done only once its whole chain is. The tree marks it `✓` `◑` `◔` `○` `✗`, the
|
|
26
|
+
card's title carries it, the level view counts the Done per unit, and `reqdeck get` prints
|
|
27
|
+
it with what keeps it from Done — `progress` in `--json`.
|
|
28
|
+
- `satisfied`, `code` and `test` on a `[[levels]]` entry set what that level needs before it is
|
|
29
|
+
done. Unset, they follow from `satisfies`, the unit's `code` and the verification method, as
|
|
30
|
+
before. `code = true` or `test = true` on a `traced = false` level is now allowed, met by
|
|
31
|
+
markers on every requirement beneath it.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- A requirement that must both be satisfied from below and named by code is done either way:
|
|
36
|
+
broken down, with code naming every requirement beneath it, or implemented as it stands,
|
|
37
|
+
with code naming it. Before, it needed its own code marker and something satisfying it — an
|
|
38
|
+
interface requirement with code but nothing satisfying it was a gap, and so was one whose
|
|
39
|
+
code sat on the software requirements beneath it.
|
|
40
|
+
- A test beneath a requirement counts for it only where its level must be satisfied, and then
|
|
41
|
+
every requirement beneath it needs one — as for code. Before, one test anywhere beneath was
|
|
42
|
+
enough, so a system requirement with one tested and one untested software requirement
|
|
43
|
+
beneath it counted as tested.
|
|
44
|
+
- `r` reloads in the background with the same spinner, instead of freezing the TUI while it
|
|
45
|
+
reads. A file that fails to load is reported once, not on every reload.
|
|
46
|
+
|
|
47
|
+
## [0.1.0] - 2026-10-10
|
|
48
|
+
|
|
49
|
+
First release: CSV requirements and open questions in your repository, `@REQ_REF` markers
|
|
50
|
+
in code and tests, the `reqdeck` CLI and TUI, and the skills for Claude Code.
|
|
51
|
+
|
|
52
|
+
[Unreleased]: https://github.com/danielriege/reqdeck/compare/v0.2.0...HEAD
|
|
53
|
+
[0.2.0]: https://github.com/danielriege/reqdeck/compare/v0.1.0...v0.2.0
|
|
54
|
+
[0.1.0]: https://github.com/danielriege/reqdeck/releases/tag/v0.1.0
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: reqdeck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Requirements, open questions and traceability in your git repo — a CLI, a TUI, and skills for your AI agent
|
|
5
5
|
Project-URL: Homepage, https://github.com/danielriege/reqdeck
|
|
6
6
|
Project-URL: Source, https://github.com/danielriege/reqdeck
|
|
7
7
|
Project-URL: Issues, https://github.com/danielriege/reqdeck/issues
|
|
8
|
-
Project-URL: Changelog, https://github.com/danielriege/reqdeck/
|
|
8
|
+
Project-URL: Changelog, https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md
|
|
9
9
|
Author: Daniel Riege
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE
|
|
@@ -32,7 +32,7 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
Think of <a href="https://www.ibm.com/products/requirements-management">IBM DOORS</a>, but it lives in your git repo, fits in your terminal, and your AI agent can read it.
|
|
33
33
|
<br><br>
|
|
34
34
|
|
|
35
|
-
<a href="https://github.com/danielriege/reqdeck/actions/workflows/
|
|
35
|
+
<a href="https://github.com/danielriege/reqdeck/actions/workflows/release.yml"><img src="https://github.com/danielriege/reqdeck/actions/workflows/release.yml/badge.svg" alt="Release"></a>
|
|
36
36
|
<img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+">
|
|
37
37
|
<a href="https://pypi.org/project/reqdeck/"><img src="https://img.shields.io/pypi/v/reqdeck" alt="PyPI"></a>
|
|
38
38
|
<a href="https://github.com/danielriege/reqdeck/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
|
|
@@ -158,6 +158,11 @@ verification method fits, where a marker goes.
|
|
|
158
158
|
status, statement, detail tables and fields — beside the open questions by topic. The tree
|
|
159
159
|
always has the cursor; the right pane shows what it highlights.
|
|
160
160
|
|
|
161
|
+
Beside each requirement in the tree are two marks. The letter is its review status: `A`ccepted,
|
|
162
|
+
`P`roposed, `R`eopened. The symbol is how far it is built, judged over it and everything that
|
|
163
|
+
satisfies it: `✓` done, `◑` untested, `◔` partial, `○` not implemented, `✗` unsatisfied. The
|
|
164
|
+
card says which requirements beneath it keep it from done, and `reqdeck get` prints the same.
|
|
165
|
+
|
|
161
166
|
| | |
|
|
162
167
|
|---|---|
|
|
163
168
|
| `j` `k` `h` `l` · `gg` `G` | Move through the tree, as in vim |
|
|
@@ -183,6 +188,11 @@ Flexoki), each given a full colour suite by a file in [reqdeck/themes/](https://
|
|
|
183
188
|
where you can add your own. Your theme and sidebar width are remembered in
|
|
184
189
|
`~/.config/reqdeck/tui.json`.
|
|
185
190
|
|
|
191
|
+
It reloads from disk every 30 seconds, so what an agent or your editor writes shows up on its
|
|
192
|
+
own — `Reloading` spins at the left of the hint line while it does, and only a change redraws
|
|
193
|
+
anything. `r` reloads at once. Set `"reload"` in `tui.json` to the seconds between reloads, or
|
|
194
|
+
`0` to turn it off.
|
|
195
|
+
|
|
186
196
|
## The CLI
|
|
187
197
|
|
|
188
198
|
```bash
|
|
@@ -264,6 +274,7 @@ traced = false
|
|
|
264
274
|
kind = "SW"
|
|
265
275
|
satisfies = ["SYS"]
|
|
266
276
|
topic = true
|
|
277
|
+
code = true # what it needs before it is done — see below
|
|
267
278
|
|
|
268
279
|
[verification.T]
|
|
269
280
|
name = "Test"
|
|
@@ -282,6 +293,51 @@ command = "nvim +{line} {file}" # or "code --goto {file}:{line}"
|
|
|
282
293
|
Without `[verification]` the methods are A, I, D and T. Without `[markers]` every common
|
|
283
294
|
source file is scanned and a marker may sit anywhere.
|
|
284
295
|
|
|
296
|
+
### What a level needs
|
|
297
|
+
|
|
298
|
+
Three keys on a `[[levels]]` entry say what each of its requirements needs before it is done.
|
|
299
|
+
Each is `true` or `false`; left out, it takes the default in the last column, which is how
|
|
300
|
+
reqdeck behaved before the keys existed.
|
|
301
|
+
|
|
302
|
+
| Key | Met when | Left out |
|
|
303
|
+
|---|---|---|
|
|
304
|
+
| `satisfied` | a requirement of a lower level names it in `satisfies` — **or**, where the level also has `code = true`, a code marker names it itself | `true` if another level lists this one in its `satisfies` |
|
|
305
|
+
| `code` | a code `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by code, or is satisfied by requirements that all are | the unit's `code = true` |
|
|
306
|
+
| `test` | a test `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by a test, or is satisfied by requirements that all are | the verification method's `automated = true` |
|
|
307
|
+
|
|
308
|
+
`code` and `test` follow one rule: **a marker on the requirement itself, or a marker on every
|
|
309
|
+
branch beneath it.** "Beneath" is everything that satisfies it, all the way down, and *every
|
|
310
|
+
branch* means each requirement satisfying it is named by a marker, or is in turn satisfied by
|
|
311
|
+
requirements that all are. One branch without is enough to leave it short. With three
|
|
312
|
+
software requirements satisfying a system requirement and code on two, the system
|
|
313
|
+
requirement is not coded and shows as partial; code on the third makes it coded. Tests are
|
|
314
|
+
counted the same way, separately: code on all three and a test on two leaves it untested.
|
|
315
|
+
|
|
316
|
+
So a level with `satisfied = true` and `code = true` is done in either of two ways:
|
|
317
|
+
|
|
318
|
+
- **Broken down.** Lower requirements satisfy it, and code names every one of them (or every
|
|
319
|
+
one of theirs, where they are broken down further).
|
|
320
|
+
- **Implemented as it stands.** Code names it directly, and nothing needs to satisfy it.
|
|
321
|
+
|
|
322
|
+
`test = true` adds the same again for tests, on top of either way.
|
|
323
|
+
|
|
324
|
+
With `satisfied = false`, only a marker on the requirement itself counts, for `code` and for
|
|
325
|
+
`test`. A level with `traced = false` may never be named by a marker directly, so `code = true`
|
|
326
|
+
or `test = true` there needs `satisfied = true` — the markers can only be beneath it — and
|
|
327
|
+
`check` refuses the config otherwise.
|
|
328
|
+
|
|
329
|
+
`coverage`, `trace`, `get` and the progress marks in the TUI all judge by these same rules.
|
|
330
|
+
|
|
331
|
+
```toml
|
|
332
|
+
[[levels]]
|
|
333
|
+
kind = "TSR" # a technical system requirement
|
|
334
|
+
satisfies = ["STORY"]
|
|
335
|
+
traced = false # never named by code itself...
|
|
336
|
+
satisfied = true # ...so a TSSR or an ICD must satisfy it,
|
|
337
|
+
code = true # and code must name every one of those,
|
|
338
|
+
test = true # and a test must name every one of those too
|
|
339
|
+
```
|
|
340
|
+
|
|
285
341
|
## Limitations
|
|
286
342
|
|
|
287
343
|
- **Small teams.** It is built for one person and their agents, or a handful of people. Two
|
|
@@ -300,6 +356,15 @@ source file is scanned and a marker may sit anywhere.
|
|
|
300
356
|
python3 -m unittest discover tests # the TUI tests skip without textual
|
|
301
357
|
```
|
|
302
358
|
|
|
359
|
+
The demo GIF and the screenshot above are recorded, not drawn: `scripts/record-demo.sh`
|
|
360
|
+
installs this checkout into a throwaway virtualenv and plays `docs/demo.tape` and
|
|
361
|
+
`docs/document.tape` through [vhs](https://github.com/charmbracelet/vhs). It needs vhs and nvim.
|
|
362
|
+
|
|
363
|
+
Every change a user would notice gets a line under `Unreleased` in
|
|
364
|
+
[CHANGELOG.md](https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md), in the same
|
|
365
|
+
commit. A release renames that section to its version; publishing refuses a version the
|
|
366
|
+
changelog does not name.
|
|
367
|
+
|
|
303
368
|
## License
|
|
304
369
|
|
|
305
370
|
MIT. See [LICENSE](https://github.com/danielriege/reqdeck/blob/main/LICENSE).
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Think of <a href="https://www.ibm.com/products/requirements-management">IBM DOORS</a>, but it lives in your git repo, fits in your terminal, and your AI agent can read it.
|
|
6
6
|
<br><br>
|
|
7
7
|
|
|
8
|
-
<a href="https://github.com/danielriege/reqdeck/actions/workflows/
|
|
8
|
+
<a href="https://github.com/danielriege/reqdeck/actions/workflows/release.yml"><img src="https://github.com/danielriege/reqdeck/actions/workflows/release.yml/badge.svg" alt="Release"></a>
|
|
9
9
|
<img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+">
|
|
10
10
|
<a href="https://pypi.org/project/reqdeck/"><img src="https://img.shields.io/pypi/v/reqdeck" alt="PyPI"></a>
|
|
11
11
|
<a href="https://github.com/danielriege/reqdeck/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
|
|
@@ -131,6 +131,11 @@ verification method fits, where a marker goes.
|
|
|
131
131
|
status, statement, detail tables and fields — beside the open questions by topic. The tree
|
|
132
132
|
always has the cursor; the right pane shows what it highlights.
|
|
133
133
|
|
|
134
|
+
Beside each requirement in the tree are two marks. The letter is its review status: `A`ccepted,
|
|
135
|
+
`P`roposed, `R`eopened. The symbol is how far it is built, judged over it and everything that
|
|
136
|
+
satisfies it: `✓` done, `◑` untested, `◔` partial, `○` not implemented, `✗` unsatisfied. The
|
|
137
|
+
card says which requirements beneath it keep it from done, and `reqdeck get` prints the same.
|
|
138
|
+
|
|
134
139
|
| | |
|
|
135
140
|
|---|---|
|
|
136
141
|
| `j` `k` `h` `l` · `gg` `G` | Move through the tree, as in vim |
|
|
@@ -156,6 +161,11 @@ Flexoki), each given a full colour suite by a file in [reqdeck/themes/](https://
|
|
|
156
161
|
where you can add your own. Your theme and sidebar width are remembered in
|
|
157
162
|
`~/.config/reqdeck/tui.json`.
|
|
158
163
|
|
|
164
|
+
It reloads from disk every 30 seconds, so what an agent or your editor writes shows up on its
|
|
165
|
+
own — `Reloading` spins at the left of the hint line while it does, and only a change redraws
|
|
166
|
+
anything. `r` reloads at once. Set `"reload"` in `tui.json` to the seconds between reloads, or
|
|
167
|
+
`0` to turn it off.
|
|
168
|
+
|
|
159
169
|
## The CLI
|
|
160
170
|
|
|
161
171
|
```bash
|
|
@@ -237,6 +247,7 @@ traced = false
|
|
|
237
247
|
kind = "SW"
|
|
238
248
|
satisfies = ["SYS"]
|
|
239
249
|
topic = true
|
|
250
|
+
code = true # what it needs before it is done — see below
|
|
240
251
|
|
|
241
252
|
[verification.T]
|
|
242
253
|
name = "Test"
|
|
@@ -255,6 +266,51 @@ command = "nvim +{line} {file}" # or "code --goto {file}:{line}"
|
|
|
255
266
|
Without `[verification]` the methods are A, I, D and T. Without `[markers]` every common
|
|
256
267
|
source file is scanned and a marker may sit anywhere.
|
|
257
268
|
|
|
269
|
+
### What a level needs
|
|
270
|
+
|
|
271
|
+
Three keys on a `[[levels]]` entry say what each of its requirements needs before it is done.
|
|
272
|
+
Each is `true` or `false`; left out, it takes the default in the last column, which is how
|
|
273
|
+
reqdeck behaved before the keys existed.
|
|
274
|
+
|
|
275
|
+
| Key | Met when | Left out |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| `satisfied` | a requirement of a lower level names it in `satisfies` — **or**, where the level also has `code = true`, a code marker names it itself | `true` if another level lists this one in its `satisfies` |
|
|
278
|
+
| `code` | a code `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by code, or is satisfied by requirements that all are | the unit's `code = true` |
|
|
279
|
+
| `test` | a test `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by a test, or is satisfied by requirements that all are | the verification method's `automated = true` |
|
|
280
|
+
|
|
281
|
+
`code` and `test` follow one rule: **a marker on the requirement itself, or a marker on every
|
|
282
|
+
branch beneath it.** "Beneath" is everything that satisfies it, all the way down, and *every
|
|
283
|
+
branch* means each requirement satisfying it is named by a marker, or is in turn satisfied by
|
|
284
|
+
requirements that all are. One branch without is enough to leave it short. With three
|
|
285
|
+
software requirements satisfying a system requirement and code on two, the system
|
|
286
|
+
requirement is not coded and shows as partial; code on the third makes it coded. Tests are
|
|
287
|
+
counted the same way, separately: code on all three and a test on two leaves it untested.
|
|
288
|
+
|
|
289
|
+
So a level with `satisfied = true` and `code = true` is done in either of two ways:
|
|
290
|
+
|
|
291
|
+
- **Broken down.** Lower requirements satisfy it, and code names every one of them (or every
|
|
292
|
+
one of theirs, where they are broken down further).
|
|
293
|
+
- **Implemented as it stands.** Code names it directly, and nothing needs to satisfy it.
|
|
294
|
+
|
|
295
|
+
`test = true` adds the same again for tests, on top of either way.
|
|
296
|
+
|
|
297
|
+
With `satisfied = false`, only a marker on the requirement itself counts, for `code` and for
|
|
298
|
+
`test`. A level with `traced = false` may never be named by a marker directly, so `code = true`
|
|
299
|
+
or `test = true` there needs `satisfied = true` — the markers can only be beneath it — and
|
|
300
|
+
`check` refuses the config otherwise.
|
|
301
|
+
|
|
302
|
+
`coverage`, `trace`, `get` and the progress marks in the TUI all judge by these same rules.
|
|
303
|
+
|
|
304
|
+
```toml
|
|
305
|
+
[[levels]]
|
|
306
|
+
kind = "TSR" # a technical system requirement
|
|
307
|
+
satisfies = ["STORY"]
|
|
308
|
+
traced = false # never named by code itself...
|
|
309
|
+
satisfied = true # ...so a TSSR or an ICD must satisfy it,
|
|
310
|
+
code = true # and code must name every one of those,
|
|
311
|
+
test = true # and a test must name every one of those too
|
|
312
|
+
```
|
|
313
|
+
|
|
258
314
|
## Limitations
|
|
259
315
|
|
|
260
316
|
- **Small teams.** It is built for one person and their agents, or a handful of people. Two
|
|
@@ -273,6 +329,15 @@ source file is scanned and a marker may sit anywhere.
|
|
|
273
329
|
python3 -m unittest discover tests # the TUI tests skip without textual
|
|
274
330
|
```
|
|
275
331
|
|
|
332
|
+
The demo GIF and the screenshot above are recorded, not drawn: `scripts/record-demo.sh`
|
|
333
|
+
installs this checkout into a throwaway virtualenv and plays `docs/demo.tape` and
|
|
334
|
+
`docs/document.tape` through [vhs](https://github.com/charmbracelet/vhs). It needs vhs and nvim.
|
|
335
|
+
|
|
336
|
+
Every change a user would notice gets a line under `Unreleased` in
|
|
337
|
+
[CHANGELOG.md](https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md), in the same
|
|
338
|
+
commit. A release renames that section to its version; publishing refuses a version the
|
|
339
|
+
changelog does not name.
|
|
340
|
+
|
|
276
341
|
## License
|
|
277
342
|
|
|
278
343
|
MIT. See [LICENSE](https://github.com/danielriege/reqdeck/blob/main/LICENSE).
|
|
@@ -32,7 +32,7 @@ classifiers = [
|
|
|
32
32
|
Homepage = "https://github.com/danielriege/reqdeck"
|
|
33
33
|
Source = "https://github.com/danielriege/reqdeck"
|
|
34
34
|
Issues = "https://github.com/danielriege/reqdeck/issues"
|
|
35
|
-
Changelog = "https://github.com/danielriege/reqdeck/
|
|
35
|
+
Changelog = "https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md"
|
|
36
36
|
|
|
37
37
|
[project.scripts]
|
|
38
38
|
reqdeck = "reqdeck.cli:main"
|
|
@@ -49,4 +49,4 @@ packages = ["reqdeck"]
|
|
|
49
49
|
"skills" = "reqdeck/skills"
|
|
50
50
|
|
|
51
51
|
[tool.hatch.build.targets.sdist]
|
|
52
|
-
include = ["reqdeck", "skills", "tests", "examples", "README.md", "LICENSE", "pyproject.toml"]
|
|
52
|
+
include = ["reqdeck", "skills", "tests", "examples", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
|
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from . import __version__, config, ops, render, store
|
|
17
17
|
from .config import STATUS
|
|
18
18
|
from .markers import Ref
|
|
19
|
-
from .model import Model, Req, split
|
|
19
|
+
from .model import PROGRESS, Model, Req, split
|
|
20
20
|
|
|
21
21
|
BOLD, DIM, RED, RESET = "\033[1m", "\033[2m", "\033[31m", "\033[0m"
|
|
22
22
|
|
|
@@ -77,6 +77,7 @@ def cmd_get(m: Model, args) -> int:
|
|
|
77
77
|
"code_refs": [asdict(x) for x in refs if not x.is_test],
|
|
78
78
|
"test_refs": [asdict(x) for x in refs if x.is_test],
|
|
79
79
|
"covering_test_refs": [asdict(x) for x in m.covering_refs(rid) if x.is_test],
|
|
80
|
+
"progress": asdict(m.progress(rid)),
|
|
80
81
|
}, indent=2))
|
|
81
82
|
return 0
|
|
82
83
|
|
|
@@ -97,6 +98,13 @@ def cmd_get(m: Model, args) -> int:
|
|
|
97
98
|
print(f" {_c('Verification:', DIM)} {r.verification} — {v.name if v else '?'}")
|
|
98
99
|
if r.status:
|
|
99
100
|
print(f" {_c('Status:', DIM)} {r.status}")
|
|
101
|
+
progress = m.progress(rid)
|
|
102
|
+
if progress.state:
|
|
103
|
+
print(f" {_c('Progress:', DIM)} {progress.state} — {PROGRESS[progress.state]}")
|
|
104
|
+
for label, ids in (("nothing satisfies", progress.unsatisfied), ("no code", progress.uncoded),
|
|
105
|
+
("no test", progress.untested)):
|
|
106
|
+
if ids:
|
|
107
|
+
print(f" {_c(label + ':', DIM)} {', '.join(ids)}")
|
|
100
108
|
if r.security:
|
|
101
109
|
print(f" {_c('Security relevant:', DIM)} {r.security}")
|
|
102
110
|
for k, v in r.extra.items():
|
|
@@ -207,8 +215,8 @@ GAP_LABELS = [
|
|
|
207
215
|
("dangling", "Satisfies a requirement that does not exist", True),
|
|
208
216
|
("orphans", "No satisfies link — traces to nothing", False),
|
|
209
217
|
("unsatisfied", "Nothing satisfies it yet", False),
|
|
210
|
-
("untested", "
|
|
211
|
-
("uncoded", "
|
|
218
|
+
("untested", "Needs a test, but none names it — or, where it must be satisfied, all that is beneath it", False),
|
|
219
|
+
("uncoded", "Needs code, but none names it — or, where it must be satisfied, all that is beneath it", False),
|
|
212
220
|
("not_accepted", "Not yet accepted — awaiting review by the user", False),
|
|
213
221
|
]
|
|
214
222
|
|
|
@@ -259,15 +267,16 @@ def cmd_trace(m: Model, args) -> int:
|
|
|
259
267
|
problems.append(f"{rid}: is a {r.kind}, which no code references directly — "
|
|
260
268
|
f"name what satisfies it instead ({below})")
|
|
261
269
|
continue
|
|
262
|
-
code =
|
|
270
|
+
code = m.code_refs(r)
|
|
263
271
|
test = [x for x in m.covering_refs(rid) if x.is_test]
|
|
264
|
-
needs_code = m.
|
|
265
|
-
|
|
266
|
-
if
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
satisfied, needs_code, needs_test = m.needs(r)
|
|
273
|
+
missing = m.missing(r)
|
|
274
|
+
if "code" in missing:
|
|
275
|
+
beneath = ", or on what implements every requirement beneath it" if satisfied else ""
|
|
276
|
+
problems.append(f"{rid}: no code reference — put @REQ_REF({rid}) on what implements it{beneath}")
|
|
277
|
+
if "test" in missing:
|
|
278
|
+
beneath = ", or test every requirement beneath it" if satisfied else ""
|
|
279
|
+
problems.append(f"{rid}: needs a test — add @REQ_REF({rid}) to the test function{beneath}")
|
|
271
280
|
results.append({"id": rid, "verification": r.verification, "status": r.status,
|
|
272
281
|
"code_refs": [asdict(x) for x in code],
|
|
273
282
|
"test_refs": [asdict(x) for x in test],
|
|
@@ -54,6 +54,18 @@ class Level:
|
|
|
54
54
|
traced: bool = True
|
|
55
55
|
separator: str = "_"
|
|
56
56
|
digits: int = 3
|
|
57
|
+
#: What a requirement of this level must have before it is done; see
|
|
58
|
+
#: `Model.missing`. Unset keeps the defaults: `satisfied` where another
|
|
59
|
+
#: level names this one in its `satisfies`, `code` where the unit says
|
|
60
|
+
#: `code = true`, `test` where the verification method is automated.
|
|
61
|
+
#: A lower level must satisfy it — or, with `code`, its own code marker.
|
|
62
|
+
satisfied: bool | None = None
|
|
63
|
+
#: A code marker must name it — or, with `satisfied`, every requirement
|
|
64
|
+
#: beneath it must be named by code, or be satisfied by ones that are.
|
|
65
|
+
code: bool | None = None
|
|
66
|
+
#: A test marker must name it — or, with `satisfied`, every requirement
|
|
67
|
+
#: beneath it must be named by a test, or be satisfied by ones that are.
|
|
68
|
+
test: bool | None = None
|
|
57
69
|
|
|
58
70
|
|
|
59
71
|
@dataclass
|
|
@@ -150,6 +162,17 @@ def load(root: Path) -> Config:
|
|
|
150
162
|
for k in lv.satisfies:
|
|
151
163
|
if k not in kinds:
|
|
152
164
|
raise ConfigError(f"{path}: level {lv.kind} satisfies unknown level {k}")
|
|
165
|
+
for key in ("satisfied", "code", "test"):
|
|
166
|
+
if getattr(lv, key) not in (None, True, False):
|
|
167
|
+
raise ConfigError(f"{path}: level {lv.kind}: {key} is true or false")
|
|
168
|
+
below = any(lv.kind in other.satisfies for other in levels)
|
|
169
|
+
for key in ("code", "test"):
|
|
170
|
+
if getattr(lv, key) and not lv.traced and not (lv.satisfied if lv.satisfied is not None else below):
|
|
171
|
+
raise ConfigError(f"{path}: level {lv.kind}: {key} = true with traced = false can only be "
|
|
172
|
+
f"met by markers beneath it, so it needs satisfied = true")
|
|
173
|
+
if lv.satisfied and not below:
|
|
174
|
+
raise ConfigError(f"{path}: level {lv.kind}: satisfied = true, but no level "
|
|
175
|
+
f"names {lv.kind} in its satisfies")
|
|
153
176
|
|
|
154
177
|
verification = {}
|
|
155
178
|
for code, (name, definition, automated) in DEFAULT_VERIFICATION.items():
|
|
@@ -112,7 +112,12 @@ def _config(shape: str, req_dir: str, questions: str, sys_: str, sub: str, icd:
|
|
|
112
112
|
split_tests: bool, headers: bool) -> str:
|
|
113
113
|
out = ["# reqdeck configuration — https://github.com/danielriege/reqdeck", "",
|
|
114
114
|
f'requirements = "{req_dir}"', f'questions = "{questions}"', "",
|
|
115
|
-
"# Most abstract first. `satisfies` names the levels a requirement may point at.",
|
|
115
|
+
"# Most abstract first. `satisfies` names the levels a requirement may point at.",
|
|
116
|
+
"# `satisfied`, `code` and `test` (true or false) set what a level needs before it is",
|
|
117
|
+
"# done; unset, they follow from `satisfies`, the unit's `code` and the verification.",
|
|
118
|
+
"# `code` and `test` are met by a marker on the requirement itself, or, with `satisfied`,",
|
|
119
|
+
"# by markers on every requirement beneath it; with `code`, its own marker also counts",
|
|
120
|
+
"# for `satisfied`. See the README.", "",
|
|
116
121
|
'[[levels]]\nkind = "STORY"\ntitle = "User story"\nreviewed = false\ntraced = false\n'
|
|
117
122
|
'separator = "-"\ndigits = 2\n']
|
|
118
123
|
if shape == "3":
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
m.up("ICD_BUS_014") # what it satisfies, transitively
|
|
9
9
|
m.down("TSR_009") # what satisfies it, transitively
|
|
10
10
|
m.covering_refs("TSR_009") # markers on it or on anything beneath it
|
|
11
|
+
m.progress("SYS_004") # how far it and everything beneath it is built
|
|
11
12
|
|
|
12
13
|
Only `satisfies` is ever written, pointing one level up. The reverse direction
|
|
13
14
|
is derived here, so the two can never disagree.
|
|
@@ -24,6 +25,17 @@ from .config import STATUS, Config
|
|
|
24
25
|
from .markers import Ref
|
|
25
26
|
from .store import Unit
|
|
26
27
|
|
|
28
|
+
#: How far a requirement is built, least first. Derived from the links and the
|
|
29
|
+
#: markers, never stored: the review status says whether it is right, this
|
|
30
|
+
#: says whether it is there.
|
|
31
|
+
PROGRESS = {
|
|
32
|
+
"Unsatisfied": "a lower level exists to satisfy it, and nothing does",
|
|
33
|
+
"Not implemented": "code is expected, here or beneath it, and none names it",
|
|
34
|
+
"Partial": "some of what is beneath it is built, not all",
|
|
35
|
+
"Untested": "the code is there; a test its verification asks for is not",
|
|
36
|
+
"Done": "coded, and tested where its verification asks, and so is everything beneath it",
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
SPLIT = re.compile(r"[\s,;]+")
|
|
28
40
|
NONE = {"", "—", "-"}
|
|
29
41
|
|
|
@@ -68,6 +80,17 @@ class Req:
|
|
|
68
80
|
return asdict(self)
|
|
69
81
|
|
|
70
82
|
|
|
83
|
+
@dataclass
|
|
84
|
+
class Progress:
|
|
85
|
+
#: One of PROGRESS, or empty where nothing traceable is asked of it or
|
|
86
|
+
#: of anything beneath it.
|
|
87
|
+
state: str
|
|
88
|
+
#: Below, or the requirement itself: what keeps it from Done.
|
|
89
|
+
unsatisfied: list[str] = field(default_factory=list)
|
|
90
|
+
uncoded: list[str] = field(default_factory=list)
|
|
91
|
+
untested: list[str] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
|
|
71
94
|
class Model:
|
|
72
95
|
def __init__(self, cfg: Config):
|
|
73
96
|
self.cfg = cfg
|
|
@@ -79,6 +102,7 @@ class Model:
|
|
|
79
102
|
#: Problems found while loading, reported by `check`.
|
|
80
103
|
self.load_problems: list[str] = []
|
|
81
104
|
self.satisfied_by: dict[str, list[str]] = {}
|
|
105
|
+
self._progress: dict[str, Progress] = {}
|
|
82
106
|
|
|
83
107
|
# ---------------------------------------------------------------- loading
|
|
84
108
|
|
|
@@ -139,6 +163,7 @@ class Model:
|
|
|
139
163
|
r.detail = text
|
|
140
164
|
|
|
141
165
|
def relink(self) -> None:
|
|
166
|
+
self._progress = {}
|
|
142
167
|
self.satisfied_by = {k: [] for k in self.reqs}
|
|
143
168
|
for r in self.reqs.values():
|
|
144
169
|
for target in r.satisfies:
|
|
@@ -221,6 +246,95 @@ class Model:
|
|
|
221
246
|
lv = self.cfg.level(r.kind)
|
|
222
247
|
return lv.traced if lv else True
|
|
223
248
|
|
|
249
|
+
def needs(self, r: Req) -> tuple[bool, bool, bool]:
|
|
250
|
+
"""Whether `r` must be satisfied from below, named by code, named by a test.
|
|
251
|
+
Its level says so, or else the defaults described on `config.Level`."""
|
|
252
|
+
lv = self.cfg.level(r.kind)
|
|
253
|
+
satisfied = lv.satisfied if lv and lv.satisfied is not None else r.kind in self.cfg.parent_kinds
|
|
254
|
+
if lv and lv.code is not None:
|
|
255
|
+
code = lv.code
|
|
256
|
+
else:
|
|
257
|
+
code = self.reviewed(r) and self.code_bearing(r.id)
|
|
258
|
+
test = lv.test if lv and lv.test is not None else self.reviewed(r) and self.automated(r)
|
|
259
|
+
return satisfied, code, test
|
|
260
|
+
|
|
261
|
+
def code_refs(self, r: Req) -> list[Ref]:
|
|
262
|
+
"""The code markers that bear on `r`: its own, and where its level must be
|
|
263
|
+
satisfied, those beneath it. Whether they are enough is `coded`."""
|
|
264
|
+
refs = self.covering_refs(r.id) if self.needs(r)[0] else self.refs_for(r.id)
|
|
265
|
+
return [x for x in refs if not x.is_test]
|
|
266
|
+
|
|
267
|
+
def _traced_by(self, r: Req, test: bool) -> bool:
|
|
268
|
+
"""A code marker (or with `test`, a test marker) names `r` — or, where its
|
|
269
|
+
level must be satisfied, every branch beneath it ends in one: each
|
|
270
|
+
requirement satisfying it is named, or is in turn satisfied by
|
|
271
|
+
requirements that all are. One branch without leaves it short, and its
|
|
272
|
+
progress Partial."""
|
|
273
|
+
def named(rid: str) -> bool:
|
|
274
|
+
return any(x.is_test == test for x in self.refs_for(rid))
|
|
275
|
+
|
|
276
|
+
def reaches(rid: str, seen: frozenset) -> bool:
|
|
277
|
+
if named(rid):
|
|
278
|
+
return True
|
|
279
|
+
below = [c for c in self.satisfied_by.get(rid, []) if c not in seen]
|
|
280
|
+
return bool(below) and all(reaches(c, seen | {c}) for c in below)
|
|
281
|
+
return named(r.id) or (self.needs(r)[0] and reaches(r.id, frozenset({r.id})))
|
|
282
|
+
|
|
283
|
+
def coded(self, r: Req) -> bool:
|
|
284
|
+
"""Code names `r`, or every branch beneath it — see `_traced_by`."""
|
|
285
|
+
return self._traced_by(r, test=False)
|
|
286
|
+
|
|
287
|
+
def tested(self, r: Req) -> bool:
|
|
288
|
+
"""A test names `r`, or every branch beneath it — see `_traced_by`."""
|
|
289
|
+
return self._traced_by(r, test=True)
|
|
290
|
+
|
|
291
|
+
def missing(self, r: Req) -> set[str]:
|
|
292
|
+
"""What `r` lacks of what it needs: "satisfied", "code", "test".
|
|
293
|
+
|
|
294
|
+
- satisfied: something satisfies it — or, needing code too, code names
|
|
295
|
+
it itself: it is implemented as it stands, not broken down further.
|
|
296
|
+
- code: code names it — or, needing to be satisfied, every branch
|
|
297
|
+
beneath it ends in code (see `coded`).
|
|
298
|
+
- test: the same, with a test (see `tested`)."""
|
|
299
|
+
satisfied, code, test = self.needs(r)
|
|
300
|
+
has_below = bool(self.satisfied_by.get(r.id))
|
|
301
|
+
own_code = any(not x.is_test for x in self.refs_for(r.id))
|
|
302
|
+
out = set()
|
|
303
|
+
if satisfied and not has_below and not (code and own_code):
|
|
304
|
+
out.add("satisfied")
|
|
305
|
+
if code and not self.coded(r):
|
|
306
|
+
out.add("code")
|
|
307
|
+
if test and not self.tested(r):
|
|
308
|
+
out.add("test")
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
def progress(self, rid: str) -> Progress:
|
|
312
|
+
"""How far `rid` is built, judged over it and everything that satisfies it,
|
|
313
|
+
by what each of them `needs`. A user story is Done once its whole chain is."""
|
|
314
|
+
if rid in self._progress:
|
|
315
|
+
return self._progress[rid]
|
|
316
|
+
below = [self.reqs[x] for x in [rid] + self.down(rid) if x in self.reqs]
|
|
317
|
+
missing = {r.id: self.missing(r) for r in below}
|
|
318
|
+
unsatisfied = [r.id for r in below if "satisfied" in missing[r.id]]
|
|
319
|
+
needs_code = [r.id for r in below if self.needs(r)[1]]
|
|
320
|
+
needs_test = [r.id for r in below if self.needs(r)[2]]
|
|
321
|
+
uncoded = [r.id for r in below if "code" in missing[r.id]]
|
|
322
|
+
untested = [r.id for r in below if "test" in missing[r.id]]
|
|
323
|
+
if rid in unsatisfied and rid not in uncoded:
|
|
324
|
+
state = "Unsatisfied"
|
|
325
|
+
elif needs_code and len(uncoded) == len(needs_code):
|
|
326
|
+
state = "Not implemented"
|
|
327
|
+
elif uncoded or unsatisfied:
|
|
328
|
+
state = "Partial"
|
|
329
|
+
elif untested:
|
|
330
|
+
state = "Untested"
|
|
331
|
+
elif needs_code or needs_test:
|
|
332
|
+
state = "Done"
|
|
333
|
+
else:
|
|
334
|
+
state = ""
|
|
335
|
+
p = self._progress[rid] = Progress(state, unsatisfied, uncoded, untested)
|
|
336
|
+
return p
|
|
337
|
+
|
|
224
338
|
def next_id(self, prefix: str) -> str:
|
|
225
339
|
unit = self.unit(prefix)
|
|
226
340
|
lv = self.cfg.level(unit.level) if unit else None
|
|
@@ -234,7 +348,6 @@ class Model:
|
|
|
234
348
|
def gaps(self) -> dict:
|
|
235
349
|
g = {"dangling": [], "orphans": [], "unsatisfied": [], "untested": [],
|
|
236
350
|
"uncoded": [], "not_accepted": []}
|
|
237
|
-
parents = self.cfg.parent_kinds
|
|
238
351
|
for r in self.reqs.values():
|
|
239
352
|
lv = self.cfg.level(r.kind)
|
|
240
353
|
for t in r.satisfies:
|
|
@@ -242,14 +355,12 @@ class Model:
|
|
|
242
355
|
g["dangling"].append({"id": r.id, "missing": t})
|
|
243
356
|
if lv and lv.satisfies and not r.satisfies:
|
|
244
357
|
g["orphans"].append(r.id)
|
|
245
|
-
|
|
246
|
-
|
|
358
|
+
missing = self.missing(r)
|
|
359
|
+
for need, key in (("satisfied", "unsatisfied"), ("test", "untested"), ("code", "uncoded")):
|
|
360
|
+
if need in missing:
|
|
361
|
+
g[key].append(r.id)
|
|
247
362
|
if not self.reviewed(r):
|
|
248
363
|
continue
|
|
249
|
-
if self.automated(r) and not [x for x in self.covering_refs(r.id) if x.is_test]:
|
|
250
|
-
g["untested"].append(r.id)
|
|
251
|
-
if self.code_bearing(r.id) and not [x for x in self.refs_for(r.id) if not x.is_test]:
|
|
252
|
-
g["uncoded"].append(r.id)
|
|
253
364
|
if r.status != "Accepted":
|
|
254
365
|
g["not_accepted"].append(r.id)
|
|
255
366
|
for k in g:
|
|
@@ -18,11 +18,11 @@ except `selection`, the one filled row.
|
|
|
18
18
|
| `heading` | Section headings and numbers in a document |
|
|
19
19
|
| `id` | A requirement or question ID |
|
|
20
20
|
| `link` | An ID in running text, which can be followed |
|
|
21
|
-
| `link_active` | The link under the keyboard cursor,
|
|
21
|
+
| `link_active` | The link under the keyboard cursor, the preview's border, and `Reloading` in the hint line |
|
|
22
22
|
| `code` | Inline code — byte values, command codes |
|
|
23
23
|
| `table_header` | A table's header row |
|
|
24
24
|
| `statement_bar` | The bar beside a requirement's statement |
|
|
25
|
-
| `accepted` `proposed` `reopened` | Review status |
|
|
25
|
+
| `accepted` `proposed` `reopened` | Review status, and progress: done, part built, not built |
|
|
26
26
|
| `security` | The security-relevant marker |
|
|
27
27
|
| `open` `answered` | An open question's state |
|
|
28
28
|
| `keys` | Key names in the hint line |
|
|
@@ -3,7 +3,8 @@ questions beside them. Reads through the same model as the CLI and writes
|
|
|
3
3
|
through the same operations.
|
|
4
4
|
|
|
5
5
|
Needs `textual`, which the CLI does not. The chosen theme and sidebar width
|
|
6
|
-
are remembered per user in `$XDG_CONFIG_HOME/reqdeck/tui.json
|
|
6
|
+
are remembered per user in `$XDG_CONFIG_HOME/reqdeck/tui.json`, and the seconds
|
|
7
|
+
between reloads read from it: `"reload": 30` by default, `0` for never.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -37,6 +38,9 @@ TARGET_RE = re.compile(r"(?:goto|link)\('(?:reqdeck:)?([^']+)'\)")
|
|
|
37
38
|
|
|
38
39
|
#: Status letter in the tree, and the theme role that colours it.
|
|
39
40
|
STATUS_MARK = {"Accepted": ("A", "accepted"), "Proposed": ("P", "proposed"), "Reopened": ("R", "reopened")}
|
|
41
|
+
#: Progress mark in the tree, and the theme role that colours it.
|
|
42
|
+
PROGRESS_MARK = {"Done": ("✓", "accepted"), "Untested": ("◑", "proposed"), "Partial": ("◔", "proposed"),
|
|
43
|
+
"Not implemented": ("○", "reopened"), "Unsatisfied": ("✗", "reopened")}
|
|
40
44
|
|
|
41
45
|
HELP = """\
|
|
42
46
|
# Keys
|
|
@@ -61,11 +65,25 @@ The tree always has the cursor; the reading pane shows what it highlights.
|
|
|
61
65
|
| `a` | Accept the highlighted requirement |
|
|
62
66
|
| `<` `>` | Narrow or widen the sidebar — or drag the divider |
|
|
63
67
|
| `t` | Theme — previewed as you move, `enter` keeps it |
|
|
64
|
-
| `r` | Reload from disk |
|
|
68
|
+
| `r` | Reload from disk — it also does so every 30 seconds |
|
|
65
69
|
| `q` | Quit |
|
|
66
70
|
|
|
67
71
|
Every requirement and question ID in the reading pane is a link, and so is every code
|
|
68
72
|
and test reference. Hover one for a preview, click it to go there.
|
|
73
|
+
|
|
74
|
+
# Marks
|
|
75
|
+
|
|
76
|
+
Beside each requirement in the tree: its review status, then how far it is built — judged
|
|
77
|
+
over it and everything that satisfies it, from the links and the `@REQ_REF` markers.
|
|
78
|
+
|
|
79
|
+
| | |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `A` `P` `R` | Accepted, Proposed, Reopened |
|
|
82
|
+
| `✓` | Done — coded, and tested where its verification asks, and so is everything beneath it |
|
|
83
|
+
| `◑` | Untested — the code is there, a test its verification asks for is not |
|
|
84
|
+
| `◔` | Partial — some of what is beneath it is built, not all |
|
|
85
|
+
| `○` | Not implemented — code is expected, and none names it |
|
|
86
|
+
| `✗` | Unsatisfied — a lower level exists to satisfy it, and nothing does |
|
|
69
87
|
"""
|
|
70
88
|
|
|
71
89
|
|
|
@@ -282,6 +300,11 @@ def _status_chip(status: str) -> Content:
|
|
|
282
300
|
return Content.assemble((f"● {status}", f"bold $rk-{role}"))
|
|
283
301
|
|
|
284
302
|
|
|
303
|
+
def _progress_chip(state: str) -> Content:
|
|
304
|
+
mark, role = PROGRESS_MARK.get(state, ("?", "muted"))
|
|
305
|
+
return Content.assemble((f"{mark} {state}", f"bold $rk-{role}"))
|
|
306
|
+
|
|
307
|
+
|
|
285
308
|
def _link(rid: str, sel: str = "") -> tuple[str, str]:
|
|
286
309
|
"""A clickable ID; the one under the keyboard link cursor is drawn reversed."""
|
|
287
310
|
style = "bold $rk-link-active on $rk-selection" if rid == sel else "$rk-link underline"
|
|
@@ -371,6 +394,8 @@ def requirement_card(m: Model, r: Req, full: bool, sel: str = "") -> Vertical:
|
|
|
371
394
|
head += [" ", (r.title, "bold $rk-foreground")]
|
|
372
395
|
if m.reviewed(r) and r.status:
|
|
373
396
|
head += [" ", _status_chip(r.status)]
|
|
397
|
+
if m.progress(r.id).state:
|
|
398
|
+
head += [" ", _progress_chip(m.progress(r.id).state)]
|
|
374
399
|
if r.security:
|
|
375
400
|
head += [" ", ("◆ security", "bold $rk-security")]
|
|
376
401
|
parts.append(Static(Content.assemble(*head), classes="card-head"))
|
|
@@ -527,6 +552,12 @@ class ReqdeckApp(App):
|
|
|
527
552
|
Binding("greater_than_sign", "resize(4)", show=False),
|
|
528
553
|
Binding("q", "quit", "Quit"),
|
|
529
554
|
]
|
|
555
|
+
#: Seconds between reloads from disk, unless `tui.json` names its own; 0 is never.
|
|
556
|
+
RELOAD_EVERY = 30
|
|
557
|
+
#: Frames of the spinner drawn while a reload runs, as lazygit's.
|
|
558
|
+
SPINNER = "|/-\\"
|
|
559
|
+
#: The spinner stays up at least this long, so a fast reload is still seen.
|
|
560
|
+
SPIN_AT_LEAST = 0.5
|
|
530
561
|
|
|
531
562
|
def __init__(self):
|
|
532
563
|
# ansi_color leaves the terminal's own background unpainted: with it
|
|
@@ -546,6 +577,10 @@ class ReqdeckApp(App):
|
|
|
546
577
|
self.hovering = ""
|
|
547
578
|
self.back: list[tuple] = []
|
|
548
579
|
self.forward: list[tuple] = []
|
|
580
|
+
#: The spinner's frame while a reload runs; None when none does.
|
|
581
|
+
self._spin: int | None = None
|
|
582
|
+
#: The last reload's error, so a file left broken is reported once.
|
|
583
|
+
self._reload_error = ""
|
|
549
584
|
self.prefs = load_prefs()
|
|
550
585
|
self.palettes = themes.load_all()
|
|
551
586
|
for p in self.palettes.values():
|
|
@@ -584,6 +619,12 @@ class ReqdeckApp(App):
|
|
|
584
619
|
self.show(("help", None))
|
|
585
620
|
self.refresh_keys()
|
|
586
621
|
self.run_worker(self._warm_origins, thread=True, group="warm")
|
|
622
|
+
try:
|
|
623
|
+
every = float(self.prefs.get("reload", self.RELOAD_EVERY))
|
|
624
|
+
except (TypeError, ValueError):
|
|
625
|
+
every = self.RELOAD_EVERY
|
|
626
|
+
if every > 0:
|
|
627
|
+
self.set_interval(every, self.reload)
|
|
587
628
|
|
|
588
629
|
def _warm_origins(self) -> None:
|
|
589
630
|
"""What every reference is attached to, read in a thread at start, so a
|
|
@@ -666,6 +707,9 @@ class ReqdeckApp(App):
|
|
|
666
707
|
if i:
|
|
667
708
|
parts.append((" | ", "$rk-muted"))
|
|
668
709
|
parts += [(f"{action}: ", "$rk-muted"), (key, "bold $rk-keys")]
|
|
710
|
+
if self._spin is not None:
|
|
711
|
+
frame = self.SPINNER[self._spin % len(self.SPINNER)]
|
|
712
|
+
parts = [(f"Reloading {frame}", "bold $rk-link-active"), " "] + parts
|
|
669
713
|
line = Content.assemble(*parts)
|
|
670
714
|
if str(line) != str(bar.content): # a repaint only when it changed
|
|
671
715
|
bar.update(line)
|
|
@@ -686,6 +730,12 @@ class ReqdeckApp(App):
|
|
|
686
730
|
if self.m.reviewed(r):
|
|
687
731
|
mark, role = STATUS_MARK.get(r.status, ("?", "muted"))
|
|
688
732
|
t.append(f"{mark} ", style=f"bold {self.colour(role)}")
|
|
733
|
+
state = self.m.progress(r.id).state
|
|
734
|
+
if state:
|
|
735
|
+
mark, role = PROGRESS_MARK[state]
|
|
736
|
+
t.append(f"{mark} ", style=f"bold {self.colour(role)}")
|
|
737
|
+
else:
|
|
738
|
+
t.append(" ")
|
|
689
739
|
t.append(r.id, style=self.colour("id"))
|
|
690
740
|
t.append(f" {r.label}")
|
|
691
741
|
return t
|
|
@@ -782,6 +832,8 @@ class ReqdeckApp(App):
|
|
|
782
832
|
|
|
783
833
|
@on(Tree.NodeHighlighted)
|
|
784
834
|
def _highlighted(self, event: Tree.NodeHighlighted) -> None:
|
|
835
|
+
if event.node.data == self.shown:
|
|
836
|
+
return # the tree rebuilt under a view it already shows
|
|
785
837
|
self.show(event.node.data)
|
|
786
838
|
|
|
787
839
|
def _widgets_for(self, data, sel: str = "") -> list[Widget]:
|
|
@@ -818,12 +870,16 @@ class ReqdeckApp(App):
|
|
|
818
870
|
return out
|
|
819
871
|
if kind == "level":
|
|
820
872
|
lv = m.cfg.level(key)
|
|
873
|
+
def done(u) -> str:
|
|
874
|
+
states = [m.progress(r.id).state for r in m.reqs.values() if r.unit == u.stem]
|
|
875
|
+
asked = [st for st in states if st]
|
|
876
|
+
return f"{asked.count('Done')}/{len(asked)}" if asked else "—"
|
|
821
877
|
rows = "\n".join(
|
|
822
878
|
f"| {u.title} | `{', '.join(u.prefixes)}` | {sum(1 for r in m.reqs.values() if r.unit == u.stem)} "
|
|
823
|
-
f"| `{m.cfg.rel(u.csv_path)}` |" for u in m.units if u.level == key)
|
|
879
|
+
f"| {done(u)} | `{m.cfg.rel(u.csv_path)}` |" for u in m.units if u.level == key)
|
|
824
880
|
return [heading(f"{lv.kind} — {lv.title}", 1),
|
|
825
881
|
Markdown(f"Satisfies: **{', '.join(lv.satisfies) or 'top level'}**\n\n"
|
|
826
|
-
f"| Unit | Prefix | Requirements | File |\n
|
|
882
|
+
f"| Unit | Prefix | Requirements | Done | File |\n|---|---|---|---|---|\n{rows}\n",
|
|
827
883
|
classes="prose")]
|
|
828
884
|
if kind == "topic":
|
|
829
885
|
qs = [q for q in m.questions if q.topic == key]
|
|
@@ -945,6 +1001,8 @@ class ReqdeckApp(App):
|
|
|
945
1001
|
head = [(r.id, "bold $rk-id")] + ([" ", (r.title, "bold $rk-foreground")] if r.title else [])
|
|
946
1002
|
if m.reviewed(r) and r.status:
|
|
947
1003
|
head += [" ", _status_chip(r.status)]
|
|
1004
|
+
if m.progress(r.id).state:
|
|
1005
|
+
head += [" ", _progress_chip(m.progress(r.id).state)]
|
|
948
1006
|
else:
|
|
949
1007
|
head = [(self._title_for(data), "bold $rk-heading")]
|
|
950
1008
|
top = Static(Content.assemble(*head), classes="card-head")
|
|
@@ -1005,12 +1063,66 @@ class ReqdeckApp(App):
|
|
|
1005
1063
|
def action_help(self) -> None:
|
|
1006
1064
|
self.show(("help", None))
|
|
1007
1065
|
|
|
1008
|
-
def action_reload(self
|
|
1009
|
-
self.
|
|
1010
|
-
|
|
1011
|
-
|
|
1066
|
+
def action_reload(self) -> None:
|
|
1067
|
+
self.reload(say=True)
|
|
1068
|
+
|
|
1069
|
+
def reload(self, say: bool = False) -> None:
|
|
1070
|
+
"""Read the model from disk again, in a thread, with the spinner up meanwhile.
|
|
1071
|
+
A dialog defers it: the next one comes soon enough."""
|
|
1072
|
+
if self._spin is not None or len(self.screen_stack) > 1:
|
|
1073
|
+
return
|
|
1074
|
+
self._spin = 0
|
|
1075
|
+
self.refresh_keys()
|
|
1076
|
+
self.run_worker(self._reload(say), exclusive=True, group="reload")
|
|
1077
|
+
|
|
1078
|
+
async def _reload(self, say: bool) -> None:
|
|
1079
|
+
start = time.monotonic()
|
|
1080
|
+
ticker = self.set_interval(0.1, self._spin_on)
|
|
1081
|
+
try:
|
|
1082
|
+
m, error = await asyncio.to_thread(load_model), ""
|
|
1083
|
+
except Exception as e: # a broken config or file, maybe half written
|
|
1084
|
+
m, error = None, str(e)
|
|
1085
|
+
await asyncio.sleep(max(0.0, self.SPIN_AT_LEAST - (time.monotonic() - start)))
|
|
1086
|
+
ticker.stop()
|
|
1087
|
+
self._spin = None
|
|
1088
|
+
self.refresh_keys()
|
|
1089
|
+
if error:
|
|
1090
|
+
if say or error != self._reload_error:
|
|
1091
|
+
self.notify(error, title="Reload failed", severity="error")
|
|
1092
|
+
self._reload_error = error
|
|
1093
|
+
return
|
|
1094
|
+
self._reload_error = ""
|
|
1095
|
+
if len(self.screen_stack) == 1 and not self._same(m, self.m):
|
|
1096
|
+
self.m = m
|
|
1097
|
+
self.drop_link()
|
|
1098
|
+
self.relabel() # rebuilds the tree, keeping the cursor where it was
|
|
1099
|
+
here = self.shown
|
|
1100
|
+
if here and (here[0] == "help" or self._node_for(here) is not None):
|
|
1101
|
+
# Redrawn from the new model, scrolled where it was.
|
|
1102
|
+
self.links = self._links_for(here) if here[0] in ("unit", "section") else None
|
|
1103
|
+
self.show(here, keep=True)
|
|
1104
|
+
else:
|
|
1105
|
+
self.call_after_refresh(self._show_cursor) # it is gone
|
|
1106
|
+
if say:
|
|
1012
1107
|
self.notify("Reloaded")
|
|
1013
1108
|
|
|
1109
|
+
def _show_cursor(self) -> None:
|
|
1110
|
+
node = self.query_one("#nav", Tree).cursor_node
|
|
1111
|
+
if node is not None:
|
|
1112
|
+
self.show(node.data)
|
|
1113
|
+
|
|
1114
|
+
def _spin_on(self) -> None:
|
|
1115
|
+
if self._spin is not None:
|
|
1116
|
+
self._spin += 1
|
|
1117
|
+
self.refresh_keys()
|
|
1118
|
+
|
|
1119
|
+
@staticmethod
|
|
1120
|
+
def _same(a: Model, b: Model) -> bool:
|
|
1121
|
+
"""Nothing on disk changed between two loads, so nothing need be redrawn."""
|
|
1122
|
+
def facts(m: Model) -> tuple:
|
|
1123
|
+
return m.cfg, m.units, m.reqs, m.refs, m.topics, m.questions, m.load_problems
|
|
1124
|
+
return facts(a) == facts(b)
|
|
1125
|
+
|
|
1014
1126
|
def action_next_match(self, direction: int) -> None:
|
|
1015
1127
|
tree = self.query_one("#nav", Tree)
|
|
1016
1128
|
line = tree.cursor_line
|
|
@@ -1085,6 +1197,8 @@ class ReqdeckApp(App):
|
|
|
1085
1197
|
head += [" ", (r.title, "bold")]
|
|
1086
1198
|
if m.reviewed(r) and r.status:
|
|
1087
1199
|
head += [" ", _status_chip(r.status)]
|
|
1200
|
+
if m.progress(r.id).state:
|
|
1201
|
+
head += [" ", _progress_chip(m.progress(r.id).state)]
|
|
1088
1202
|
lines = [Content.assemble(*head), Content(""), Content(r.statement)]
|
|
1089
1203
|
if r.satisfies:
|
|
1090
1204
|
lines.append(Content.assemble(("Satisfies ", "$rk-muted"), ", ".join(r.satisfies)))
|
|
@@ -1301,7 +1415,7 @@ class ReqdeckApp(App):
|
|
|
1301
1415
|
if terminal:
|
|
1302
1416
|
with self.suspend():
|
|
1303
1417
|
subprocess.run(argv, cwd=self.m.cfg.root)
|
|
1304
|
-
self.
|
|
1418
|
+
self.reload()
|
|
1305
1419
|
else:
|
|
1306
1420
|
subprocess.Popen(argv, cwd=self.m.cfg.root, stdin=subprocess.DEVNULL,
|
|
1307
1421
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
@@ -148,10 +148,31 @@ non-canonical CSV, a detail block for a requirement that is not in that unit, an
|
|
|
148
148
|
| `dangling` | Satisfies something that does not exist — always a bug |
|
|
149
149
|
| `orphans` | No satisfies link. Legitimate only with the reason in the rationale |
|
|
150
150
|
| `unsatisfied` | A story, or a parent level, nothing satisfies yet |
|
|
151
|
-
| `untested` |
|
|
152
|
-
| `uncoded` |
|
|
151
|
+
| `untested` | Its level, or else its verification method, needs a test, and no test names it — or, where it must be satisfied, not every requirement beneath it |
|
|
152
|
+
| `uncoded` | Its level, or else its unit, needs code, and no code names it — or, where it must be satisfied, not every requirement beneath it |
|
|
153
153
|
| `not_accepted` | Awaiting the user's review — not a gap to close by editing |
|
|
154
154
|
|
|
155
|
+
What each level needs is set by `satisfied`, `code` and `test` on its `[[levels]]` entry in
|
|
156
|
+
`.reqdeck.toml`:
|
|
157
|
+
|
|
158
|
+
- `satisfied` — something beneath satisfies it, or, where the level also needs code, code
|
|
159
|
+
names it itself.
|
|
160
|
+
- `code` — code names it, or, where the level also needs satisfying, code names every
|
|
161
|
+
requirement beneath it (each one, or all of the ones satisfying it in turn). One branch
|
|
162
|
+
without code leaves it uncoded.
|
|
163
|
+
- `test` — the same as `code`, with tests: a test names it, or, where the level also needs
|
|
164
|
+
satisfying, a test names every requirement beneath it.
|
|
165
|
+
|
|
166
|
+
So a requirement that needs both `satisfied` and `code` is done broken down (lower
|
|
167
|
+
requirements satisfy it, and code names every one of them) or implemented as it stands (code
|
|
168
|
+
names it directly). Put a marker on the requirement the code really implements; never add one to a
|
|
169
|
+
higher level just to close its gap.
|
|
170
|
+
|
|
171
|
+
`get --json` carries the same per requirement as `progress`, judged over it and everything
|
|
172
|
+
beneath it: `state` is `Unsatisfied`, `Not implemented`, `Partial`, `Untested` or `Done`
|
|
173
|
+
(empty where nothing traceable is asked), and `unsatisfied`, `uncoded`, `untested` list the
|
|
174
|
+
IDs keeping it from `Done`. A story is `Done` only once its whole chain is.
|
|
175
|
+
|
|
155
176
|
Gaps are normal while topics are still being discussed. **Do not close them by inventing
|
|
156
177
|
links or markers.** Report them.
|
|
157
178
|
|
|
@@ -250,6 +250,94 @@ class TestTrace(Base):
|
|
|
250
250
|
self.assertEqual([r.id for r in m.covering_refs("SR_001")], ["SSR_APP_001"])
|
|
251
251
|
self.assertNotIn("SR_001", m.gaps()["untested"])
|
|
252
252
|
|
|
253
|
+
def test_every_branch_beneath_needs_its_test(self):
|
|
254
|
+
self.p.write("tests/screen_test.swift", "// @REQ_REF(SSR_APP_001) shows the screen\n")
|
|
255
|
+
ops.add(self.p.model(), "SSR_APP", {"statement": "T.", "satisfies": "SR_001", "source": "x",
|
|
256
|
+
"verification": "T"})
|
|
257
|
+
m = self.p.model()
|
|
258
|
+
self.assertIn("SR_001", m.gaps()["untested"]) # SSR_APP_002 has no test
|
|
259
|
+
self.p.write("tests/sound_test.swift", "// @REQ_REF(SSR_APP_002) sounds\n")
|
|
260
|
+
self.assertNotIn("SR_001", self.p.model().gaps()["untested"])
|
|
261
|
+
|
|
262
|
+
def test_progress_rolls_up_the_chain(self):
|
|
263
|
+
def states():
|
|
264
|
+
m = self.p.model()
|
|
265
|
+
return [m.progress(rid).state for rid in ("A-01", "SR_001", "SSR_APP_001")]
|
|
266
|
+
self.assertEqual(states(), ["Not implemented"] * 3)
|
|
267
|
+
self.p.write("src/screen.swift", "// @REQ_REF(SSR_APP_001) shows the screen\n")
|
|
268
|
+
self.assertEqual(states(), ["Untested"] * 3)
|
|
269
|
+
self.assertEqual(self.p.model().progress("A-01").untested, ["SR_001", "SSR_APP_001"])
|
|
270
|
+
self.p.write("tests/screen_test.swift", "// @REQ_REF(SSR_APP_001) shows the screen\n")
|
|
271
|
+
self.assertEqual(states(), ["Done"] * 3)
|
|
272
|
+
# A second story, which nothing satisfies, and a second child with no code.
|
|
273
|
+
ops.add(self.p.model(), "A", {"statement": "As a collector, I want sound."})
|
|
274
|
+
ops.add(self.p.model(), "SSR_APP", {"statement": "T.", "satisfies": "SR_001", "source": "x",
|
|
275
|
+
"verification": "I"})
|
|
276
|
+
m = self.p.model()
|
|
277
|
+
self.assertEqual(m.progress("A-02").state, "Unsatisfied")
|
|
278
|
+
self.assertEqual(m.progress("SR_001").state, "Partial")
|
|
279
|
+
self.assertEqual(m.progress("SR_001").uncoded, ["SSR_APP_002"])
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class TestLevelNeeds(Base):
|
|
283
|
+
def configure(self, sr: str) -> None:
|
|
284
|
+
"""The SR level with `sr` in place of `traced = false`."""
|
|
285
|
+
path = self.p.root / ".reqdeck.toml"
|
|
286
|
+
path.write_text(CONFIG.replace("topic = true\ntraced = false\n", f"topic = true\n{sr}\n", 1))
|
|
287
|
+
|
|
288
|
+
def test_its_own_code_or_code_on_every_branch_beneath(self):
|
|
289
|
+
self.configure("code = true")
|
|
290
|
+
m = self.p.model()
|
|
291
|
+
self.assertEqual(m.missing(m.reqs["SR_001"]), {"satisfied", "code", "test"})
|
|
292
|
+
self.assertEqual(m.progress("SR_001").state, "Not implemented")
|
|
293
|
+
self.p.write("src/light.swift", "// @REQ_REF(SR_001) lights\n")
|
|
294
|
+
m = self.p.model()
|
|
295
|
+
self.assertEqual(m.missing(m.reqs["SR_001"]), {"test"}) # coded: no breakdown needed
|
|
296
|
+
self.p.write("src/light.swift", "")
|
|
297
|
+
ops.add(self.p.model(), "SSR_APP", {"statement": "S.", "satisfies": "SR_001", "source": "x",
|
|
298
|
+
"verification": "I"})
|
|
299
|
+
m = self.p.model()
|
|
300
|
+
self.assertEqual(m.missing(m.reqs["SR_001"]), {"code", "test"}) # broken down, but nothing coded
|
|
301
|
+
self.p.write("src/screen.swift", "// @REQ_REF(SSR_APP_001) shows it\n")
|
|
302
|
+
m = self.p.model()
|
|
303
|
+
self.assertEqual(m.missing(m.reqs["SR_001"]), {"test"}) # code beneath it counts
|
|
304
|
+
self.assertEqual([x.id for x in m.code_refs(m.reqs["SR_001"])], ["SSR_APP_001"])
|
|
305
|
+
ops.add(self.p.model(), "SSR_APP", {"statement": "T.", "satisfies": "SR_001", "source": "x",
|
|
306
|
+
"verification": "I"})
|
|
307
|
+
m = self.p.model()
|
|
308
|
+
self.assertEqual(m.missing(m.reqs["SR_001"]), {"code", "test"}) # every branch must end in code
|
|
309
|
+
self.assertEqual(m.progress("SR_001").state, "Partial")
|
|
310
|
+
self.p.write("src/sound.swift", "// @REQ_REF(SSR_APP_002) sounds\n")
|
|
311
|
+
m = self.p.model()
|
|
312
|
+
self.assertEqual(m.missing(m.reqs["SR_001"]), {"test"})
|
|
313
|
+
|
|
314
|
+
def test_code_beneath_counts_only_where_it_must_be_satisfied(self):
|
|
315
|
+
self.configure("satisfied = false\ncode = true")
|
|
316
|
+
ops.add(self.p.model(), "SSR_APP", {"statement": "S.", "satisfies": "SR_001", "source": "x",
|
|
317
|
+
"verification": "I"})
|
|
318
|
+
self.p.write("src/screen.swift", "// @REQ_REF(SSR_APP_001) shows it\n")
|
|
319
|
+
m = self.p.model()
|
|
320
|
+
self.assertIn("code", m.missing(m.reqs["SR_001"])) # only its own marker counts
|
|
321
|
+
self.configure("traced = false\ncode = true") # met only from beneath: fine
|
|
322
|
+
self.assertEqual(self.p.model().missing(self.p.model().reqs["SR_001"]), {"test"})
|
|
323
|
+
self.configure("traced = false\ncode = true\nsatisfied = false")
|
|
324
|
+
with self.assertRaisesRegex(config.ConfigError, "needs satisfied = true"):
|
|
325
|
+
self.p.model()
|
|
326
|
+
|
|
327
|
+
def test_levels_turn_needs_off(self):
|
|
328
|
+
self.configure("traced = false\nsatisfied = false\ntest = false")
|
|
329
|
+
m = self.p.model()
|
|
330
|
+
self.assertEqual(m.missing(m.reqs["SR_001"]), set())
|
|
331
|
+
self.assertNotIn("SR_001", m.gaps()["unsatisfied"])
|
|
332
|
+
self.assertNotIn("SR_001", m.gaps()["untested"])
|
|
333
|
+
|
|
334
|
+
def test_contradictions_are_refused(self):
|
|
335
|
+
self.configure("")
|
|
336
|
+
path = self.p.root / ".reqdeck.toml"
|
|
337
|
+
path.write_text(path.read_text().replace('kind = "SSR"\n', 'kind = "SSR"\nsatisfied = true\n'))
|
|
338
|
+
with self.assertRaisesRegex(config.ConfigError, "no level names SSR"):
|
|
339
|
+
self.p.model()
|
|
340
|
+
|
|
253
341
|
|
|
254
342
|
class TestDocument(Base):
|
|
255
343
|
def test_sections_are_numbered_by_declaration_order(self):
|
|
@@ -361,6 +361,45 @@ class TuiTest(unittest.TestCase):
|
|
|
361
361
|
self.run_app(steps)
|
|
362
362
|
self.assertEqual(self.p.model().reqs["SR_002"].status, "Accepted")
|
|
363
363
|
|
|
364
|
+
def test_reload_spins_while_it_runs_and_redraws_only_a_change(self):
|
|
365
|
+
async def steps(app, pilot):
|
|
366
|
+
bar = app.query_one("#keys")
|
|
367
|
+
self.assertNotIn("Reloading", str(bar.content))
|
|
368
|
+
app.action_goto("SR_002")
|
|
369
|
+
await self.settle(app, pilot)
|
|
370
|
+
page = app._page()
|
|
371
|
+
app.reload()
|
|
372
|
+
await pilot.pause()
|
|
373
|
+
self.assertTrue(str(bar.content).startswith("Reloading |"))
|
|
374
|
+
await self.settle(app, pilot)
|
|
375
|
+
self.assertNotIn("Reloading", str(bar.content))
|
|
376
|
+
self.assertIs(app._page(), page) # nothing changed: nothing redrawn
|
|
377
|
+
|
|
378
|
+
ops.edit(self.p.model(), "SR_002", {"title": "Shine"})
|
|
379
|
+
app.reload()
|
|
380
|
+
await self.settle(app, pilot)
|
|
381
|
+
self.assertEqual(self.cursor(app), ("req", "SR_002"))
|
|
382
|
+
self.assertIn("Shine", str(app._page().query_one(".card-head").content))
|
|
383
|
+
self.run_app(steps)
|
|
384
|
+
|
|
385
|
+
def test_reload_runs_on_its_own_unless_turned_off(self):
|
|
386
|
+
from reqdeck.viewer import ReqdeckApp, save_prefs
|
|
387
|
+
|
|
388
|
+
async def go(prefs):
|
|
389
|
+
save_prefs(prefs)
|
|
390
|
+
app = ReqdeckApp()
|
|
391
|
+
app.SPIN_AT_LEAST = 0
|
|
392
|
+
async with app.run_test(size=(160, 48)) as pilot:
|
|
393
|
+
await pilot.pause()
|
|
394
|
+
rid, _, _ = ops.add(self.p.model(), "SR", {
|
|
395
|
+
"title": "Hum", "statement": "The system shall hum.", "satisfies": "A-01",
|
|
396
|
+
"topic": "01", "source": "OQ-0102.", "verification": "T"})
|
|
397
|
+
await asyncio.sleep(0.5) # a tick after the change, at least
|
|
398
|
+
await self.settle(app, pilot)
|
|
399
|
+
return rid in app.nodes
|
|
400
|
+
self.assertTrue(asyncio.run(go({"reload": 0.1})))
|
|
401
|
+
self.assertFalse(asyncio.run(go({"reload": 0})))
|
|
402
|
+
|
|
364
403
|
|
|
365
404
|
if __name__ == "__main__":
|
|
366
405
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|