NFR-OPS-1 asks for structured levelled logging to a rotating, size-capped on-disk log in the XDG state or Android app directory, automatic redaction of credentials and tokens, and a one-click diagnostics bundle with an explicit preview-and-consent step. Its two tags were on `compute_coverage` and on the traceability tool's gesture extractor. Neither is diagnostics under any reading. One computes a ratio and the other generates a markdown document; neither writes a log, and no rotating on-disk log exists anywhere in the tree — logging goes to stderr and to logcat. These were real tags, not the fixtures the extractor was just taught to ignore, which makes them the more instructive case: the tool was correct and the tags were wrong. NFR-OPS-1 is untagged again, and outstanding.md §9 now says what is actually missing rather than that the requirement is covered. `gestures.rs` keeps its FR-UI-4 tag, which is a separate claim and unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
613 lines
23 KiB
Rust
613 lines
23 KiB
Rust
//! TRACES: FR-UI-4
|
|
//! Interaction documentation, extracted from the code that implements it.
|
|
//!
|
|
//! # Why this exists at all
|
|
//!
|
|
//! FR-UI-4 says a gesture with no visible counterpart is a feature only its
|
|
//! author knows about. The application has grown a real gesture vocabulary —
|
|
//! hold to select, arm a range and tap its end, pinch to resize the grid, drag
|
|
//! a selection onto a collection — and every one of them was documented only in
|
|
//! the comment beside the `TouchArea` that implements it. Excellent comments,
|
|
//! and unreachable by anyone who is not reading the source.
|
|
//!
|
|
//! Writing them out a second time, in a hand-kept help page, is the failure
|
|
//! this file is built to avoid. Two descriptions of one gesture drift, and the
|
|
//! one that drifts is always the prose: the code is exercised every time
|
|
//! somebody uses the application and the page is exercised never. A help screen
|
|
//! that confidently describes a double-tap the grid stopped honouring six
|
|
//! months ago is worse than no help screen.
|
|
//!
|
|
//! So the comment beside the implementation is the only copy. This scans it out
|
|
//! into two artefacts — `docs/gestures.md` for a reader, and a generated Rust
|
|
//! table the application itself displays — and CI fails if either has drifted
|
|
//! from the source. The same discipline, and the same gate, the requirements
|
|
//! matrix already runs under.
|
|
//!
|
|
//! # Why it lives in the traceability crate
|
|
//!
|
|
//! It is the same operation on the same input: walk the source tree, pull
|
|
//! structured tags out of comments, render, and fail if the committed artefact
|
|
//! no longer matches. Everything but the tag vocabulary is shared — the file
|
|
//! walk, the extension list, the exclusions — and a second crate would have had
|
|
//! to duplicate all of it to add one parser.
|
|
//!
|
|
//! # The tag
|
|
//!
|
|
//! ```text
|
|
//! // GESTURE: Select a range
|
|
//! // where: Library grid
|
|
//! // touch: "Select to…", then tap the last photograph
|
|
//! // pointer: Shift-click
|
|
//! // why: A finger sweep runs out at the edge of the screen, and the
|
|
//! // ranges that hurt are longer than a screenful.
|
|
//! ```
|
|
//!
|
|
//! Works in `.rs` and `.slint` alike, because both comment with `//` and the
|
|
//! gestures live in both — the arbitration in Rust, the affordance in Slint.
|
|
//!
|
|
//! **The tag opens its comment or it is not a tag.** A line that merely
|
|
//! mentions it — a sentence about the vocabulary, a worked example in a doc
|
|
//! comment like the one above — is describing the mechanism rather than
|
|
//! declaring a gesture, and position is the only thing that tells the two
|
|
//! apart.
|
|
//!
|
|
//! A block runs from that line to the first line that is not a comment, or to a
|
|
//! comment line that is empty after its marker. A line reading
|
|
//! `key: value` sets a field; any other line inside the block continues the
|
|
//! field before it, which is what lets `why` run to a sentence. An unrecognised
|
|
//! key is an error rather than a silently dropped line: `pointr:` should not
|
|
//! quietly cost a gesture its desktop half.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde::Serialize;
|
|
|
|
/// One documented interaction.
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
pub struct Gesture {
|
|
/// What it does, in the user's terms — "Select a range", not "arm ranging".
|
|
pub title: String,
|
|
/// The place it applies, and the heading it is grouped under.
|
|
pub section: String,
|
|
/// What a finger does. `None` where the gesture is pointer-only.
|
|
pub touch: Option<String>,
|
|
/// What a mouse or trackpad does.
|
|
pub pointer: Option<String>,
|
|
/// The keyboard route, where there is one.
|
|
pub keys: Option<String>,
|
|
/// Why it works this way. Shown in the document, not in the application:
|
|
/// a user consulting the help sheet wants the gesture, and a reader of the
|
|
/// document wants the argument.
|
|
pub why: Option<String>,
|
|
pub file: String,
|
|
pub line: usize,
|
|
}
|
|
|
|
impl Gesture {
|
|
/// Every way in, longest-lived first.
|
|
///
|
|
/// Touch leads because that is the modality the gesture vocabulary was
|
|
/// grown for: ctrl and shift already documented themselves by existing on
|
|
/// every other application, and the hold-and-tap sequences did not.
|
|
pub fn routes(&self) -> Vec<(&'static str, &str)> {
|
|
let mut out = Vec::new();
|
|
if let Some(t) = &self.touch {
|
|
out.push(("Touch", t.as_str()));
|
|
}
|
|
if let Some(p) = &self.pointer {
|
|
out.push(("Pointer", p.as_str()));
|
|
}
|
|
if let Some(k) = &self.keys {
|
|
out.push(("Keyboard", k.as_str()));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// A key that may appear in a gesture block.
|
|
const KEYS: &[&str] = &["where", "touch", "pointer", "keys", "why"];
|
|
|
|
/// Something wrong with a tag, reported rather than dropped.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct GestureProblem {
|
|
pub file: String,
|
|
pub line: usize,
|
|
pub what: String,
|
|
}
|
|
|
|
impl std::fmt::Display for GestureProblem {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}:{}: {}", self.file, self.line, self.what)
|
|
}
|
|
}
|
|
|
|
/// Extract every gesture block from one file's text.
|
|
///
|
|
/// Returns what parsed and what did not. Both, deliberately: a malformed tag
|
|
/// that merely vanished would leave a gesture undocumented and everything
|
|
/// looking fine, which is the failure mode this whole file exists to prevent.
|
|
pub fn extract_from_text(text: &str, path: &str) -> (Vec<Gesture>, Vec<GestureProblem>) {
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
let mut out = Vec::new();
|
|
let mut problems = Vec::new();
|
|
|
|
let mut i = 0;
|
|
while i < lines.len() {
|
|
// **The tag must open its comment**, not merely appear somewhere in
|
|
// one. Prose that mentions it — "generated from the `GESTURE:` comments
|
|
// beside the code" — is talking *about* the vocabulary, not declaring a
|
|
// member of it, and the two are only distinguishable by position. The
|
|
// gate caught exactly that sentence in `dr-ui`'s own module list, which
|
|
// is the right outcome for a typo and the wrong one for a description.
|
|
//
|
|
// This also excludes a tag inside a string literal, which has code in
|
|
// front of it and so does not open a comment at all.
|
|
let Some(body) = comment_body(lines[i]) else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
let Some(title) = body.trim_start().strip_prefix("GESTURE:") else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
|
|
let start = i;
|
|
let title = title.trim().to_string();
|
|
|
|
let mut fields: BTreeMap<String, String> = BTreeMap::new();
|
|
let mut last: Option<String> = None;
|
|
i += 1;
|
|
while i < lines.len() {
|
|
let Some(body) = comment_body(lines[i]) else {
|
|
break;
|
|
};
|
|
if body.trim().is_empty() {
|
|
break;
|
|
}
|
|
// The next gesture ends this one. Without this, two blocks written
|
|
// back to back — which is how a screen's gestures naturally get
|
|
// listed — had the first swallow the second's title as a
|
|
// continuation of its last field, and the second vanished.
|
|
if body.contains("GESTURE:") {
|
|
break;
|
|
}
|
|
match split_key(body) {
|
|
Some((key, value)) => {
|
|
if !KEYS.contains(&key.as_str()) {
|
|
problems.push(GestureProblem {
|
|
file: path.to_string(),
|
|
line: i + 1,
|
|
what: format!(
|
|
"unknown gesture key `{key}` (expected one of {})",
|
|
KEYS.join(", ")
|
|
),
|
|
});
|
|
}
|
|
fields.insert(key.clone(), value.trim().to_string());
|
|
last = Some(key);
|
|
}
|
|
// A continuation of the field above it, joined with a single
|
|
// space: the source wraps at the column the file wraps at, and
|
|
// those line breaks are not part of the sentence.
|
|
None => match &last {
|
|
Some(key) => {
|
|
let entry = fields.entry(key.clone()).or_default();
|
|
if !entry.is_empty() {
|
|
entry.push(' ');
|
|
}
|
|
entry.push_str(body.trim());
|
|
}
|
|
None => break,
|
|
},
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
let take = |k: &str| fields.get(k).filter(|v| !v.is_empty()).cloned();
|
|
let g = Gesture {
|
|
title: title.clone(),
|
|
section: take("where").unwrap_or_default(),
|
|
touch: take("touch"),
|
|
pointer: take("pointer"),
|
|
keys: take("keys"),
|
|
why: take("why"),
|
|
file: path.to_string(),
|
|
line: start + 1,
|
|
};
|
|
|
|
// Validated here rather than at the gate, so the problem names the line
|
|
// that caused it.
|
|
if g.title.is_empty() {
|
|
problems.push(GestureProblem {
|
|
file: path.to_string(),
|
|
line: start + 1,
|
|
what: "gesture has no title".into(),
|
|
});
|
|
}
|
|
if g.section.is_empty() {
|
|
problems.push(GestureProblem {
|
|
file: path.to_string(),
|
|
line: start + 1,
|
|
what: format!("gesture `{}` has no `where:`", g.title),
|
|
});
|
|
}
|
|
if g.routes().is_empty() {
|
|
problems.push(GestureProblem {
|
|
file: path.to_string(),
|
|
line: start + 1,
|
|
what: format!(
|
|
"gesture `{}` says how to do it in no modality — needs at least one of \
|
|
touch, pointer or keys",
|
|
g.title
|
|
),
|
|
});
|
|
}
|
|
out.push(g);
|
|
}
|
|
|
|
(out, problems)
|
|
}
|
|
|
|
/// The text of a comment line, marker stripped. `None` for anything else.
|
|
///
|
|
/// `///` and `//!` are stripped to the same thing: a gesture block written as a
|
|
/// doc comment on the function that implements it is the natural place for one,
|
|
/// and it should not need a different tag.
|
|
fn comment_body(line: &str) -> Option<&str> {
|
|
let t = line.trim_start();
|
|
let rest = t.strip_prefix("//")?;
|
|
Some(
|
|
rest.strip_prefix('!')
|
|
.or_else(|| rest.strip_prefix('/'))
|
|
.unwrap_or(rest),
|
|
)
|
|
}
|
|
|
|
/// Split ` key: value` into its halves.
|
|
///
|
|
/// The key must be a bare word against the left of the comment body, which is
|
|
/// what keeps a colon inside prose — "so: the run is resolved by the catalog" —
|
|
/// from being read as a new field. A continuation is indented past its key, and
|
|
/// the key it continues is a single word; requiring both is enough to tell them
|
|
/// apart without counting spaces.
|
|
fn split_key(body: &str) -> Option<(String, String)> {
|
|
let (head, tail) = body.split_once(':')?;
|
|
let key = head.trim();
|
|
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphabetic()) {
|
|
return None;
|
|
}
|
|
// A key sits at the start of the comment body, under a shallow indent. A
|
|
// continuation line is pushed further right to line up under its value.
|
|
let indent = body.len() - body.trim_start().len();
|
|
if indent > 5 {
|
|
return None;
|
|
}
|
|
Some((key.to_ascii_lowercase(), tail.to_string()))
|
|
}
|
|
|
|
/// Group gestures under their `where:`, keeping the order the sections were
|
|
/// first seen so the document reads in the order a user meets them.
|
|
pub fn by_section(gestures: &[Gesture]) -> Vec<(String, Vec<&Gesture>)> {
|
|
let mut order: Vec<String> = Vec::new();
|
|
let mut map: BTreeMap<String, Vec<&Gesture>> = BTreeMap::new();
|
|
for g in gestures {
|
|
if !map.contains_key(&g.section) {
|
|
order.push(g.section.clone());
|
|
}
|
|
map.entry(g.section.clone()).or_default().push(g);
|
|
}
|
|
order
|
|
.into_iter()
|
|
.map(|s| {
|
|
let v = map.remove(&s).unwrap_or_default();
|
|
(s, v)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ── rendering ─────────────────────────────────────────────────────────────
|
|
|
|
/// The reader's artefact: `docs/gestures.md`.
|
|
///
|
|
/// Carries `why` where the application's sheet does not. Someone reading the
|
|
/// document is deciding whether a gesture is right; someone holding the tablet
|
|
/// has already decided and wants the move.
|
|
pub fn render_markdown(gestures: &[Gesture]) -> String {
|
|
let mut m = String::new();
|
|
m.push_str("# How the application is driven\n\n");
|
|
m.push_str("<!-- GENERATED FILE — do not edit by hand. -->\n");
|
|
m.push_str("<!-- Regenerate: cargo run -p traceability -- gestures -->\n\n");
|
|
m.push_str(
|
|
"Every entry here is extracted from the comment beside the code that implements it, \
|
|
so this file cannot describe a gesture the application does not have. Add one by \
|
|
writing a `GESTURE:` block next to the implementation; there is nowhere else to \
|
|
write it.\n\n",
|
|
);
|
|
let places = by_section(gestures).len();
|
|
m.push_str(&format!(
|
|
"{} gesture{}, in {} place{}.\n",
|
|
gestures.len(),
|
|
if gestures.len() == 1 { "" } else { "s" },
|
|
places,
|
|
if places == 1 { "" } else { "s" },
|
|
));
|
|
|
|
for (section, list) in by_section(gestures) {
|
|
m.push_str(&format!("\n## {section}\n"));
|
|
for g in list {
|
|
m.push_str(&format!("\n### {}\n\n", g.title));
|
|
for (modality, how) in g.routes() {
|
|
m.push_str(&format!("- **{modality}** — {how}\n"));
|
|
}
|
|
if let Some(why) = &g.why {
|
|
m.push_str(&format!("\n{why}\n"));
|
|
}
|
|
m.push_str(&format!("\n<sub>`{}:{}`</sub>\n", g.file, g.line));
|
|
}
|
|
}
|
|
m
|
|
}
|
|
|
|
/// The application's artefact: a Rust table it displays.
|
|
///
|
|
/// Generated and *committed*, not built in a `build.rs`. Two reasons. A
|
|
/// generated file that only exists in `OUT_DIR` cannot be read in review, and
|
|
/// this one is user-facing text that should be reviewed like any other. And the
|
|
/// scanner walks the whole source tree, which is not something to do on every
|
|
/// incremental build of the crate being scanned.
|
|
///
|
|
/// `why` is deliberately not carried across. It is the argument for the design
|
|
/// and belongs in the document; putting it on a phone-sized help sheet would
|
|
/// bury the one line the user opened the sheet to read.
|
|
pub fn render_rust(gestures: &[Gesture]) -> String {
|
|
let mut r = String::new();
|
|
r.push_str("// @generated by `cargo run -p traceability -- gestures`. Do not edit.\n");
|
|
r.push_str("//\n");
|
|
r.push_str("// TRACES: FR-UI-4\n");
|
|
r.push_str("//! The gesture vocabulary, as the application states it.\n");
|
|
r.push_str("//!\n");
|
|
r.push_str("//! Extracted from the tagged comments beside the code that implements each\n");
|
|
r.push_str("//! one, so the help sheet cannot describe a gesture the application does not\n");
|
|
r.push_str("//! have. Edit the comment beside the implementation, then regenerate with\n");
|
|
r.push_str("//! `cargo run -p traceability -- gestures`.\n\n");
|
|
|
|
r.push_str("/// One documented interaction, as drawn.\n");
|
|
r.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n");
|
|
r.push_str("pub struct Gesture {\n");
|
|
r.push_str(" pub title: &'static str,\n");
|
|
r.push_str(" /// The place it applies, and the heading it is grouped under.\n");
|
|
r.push_str(" pub section: &'static str,\n");
|
|
r.push_str(" /// Empty where the gesture has no counterpart in that modality.\n");
|
|
r.push_str(" pub touch: &'static str,\n");
|
|
r.push_str(" pub pointer: &'static str,\n");
|
|
r.push_str(" pub keys: &'static str,\n");
|
|
r.push_str("}\n\n");
|
|
|
|
r.push_str("/// Every gesture, in the order the sections were first seen in the source.\n");
|
|
r.push_str("pub const GESTURES: &[Gesture] = &[\n");
|
|
for (_, list) in by_section(gestures) {
|
|
for g in list {
|
|
r.push_str(" Gesture {\n");
|
|
r.push_str(&format!(" title: {},\n", quote(&g.title)));
|
|
r.push_str(&format!(" section: {},\n", quote(&g.section)));
|
|
r.push_str(&format!(
|
|
" touch: {},\n",
|
|
quote(g.touch.as_deref().unwrap_or(""))
|
|
));
|
|
r.push_str(&format!(
|
|
" pointer: {},\n",
|
|
quote(g.pointer.as_deref().unwrap_or(""))
|
|
));
|
|
r.push_str(&format!(
|
|
" keys: {},\n",
|
|
quote(g.keys.as_deref().unwrap_or(""))
|
|
));
|
|
r.push_str(" },\n");
|
|
}
|
|
}
|
|
r.push_str("];\n");
|
|
r
|
|
}
|
|
|
|
/// A Rust string literal for arbitrary gesture text.
|
|
///
|
|
/// The text is prose written by hand and routinely contains quotation marks —
|
|
/// `"Select to…", then tap the last photograph` — so escaping is not optional.
|
|
fn quote(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len() + 2);
|
|
out.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
_ => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The shape every real tag is written in.
|
|
const SAMPLE: &str = "\
|
|
// GESTURE: Select a range
|
|
// where: Library grid
|
|
// touch: \"Select to…\", then tap the last photograph
|
|
// pointer: Shift-click
|
|
// why: A finger sweep runs out at the edge of the
|
|
// screen.
|
|
property <bool> ranging: false;
|
|
";
|
|
|
|
#[test]
|
|
fn a_block_parses_every_field() {
|
|
let (g, p) = extract_from_text(SAMPLE, "library.slint");
|
|
assert!(p.is_empty(), "{p:?}");
|
|
assert_eq!(g.len(), 1);
|
|
assert_eq!(g[0].title, "Select a range");
|
|
assert_eq!(g[0].section, "Library grid");
|
|
assert_eq!(
|
|
g[0].touch.as_deref(),
|
|
Some("\"Select to…\", then tap the last photograph")
|
|
);
|
|
assert_eq!(g[0].pointer.as_deref(), Some("Shift-click"));
|
|
assert_eq!(g[0].line, 1);
|
|
}
|
|
|
|
/// The source wraps where the file wraps; the sentence does not.
|
|
#[test]
|
|
fn a_continuation_joins_with_one_space() {
|
|
let (g, _) = extract_from_text(SAMPLE, "x.slint");
|
|
assert_eq!(
|
|
g[0].why.as_deref(),
|
|
Some("A finger sweep runs out at the edge of the screen.")
|
|
);
|
|
}
|
|
|
|
/// A colon in prose is not a new field, or `why` could never contain one.
|
|
#[test]
|
|
fn a_colon_inside_a_continuation_is_not_a_key() {
|
|
let text = "\
|
|
// GESTURE: Hold to select
|
|
// where: Library grid
|
|
// touch: Press and hold
|
|
// why: The platform convention: every gallery on the device
|
|
// opens a selection this way.
|
|
";
|
|
let (g, p) = extract_from_text(text, "x.rs");
|
|
assert!(p.is_empty(), "{p:?}");
|
|
assert_eq!(
|
|
g[0].why.as_deref(),
|
|
Some(
|
|
"The platform convention: every gallery on the device opens a selection this way."
|
|
)
|
|
);
|
|
}
|
|
|
|
/// A typo must not quietly cost a gesture its desktop half.
|
|
#[test]
|
|
fn an_unknown_key_is_reported() {
|
|
let text = "\
|
|
// GESTURE: Open a photograph
|
|
// where: Library grid
|
|
// touch: Tap it
|
|
// pointr: Click it
|
|
";
|
|
let (_, p) = extract_from_text(text, "x.rs");
|
|
assert_eq!(p.len(), 1, "{p:?}");
|
|
assert!(p[0].what.contains("pointr"), "{}", p[0].what);
|
|
}
|
|
|
|
/// A gesture nobody can perform is a documentation bug, not a gesture.
|
|
#[test]
|
|
fn a_gesture_with_no_modality_is_reported() {
|
|
let text = "// GESTURE: Do a thing\n// where: Somewhere\n";
|
|
let (_, p) = extract_from_text(text, "x.rs");
|
|
assert!(p.iter().any(|x| x.what.contains("no modality")), "{p:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_gesture_with_no_place_is_reported() {
|
|
let text = "// GESTURE: Do a thing\n// touch: Tap\n";
|
|
let (_, p) = extract_from_text(text, "x.rs");
|
|
assert!(p.iter().any(|x| x.what.contains("`where:`")), "{p:?}");
|
|
}
|
|
|
|
/// The tag inside this crate's own doc comments and test fixtures must not
|
|
/// become a gesture in the user's help sheet.
|
|
#[test]
|
|
fn a_tag_inside_a_string_literal_is_not_a_gesture() {
|
|
let text = "let s = \"GESTURE: not a real one\";\n";
|
|
let (g, p) = extract_from_text(text, "x.rs");
|
|
assert!(g.is_empty(), "{g:?}");
|
|
assert!(p.is_empty());
|
|
}
|
|
|
|
/// Prose *about* the vocabulary is not a member of it. The gate caught this
|
|
/// exact sentence in `dr-ui`'s module list, where a comment explaining
|
|
/// where the generated table comes from named the tag in passing.
|
|
#[test]
|
|
fn a_sentence_that_mentions_the_tag_is_not_a_gesture() {
|
|
let text = "\
|
|
// Generated from the `GESTURE:` comments beside the code that implements
|
|
// each one — see `tools/traceability`.
|
|
mod gesture_book;
|
|
";
|
|
let (g, p) = extract_from_text(text, "lib.rs");
|
|
assert!(g.is_empty(), "{g:?}");
|
|
assert!(p.is_empty(), "{p:?}");
|
|
}
|
|
|
|
/// A blank comment line ends the block, so ordinary prose beneath a gesture
|
|
/// is not swallowed into its `why`.
|
|
#[test]
|
|
fn a_blank_comment_line_ends_the_block() {
|
|
let text = "\
|
|
// GESTURE: Tap to open
|
|
// where: Library grid
|
|
// touch: Tap a photograph
|
|
//
|
|
// This paragraph is about something else entirely.
|
|
";
|
|
let (g, _) = extract_from_text(text, "x.rs");
|
|
assert_eq!(g[0].touch.as_deref(), Some("Tap a photograph"));
|
|
assert!(g[0].why.is_none(), "{:?}", g[0].why);
|
|
}
|
|
|
|
/// A doc comment is the natural place for a gesture on the function that
|
|
/// implements it, and must not need a different tag.
|
|
#[test]
|
|
fn a_doc_comment_carries_a_gesture_too() {
|
|
let text = "\
|
|
/// GESTURE: Pinch the grid
|
|
/// where: Library grid
|
|
/// touch: Pinch to resize the thumbnails
|
|
pub fn zoom() {}
|
|
";
|
|
let (g, p) = extract_from_text(text, "x.rs");
|
|
assert!(p.is_empty(), "{p:?}");
|
|
assert_eq!(g[0].title, "Pinch the grid");
|
|
}
|
|
|
|
/// Two blocks back to back, which is how a screen's gestures get listed.
|
|
#[test]
|
|
fn one_block_does_not_swallow_the_next() {
|
|
let text = "\
|
|
// GESTURE: First
|
|
// where: Grid
|
|
// touch: Tap
|
|
// GESTURE: Second
|
|
// where: Grid
|
|
// touch: Hold
|
|
";
|
|
let (g, p) = extract_from_text(text, "x.rs");
|
|
assert!(p.is_empty(), "{p:?}");
|
|
assert_eq!(g.len(), 2, "{g:?}");
|
|
assert_eq!(g[0].touch.as_deref(), Some("Tap"));
|
|
assert_eq!(g[1].title, "Second");
|
|
}
|
|
|
|
#[test]
|
|
fn sections_keep_the_order_they_were_first_seen() {
|
|
let text = "\
|
|
// GESTURE: B
|
|
// where: Second
|
|
// touch: x
|
|
// GESTURE: A
|
|
// where: First
|
|
// touch: x
|
|
// GESTURE: C
|
|
// where: Second
|
|
// touch: x
|
|
";
|
|
let (g, _) = extract_from_text(text, "x.rs");
|
|
let grouped = by_section(&g);
|
|
assert_eq!(grouped[0].0, "Second");
|
|
assert_eq!(grouped[0].1.len(), 2);
|
|
assert_eq!(grouped[1].0, "First");
|
|
}
|
|
}
|