| 4 |
4 |
|
//! - `docs/business/assumptions.toml` parses
|
| 5 |
5 |
|
//! - All consistency rules pass (sums, bounds, founding ≤ standard)
|
| 6 |
6 |
|
//! - Every `{{ ... }}` marker in the live site-docs corpus resolves
|
|
7 |
+ |
//! - No *bare* copy of a linted assumption value survives in the corpus
|
|
8 |
+ |
//! - The `[synckit]` mirror still matches the authoritative Rust constants
|
| 7 |
9 |
|
//!
|
| 8 |
10 |
|
//! Run with: cargo test --test assumptions
|
| 9 |
11 |
|
|
| 10 |
|
- |
use docengine::Assumptions;
|
|
12 |
+ |
use makenotwork::synckit_billing;
|
|
13 |
+ |
use mnw_assumptions::{Assumptions, code_span_ranges};
|
| 11 |
14 |
|
|
| 12 |
15 |
|
// Canonical assumptions.toml ships with the repo at server/docs/business/.
|
| 13 |
16 |
|
// Cargo runs tests with cwd = crate manifest dir (MNW/server/), so the
|
| 45 |
48 |
|
);
|
| 46 |
49 |
|
}
|
| 47 |
50 |
|
|
|
51 |
+ |
/// `[synckit]` in assumptions.toml mirrors constants that live in Rust, so the
|
|
52 |
+ |
/// public developer docs can substitute them. Rust is authoritative; this test
|
|
53 |
+ |
/// is the drift guard, the same shape as the `tier_limits` ↔ `tier_bytes` rule
|
|
54 |
+ |
/// inside `Assumptions::validate`.
|
|
55 |
+ |
#[test]
|
|
56 |
+ |
fn synckit_block_mirrors_billing_constants() {
|
|
57 |
+ |
let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
|
|
58 |
+ |
let num = |key: &str| {
|
|
59 |
+ |
a.get(key)
|
|
60 |
+ |
.unwrap_or_else(|| panic!("missing key {key}"))
|
|
61 |
+ |
.as_f64()
|
|
62 |
+ |
.unwrap_or_else(|| panic!("{key} is not numeric"))
|
|
63 |
+ |
};
|
|
64 |
+ |
|
|
65 |
+ |
// Both sides are money, so compare in whole cents rather than comparing
|
|
66 |
+ |
// f64s for equality.
|
|
67 |
+ |
let cents = |key: &str| (num(key) * 100.0).round() as i64;
|
|
68 |
+ |
|
|
69 |
+ |
assert_eq!(
|
|
70 |
+ |
cents("synckit.storage_usd_per_gb_month"),
|
|
71 |
+ |
synckit_billing::STORAGE_RATE_CENTS_PER_GB,
|
|
72 |
+ |
"synckit.storage_usd_per_gb_month != STORAGE_RATE_CENTS_PER_GB"
|
|
73 |
+ |
);
|
|
74 |
+ |
assert_eq!(
|
|
75 |
+ |
cents("synckit.invoice_floor_usd"),
|
|
76 |
+ |
synckit_billing::BASE_FLOOR_CENTS,
|
|
77 |
+ |
"synckit.invoice_floor_usd != BASE_FLOOR_CENTS"
|
|
78 |
+ |
);
|
|
79 |
+ |
assert_eq!(
|
|
80 |
+ |
num("synckit.max_priced_gb").round() as i64,
|
|
81 |
+ |
synckit_billing::MAX_STORAGE_GB,
|
|
82 |
+ |
"synckit.max_priced_gb != MAX_STORAGE_GB"
|
|
83 |
+ |
);
|
|
84 |
+ |
}
|
|
85 |
+ |
|
|
86 |
+ |
// --- bare-value lint
|
|
87 |
+ |
|
|
88 |
+ |
/// How a linted key renders when a writer types it by hand. Each variant maps
|
|
89 |
+ |
/// to the marker + filter a doc would use, so the searched-for literal is
|
|
90 |
+ |
/// exactly what substitution would have produced.
|
|
91 |
+ |
#[derive(Copy, Clone)]
|
|
92 |
+ |
enum Form {
|
|
93 |
+ |
/// `{{ k | money }}` — `$0.30`.
|
|
94 |
+ |
Money,
|
|
95 |
+ |
/// `{{ k | percent }}` — `2.9%`.
|
|
96 |
+ |
Percent,
|
|
97 |
+ |
/// `${{ k }}` — `$580`, plus the comma-grouped hand-written form.
|
|
98 |
+ |
Dollars,
|
|
99 |
+ |
/// `{{ k }}` — the value verbatim (already a display string).
|
|
100 |
+ |
Verbatim,
|
|
101 |
+ |
}
|
|
102 |
+ |
|
|
103 |
+ |
/// Assumption values distinctive enough that a bare occurrence in the corpus
|
|
104 |
+ |
/// is a stale-copy bug rather than a coincidence.
|
|
105 |
+ |
///
|
|
106 |
+ |
/// Deliberately NOT linted: the tier prices and Fan+ prices (small dollar
|
|
107 |
+ |
/// amounts like `$8` collide with illustrative sale prices all over the guide),
|
|
108 |
+ |
/// the tier envelope sizes (`5 MB` is also the sandbox per-file cap, `500 MB`
|
|
109 |
+ |
/// the export cap — different limits that happen to share a number), and
|
|
110 |
+ |
/// `uptime.target_downtime_hours_year` (a bare `44` is noise). Those still
|
|
111 |
+ |
/// carry markers in the corpus; they just can't be policed by string search.
|
|
112 |
+ |
const LINTED: &[(&str, Form)] = &[
|
|
113 |
+ |
("expenses.F_monthly", Form::Dollars),
|
|
114 |
+ |
("stripe.percent", Form::Percent),
|
|
115 |
+ |
("stripe.fixed", Form::Money),
|
|
116 |
+ |
("stripe.dispute_fee", Form::Money),
|
|
117 |
+ |
("uptime.target_pct", Form::Percent),
|
|
118 |
+ |
("derived.R_cap", Form::Dollars),
|
|
119 |
+ |
("cohort.cap_display", Form::Verbatim),
|
|
120 |
+ |
("broadcasts.recipients_per_send_display", Form::Verbatim),
|
|
121 |
+ |
("synckit.storage_usd_per_gb_month", Form::Money),
|
|
122 |
+ |
("synckit.invoice_floor_usd", Form::Money),
|
|
123 |
+ |
("derived.annual_founding_basic", Form::Dollars),
|
|
124 |
+ |
("derived.annual_founding_small_files", Form::Dollars),
|
|
125 |
+ |
("derived.annual_founding_big_files", Form::Dollars),
|
|
126 |
+ |
("derived.annual_founding_everything", Form::Dollars),
|
|
127 |
+ |
("derived.annual_standard_basic", Form::Dollars),
|
|
128 |
+ |
("derived.annual_standard_small_files", Form::Dollars),
|
|
129 |
+ |
("derived.annual_standard_big_files", Form::Dollars),
|
|
130 |
+ |
("derived.annual_standard_everything", Form::Dollars),
|
|
131 |
+ |
];
|
|
132 |
+ |
|
|
133 |
+ |
/// Lines that legitimately carry a linted literal. Matched as
|
|
134 |
+ |
/// (path suffix, substring of the offending line, reason) — a substring rather
|
|
135 |
+ |
/// than a line number so ordinary edits above don't invalidate the waiver.
|
|
136 |
+ |
const EXEMPT: &[(&str, &str, &str)] = &[
|
|
137 |
+ |
(
|
|
138 |
+ |
"guide/git.md",
|
|
139 |
+ |
"10,000 lines",
|
|
140 |
+ |
"commit-diff display cap, unrelated to the broadcast recipient cap",
|
|
141 |
+ |
),
|
|
142 |
+ |
(
|
|
143 |
+ |
"guide/items.md",
|
|
144 |
+ |
"10,000 characters",
|
|
145 |
+ |
"changelog field length, unrelated to the broadcast recipient cap",
|
|
146 |
+ |
),
|
|
147 |
+ |
(
|
|
148 |
+ |
"about/compare.md",
|
|
149 |
+ |
"1,000 sales",
|
|
150 |
+ |
"illustrative transaction count in a revenue example, not the cohort cap",
|
|
151 |
+ |
),
|
|
152 |
+ |
(
|
|
153 |
+ |
"about/how-we-work.md",
|
|
154 |
+ |
"1,000 transactions",
|
|
155 |
+ |
"illustrative transaction count in a revenue example, not the cohort cap",
|
|
156 |
+ |
),
|
|
157 |
+ |
(
|
|
158 |
+ |
"guide/stripe.md",
|
|
159 |
+ |
"| Canada |",
|
|
160 |
+ |
"Canadian Stripe rate (C$), coincidentally the same digits as the US rate; \
|
|
161 |
+ |
only the US row is ours to substitute",
|
|
162 |
+ |
),
|
|
163 |
+ |
(
|
|
164 |
+ |
"guide/stripe.md",
|
|
165 |
+ |
"| Australia |",
|
|
166 |
+ |
"Australian Stripe rate (A$), not the US flat fee",
|
|
167 |
+ |
),
|
|
168 |
+ |
];
|
|
169 |
+ |
|
|
170 |
+ |
#[test]
|
|
171 |
+ |
fn no_bare_assumption_values_in_site_docs() {
|
|
172 |
+ |
let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load");
|
|
173 |
+ |
|
|
174 |
+ |
// Render each linted key through the engine itself, so the literal we hunt
|
|
175 |
+ |
// for is byte-identical to what the marker would have emitted.
|
|
176 |
+ |
let mut needles: Vec<(&str, String)> = Vec::new();
|
|
177 |
+ |
for &(key, form) in LINTED {
|
|
178 |
+ |
assert!(a.get(key).is_some(), "linted key {key} is not in the table");
|
|
179 |
+ |
let render = |expr: &str| {
|
|
180 |
+ |
a.substitute(&format!("{{{{ {expr} }}}}"))
|
|
181 |
+ |
.unwrap_or_else(|e| panic!("cannot render {expr}: {e}"))
|
|
182 |
+ |
};
|
|
183 |
+ |
match form {
|
|
184 |
+ |
Form::Money => needles.push((key, render(&format!("{key} | money")))),
|
|
185 |
+ |
Form::Percent => needles.push((key, render(&format!("{key} | percent")))),
|
|
186 |
+ |
Form::Verbatim => needles.push((key, render(key))),
|
|
187 |
+ |
Form::Dollars => {
|
|
188 |
+ |
let plain = render(key);
|
|
189 |
+ |
needles.push((key, format!("${plain}")));
|
|
190 |
+ |
let grouped = group_thousands(&plain);
|
|
191 |
+ |
if grouped != plain {
|
|
192 |
+ |
needles.push((key, format!("${grouped}")));
|
|
193 |
+ |
}
|
|
194 |
+ |
}
|
|
195 |
+ |
}
|
|
196 |
+ |
}
|
|
197 |
+ |
|
|
198 |
+ |
let mut findings = Vec::new();
|
|
199 |
+ |
visit_markdown(std::path::Path::new(SITE_DOCS_PATH), &mut |path, body| {
|
|
200 |
+ |
let display = path.display().to_string();
|
|
201 |
+ |
let code = code_span_ranges(body);
|
|
202 |
+ |
let prose = strip_ranges(body, &code);
|
|
203 |
+ |
let prose = strip_markers(&prose);
|
|
204 |
+ |
|
|
205 |
+ |
for (lineno, line) in prose.lines().enumerate() {
|
|
206 |
+ |
for (key, needle) in &needles {
|
|
207 |
+ |
if !contains_bare(line, needle) {
|
|
208 |
+ |
continue;
|
|
209 |
+ |
}
|
|
210 |
+ |
if EXEMPT
|
|
211 |
+ |
.iter()
|
|
212 |
+ |
.any(|(file, snippet, _)| display.ends_with(file) && line.contains(snippet))
|
|
213 |
+ |
{
|
|
214 |
+ |
continue;
|
|
215 |
+ |
}
|
|
216 |
+ |
findings.push(format!(
|
|
217 |
+ |
" {display}:{}: bare {needle:?} — use a {{{{ {key} }}}} marker\n {}",
|
|
218 |
+ |
lineno + 1,
|
|
219 |
+ |
line.trim()
|
|
220 |
+ |
));
|
|
221 |
+ |
}
|
|
222 |
+ |
}
|
|
223 |
+ |
});
|
|
224 |
+ |
|
|
225 |
+ |
assert!(
|
|
226 |
+ |
findings.is_empty(),
|
|
227 |
+ |
"{} bare assumption value(s) in the corpus \
|
|
228 |
+ |
(substitute them, or add an EXEMPT entry with a reason):\n{}",
|
|
229 |
+ |
findings.len(),
|
|
230 |
+ |
findings.join("\n")
|
|
231 |
+ |
);
|
|
232 |
+ |
}
|
|
233 |
+ |
|
|
234 |
+ |
/// A waiver that no longer matches anything is a waiver that should be
|
|
235 |
+ |
/// deleted, not carried. Keeps `EXEMPT` from silently outliving its reason.
|
|
236 |
+ |
#[test]
|
|
237 |
+ |
fn every_exemption_still_matches_a_line() {
|
|
238 |
+ |
let mut stale = Vec::new();
|
|
239 |
+ |
for (file, snippet, reason) in EXEMPT {
|
|
240 |
+ |
let path = std::path::Path::new(SITE_DOCS_PATH).join(file);
|
|
241 |
+ |
let matched = std::fs::read_to_string(&path)
|
|
242 |
+ |
.is_ok_and(|body| body.lines().any(|line| line.contains(*snippet)));
|
|
243 |
+ |
if !matched {
|
|
244 |
+ |
stale.push(format!(
|
|
245 |
+ |
" {file}: {snippet:?} no longer present ({reason})"
|
|
246 |
+ |
));
|
|
247 |
+ |
}
|
|
248 |
+ |
}
|
|
249 |
+ |
assert!(
|
|
250 |
+ |
stale.is_empty(),
|
|
251 |
+ |
"{} stale bare-value exemption(s), delete them:\n{}",
|
|
252 |
+ |
stale.len(),
|
|
253 |
+ |
stale.join("\n")
|
|
254 |
+ |
);
|
|
255 |
+ |
}
|
|
256 |
+ |
|
|
257 |
+ |
/// Blank out byte ranges (code spans, `{{ … }}` markers) with spaces, keeping
|
|
258 |
+ |
/// every other byte at its original offset so line numbers stay truthful.
|
|
259 |
+ |
fn strip_ranges(text: &str, ranges: &[(usize, usize)]) -> String {
|
|
260 |
+ |
let mut out: Vec<u8> = text.as_bytes().to_vec();
|
|
261 |
+ |
for &(start, end) in ranges {
|
|
262 |
+ |
for b in &mut out[start..end.min(text.len())] {
|
|
263 |
+ |
if *b != b'\n' {
|
|
264 |
+ |
*b = b' ';
|
|
265 |
+ |
}
|
|
266 |
+ |
}
|
|
267 |
+ |
}
|
|
268 |
+ |
String::from_utf8(out).expect("only ASCII bytes replaced")
|
|
269 |
+ |
}
|
|
270 |
+ |
|
|
271 |
+ |
/// Blank out `{{ … }}` markers: a value inside one is substituted, not bare.
|
|
272 |
+ |
fn strip_markers(text: &str) -> String {
|
|
273 |
+ |
let mut ranges = Vec::new();
|
|
274 |
+ |
let mut rest = text;
|
|
275 |
+ |
let mut base = 0;
|
|
276 |
+ |
while let Some(open) = rest.find("{{") {
|
|
277 |
+ |
let after = open + 2;
|
|
278 |
+ |
match rest[after..].find("}}") {
|
|
279 |
+ |
Some(rel) => {
|
|
280 |
+ |
let close = after + rel + 2;
|
|
281 |
+ |
ranges.push((base + open, base + close));
|
|
282 |
+ |
base += close;
|
|
283 |
+ |
rest = &rest[close..];
|
|
284 |
+ |
}
|
|
285 |
+ |
// Unclosed marker: nothing further to blank.
|
|
286 |
+ |
None => break,
|
|
287 |
+ |
}
|
|
288 |
+ |
}
|
|
289 |
+ |
strip_ranges(text, &ranges)
|
|
290 |
+ |
}
|
|
291 |
+ |
|
|
292 |
+ |
/// True when `needle` occurs in `line` as a whole number, not as a fragment of
|
|
293 |
+ |
/// a longer one. `$8` must not match inside `$8,410`, and `2.9%` must not match
|
|
294 |
+ |
/// inside `12.9%`.
|
|
295 |
+ |
fn contains_bare(line: &str, needle: &str) -> bool {
|
|
296 |
+ |
let bytes = line.as_bytes();
|
|
297 |
+ |
let mut from = 0;
|
|
298 |
+ |
while let Some(rel) = line[from..].find(needle) {
|
|
299 |
+ |
let start = from + rel;
|
|
300 |
+ |
let end = start + needle.len();
|
|
301 |
+ |
let before_ok = start == 0 || !matches!(bytes[start - 1], b'0'..=b'9' | b'.' | b',' | b'$');
|
|
302 |
+ |
let after_ok =
|
|
303 |
+ |
end == bytes.len() || !matches!(bytes[end], b'0'..=b'9' | b'.' | b',' | b'%');
|
|
304 |
+ |
if before_ok && after_ok {
|
|
305 |
+ |
return true;
|
|
306 |
+ |
}
|
|
307 |
+ |
from = end;
|
|
308 |
+ |
}
|
|
309 |
+ |
false
|
|
310 |
+ |
}
|
|
311 |
+ |
|
|
312 |
+ |
/// `61960` → `61,960`. Leaves anything with a decimal point alone.
|
|
313 |
+ |
fn group_thousands(digits: &str) -> String {
|
|
314 |
+ |
if !digits.bytes().all(|b| b.is_ascii_digit()) {
|
|
315 |
+ |
return digits.to_string();
|
|
316 |
+ |
}
|
|
317 |
+ |
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
|
318 |
+ |
let n = digits.len();
|
|
319 |
+ |
for (i, c) in digits.chars().enumerate() {
|
|
320 |
+ |
if i > 0 && (n - i).is_multiple_of(3) {
|
|
321 |
+ |
out.push(',');
|
|
322 |
+ |
}
|
|
323 |
+ |
out.push(c);
|
|
324 |
+ |
}
|
|
325 |
+ |
out
|
|
326 |
+ |
}
|
|
327 |
+ |
|
| 48 |
328 |
|
fn visit_markdown(dir: &std::path::Path, f: &mut impl FnMut(&std::path::Path, &str)) {
|
| 49 |
329 |
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
| 50 |
330 |
|
return;
|
| 60 |
340 |
|
}
|
| 61 |
341 |
|
}
|
| 62 |
342 |
|
}
|
|
343 |
+ |
|
|
344 |
+ |
mod lint_unit {
|
|
345 |
+ |
use super::*;
|
|
346 |
+ |
|
|
347 |
+ |
#[test]
|
|
348 |
+ |
fn contains_bare_rejects_longer_numbers() {
|
|
349 |
+ |
assert!(contains_bare("costs $580 a month", "$580"));
|
|
350 |
+ |
assert!(!contains_bare("revenue of $5,800", "$580"));
|
|
351 |
+ |
assert!(!contains_bare("total $5804", "$580"));
|
|
352 |
+ |
assert!(contains_bare("fee is 2.9% today", "2.9%"));
|
|
353 |
+ |
assert!(!contains_bare("fee is 12.9% today", "2.9%"));
|
|
354 |
+ |
assert!(!contains_bare("$10,000/month", "10,000"));
|
|
355 |
+ |
assert!(contains_bare("cap is 10,000 per list", "10,000"));
|
|
356 |
+ |
}
|
|
357 |
+ |
|
|
358 |
+ |
#[test]
|
|
359 |
+ |
fn strip_markers_preserves_offsets_and_lines() {
|
|
360 |
+ |
let input = "a {{ x.y }} b\nc {{ z }} d\n";
|
|
361 |
+ |
let out = strip_markers(input);
|
|
362 |
+ |
assert_eq!(out.len(), input.len());
|
|
363 |
+ |
assert_eq!(out.lines().count(), input.lines().count());
|
|
364 |
+ |
assert!(!out.contains("x.y"), "marker body must be blanked: {out:?}");
|
|
365 |
+ |
assert!(out.starts_with("a "), "prose must survive: {out:?}");
|
|
366 |
+ |
}
|
|
367 |
+ |
|
|
368 |
+ |
#[test]
|
|
369 |
+ |
fn strip_ranges_blanks_code_spans_only() {
|
|
370 |
+ |
let input = "real $580 and `literal $580`";
|
|
371 |
+ |
let out = strip_ranges(input, &code_span_ranges(input));
|
|
372 |
+ |
assert!(contains_bare(&out, "$580"), "prose copy still visible");
|
|
373 |
+ |
assert_eq!(out.matches("$580").count(), 1, "code copy blanked: {out:?}");
|
|
374 |
+ |
}
|
|
375 |
+ |
|
|
376 |
+ |
#[test]
|
|
377 |
+ |
fn group_thousands_inserts_separators() {
|
|
378 |
+ |
assert_eq!(group_thousands("61960"), "61,960");
|
|
379 |
+ |
assert_eq!(group_thousands("580"), "580");
|
|
380 |
+ |
assert_eq!(group_thousands("1000000"), "1,000,000");
|
|
381 |
+ |
assert_eq!(group_thousands("0.31"), "0.31");
|
|
382 |
+ |
}
|
|
383 |
+ |
}
|