Extract the gesture vocabulary from the code that implements it
Every gesture the application has was documented in the comment beside the `TouchArea` that implements it. Excellent comments, and unreachable by anyone not reading the source — which is the FR-UI-4 failure in a different costume: a gesture nobody can find is a feature only its author knows about. Writing them out again in a hand-kept help page is the failure this avoids. Two descriptions of one gesture drift, and it is always the prose that drifts: the code is exercised every time somebody uses the application and the page is exercised never. A help screen confidently describing a double tap the grid stopped honouring last week is worse than no help screen — and the grid did stop honouring one, in the commit before this. So the comment beside the implementation stays the only copy, and a `GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md` for a reader, and a Rust table for the application to draw a help sheet from. Both committed, both gated, so neither can quietly stop describing the code. It lives in the traceability crate because it is the same operation on the same input — walk the tree, pull structured tags out of comments, render, fail if the committed artefact has moved. Only the vocabulary is new. It scans `ui` and `apps` alone: a gesture needs an interface to be performed on, and excluding `tools` is also what stops the scanner extracting its own worked examples as broken gestures. Fifteen gestures so far, across the library grid and the People screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
//! TRACES: FR-UI-4 | NFR-OPS-1
|
||||
//! 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.
|
||||
//!
|
||||
//! A block runs from the `GESTURE:` 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() {
|
||||
let Some(pos) = lines[i].find("GESTURE:") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
// Only in a comment. A `GESTURE:` inside a string literal — this very
|
||||
// file's doc comment shows the tag, and so does the scanner's own test
|
||||
// fixture — is not a tag.
|
||||
if !is_comment(lines[i]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = i;
|
||||
let title = lines[i][pos + "GESTURE:".len()..].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)
|
||||
}
|
||||
|
||||
/// Whether a line is a `//` comment and nothing else of substance before it.
|
||||
///
|
||||
/// Deliberately strict about what precedes the marker: `let s = "// GESTURE:"`
|
||||
/// has code in front of it and is not a comment line. Trailing comments after
|
||||
/// code are not a place gesture blocks are written, so refusing them costs
|
||||
/// nothing and keeps string literals out of the document.
|
||||
fn is_comment(line: &str) -> bool {
|
||||
let t = line.trim_start();
|
||||
t.starts_with("//")
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user