|
1 |
+ |
//! Forward fence, the hand-written `hx-confirm` ratchet.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Task `b279b9eb`, and the half of its done condition no single change can
|
|
4 |
+ |
//! reach: "no template writes `hx-confirm` by hand". Asking before a destructive
|
|
5 |
+ |
//! act is a property of the act, so it belongs in the description
|
|
6 |
+ |
//! (`quasi_router::Act::confirm`, `screen.rs:2787` with its builder at `:2932`),
|
|
7 |
+ |
//! where a terminal host asks in its own way and no renderer can forget to ask.
|
|
8 |
+ |
//! An attribute typed into Askama says it to one host only.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! There are 50 of them across 33 files and every one leaves as its screen is
|
|
11 |
+ |
//! described, so the condition is met by the conversion program rather than by
|
|
12 |
+ |
//! an edit. What this seal does is make that monotone: a new template writing
|
|
13 |
+ |
//! the attribute by hand fails here, and every conversion lowers the number.
|
|
14 |
+ |
//!
|
|
15 |
+ |
//! # What this seal is not
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! It does not, and cannot, say which of the 50 acts destroy something. Only 29
|
|
18 |
+ |
//! of the 50 carry a danger class within the six preceding lines, and the
|
|
19 |
+ |
//! disagreement runs both ways: `templates/partials/tabs/item_details.html` has
|
|
20 |
+ |
//! four confirms and no danger class at all while three of its four acts are
|
|
21 |
+ |
//! destructive, and `templates/partials/admin_user_entries.html` has four danger
|
|
22 |
+ |
//! classes for four confirms while two of those acts (Unlock at `:60`, Unsuspend
|
|
23 |
+ |
//! at `:80`) restore rather than destroy. Ten of the fifty are non-destructive
|
|
24 |
+ |
//! and keep their confirm without taking `Tone::Danger`. So a converting change
|
|
25 |
+ |
//! reads its own sites; the markup is not evidence in either direction.
|
|
26 |
+ |
//!
|
|
27 |
+ |
//! The converted side is asserted where it is written, not here: each described
|
|
28 |
+ |
//! screen's own tests check that its destructive acts carry both marks and that
|
|
29 |
+ |
//! its non-destructive ones carry neither (`src/quasi/ssh_keys.rs`,
|
|
30 |
+ |
//! `library_contacts.rs`, `forum_memberships.rs`).
|
|
31 |
+ |
|
|
32 |
+ |
use std::fs;
|
|
33 |
+ |
use std::path::{Path, PathBuf};
|
|
34 |
+ |
|
|
35 |
+ |
/// Ratchets down only, never up.
|
|
36 |
+ |
///
|
|
37 |
+ |
/// 50 across 33 files on 2026-08-20, the measurement the task was written
|
|
38 |
+ |
/// against and unchanged since.
|
|
39 |
+ |
///
|
|
40 |
+ |
/// Three of the fifty are not this program's to remove and will be the last to
|
|
41 |
+ |
/// go: `partials/ssh_keys_list.html`, `partials/git_tokens_list.html` and
|
|
42 |
+ |
/// `partials/tabs/library_contacts.html` back screens that are already
|
|
43 |
+ |
/// described, and are still live Askama structs because the conversions are
|
|
44 |
+ |
/// dual-serve while `QUASI_SCREENS` serves nobody. They leave with the templates
|
|
45 |
+ |
/// when the switch ships, which is task `64b33b26`.
|
|
46 |
+ |
const HIGH_WATER: usize = 50;
|
|
47 |
+ |
|
|
48 |
+ |
/// Every template, recursively. Walked rather than listed for the reason the
|
|
49 |
+ |
/// frontend-globals seal walks `frontend/src`: a fence that has to be told about
|
|
50 |
+ |
/// each new file silently stops covering the tree.
|
|
51 |
+ |
fn templates() -> Vec<PathBuf> {
|
|
52 |
+ |
let mut files = Vec::new();
|
|
53 |
+ |
collect(
|
|
54 |
+ |
&Path::new(env!("CARGO_MANIFEST_DIR")).join("templates"),
|
|
55 |
+ |
&mut files,
|
|
56 |
+ |
);
|
|
57 |
+ |
files.sort();
|
|
58 |
+ |
files
|
|
59 |
+ |
}
|
|
60 |
+ |
|
|
61 |
+ |
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
|
62 |
+ |
let Ok(entries) = fs::read_dir(dir) else {
|
|
63 |
+ |
return;
|
|
64 |
+ |
};
|
|
65 |
+ |
for entry in entries {
|
|
66 |
+ |
let path = entry.expect("dir entry").path();
|
|
67 |
+ |
if path.is_dir() {
|
|
68 |
+ |
collect(&path, out);
|
|
69 |
+ |
} else if path.extension().and_then(|e| e.to_str()) == Some("html") {
|
|
70 |
+ |
out.push(path);
|
|
71 |
+ |
}
|
|
72 |
+ |
}
|
|
73 |
+ |
}
|
|
74 |
+ |
|
|
75 |
+ |
/// Where the attribute is written, one line per site, sorted by path.
|
|
76 |
+ |
///
|
|
77 |
+ |
/// The failure message carries the list rather than a bare number, because a
|
|
78 |
+ |
/// ratchet that only says "it rose" leaves the reader to find the site.
|
|
79 |
+ |
fn sites() -> Vec<String> {
|
|
80 |
+ |
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
81 |
+ |
let mut found = Vec::new();
|
|
82 |
+ |
|
|
83 |
+ |
for path in templates() {
|
|
84 |
+ |
let src = fs::read_to_string(&path).unwrap_or_default();
|
|
85 |
+ |
let shown = path
|
|
86 |
+ |
.strip_prefix(root)
|
|
87 |
+ |
.unwrap_or(&path)
|
|
88 |
+ |
.display()
|
|
89 |
+ |
.to_string();
|
|
90 |
+ |
for (index, line) in src.lines().enumerate() {
|
|
91 |
+ |
for _ in 0..line.matches("hx-confirm").count() {
|
|
92 |
+ |
found.push(format!("{shown}:{}", index + 1));
|
|
93 |
+ |
}
|
|
94 |
+ |
}
|
|
95 |
+ |
}
|
|
96 |
+ |
|
|
97 |
+ |
found
|
|
98 |
+ |
}
|
|
99 |
+ |
|
|
100 |
+ |
#[test]
|
|
101 |
+ |
fn hand_written_confirms_do_not_grow() {
|
|
102 |
+ |
let found = sites();
|
|
103 |
+ |
let count = found.len();
|
|
104 |
+ |
|
|
105 |
+ |
assert!(
|
|
106 |
+ |
count <= HIGH_WATER,
|
|
107 |
+ |
"hand-written hx-confirm rose to {count} (HIGH_WATER {HIGH_WATER}).\n\
|
|
108 |
+ |
Asking before an act is the act's own property: say it with \
|
|
109 |
+ |
Act::confirm in the described screen, not as an attribute in Askama. \
|
|
110 |
+ |
If you CONVERTED sites, lower HIGH_WATER to {count}.\n{}",
|
|
111 |
+ |
found.join("\n")
|
|
112 |
+ |
);
|
|
113 |
+ |
|
|
114 |
+ |
assert_eq!(
|
|
115 |
+ |
count, HIGH_WATER,
|
|
116 |
+ |
"hand-written hx-confirm fell to {count}. Lower HIGH_WATER to {count}, \
|
|
117 |
+ |
and say in its doc comment which screen took them.",
|
|
118 |
+ |
);
|
|
119 |
+ |
}
|
|
120 |
+ |
|
|
121 |
+ |
#[test]
|
|
122 |
+ |
fn the_seal_reads_every_site_and_not_every_file() {
|
|
123 |
+ |
// Four files hold more than one, so a fence counting files rather than
|
|
124 |
+ |
// occurrences would report a conversion that removed two of four as
|
|
125 |
+ |
// progress on neither. The count is of sites.
|
|
126 |
+ |
let found = sites();
|
|
127 |
+ |
|
|
128 |
+ |
assert!(
|
|
129 |
+ |
found.iter().any(|site| site.contains("item_details.html")),
|
|
130 |
+ |
"item_details holds four of them: {found:?}"
|
|
131 |
+ |
);
|
|
132 |
+ |
assert_eq!(
|
|
133 |
+ |
found
|
|
134 |
+ |
.iter()
|
|
135 |
+ |
.filter(|site| site.contains("item_details.html"))
|
|
136 |
+ |
.count(),
|
|
137 |
+ |
4,
|
|
138 |
+ |
"all four, not the file once: {found:?}"
|
|
139 |
+ |
);
|
|
140 |
+ |
}
|