Skip to main content

max / audiofiles

Describe the subscription and storage cap The purchase half of the sync panel: what is bought, how full it is, what a cap costs, and the checkout. Most of it describes cleanly and two things do not, both filed rather than papered over. A price cannot follow a slider. The shipped picker is a logarithmic slider with a live quote beside it, recomputed per drag frame from a server pricing model the app holds a copy of. A described field carries a value and Field::changes fires a route when it changes, so following the drag is a request per step. The port prices what is committed, and the general finding is that nothing describes a display derived from a control's own uncommitted value. The rejected alternative is shipping the pricing model in the description so the renderer can compute: a formula travelling as data, and the next change to it prices three renderers differently. A form has one submit, and this offers two priced choices over one cap. Node::Form carries one action and one submit, so annual-and-monthly as drawn is unsayable. The cadence becomes a described Choice inside the form and it submits once, which is arguably the better screen -- the two buttons are a radio wearing button clothes -- but it is a redesign the vocabulary forced rather than one chosen, and it is recorded as such. Also worth keeping: the checkout URL is not an Outcome::Goto where the auth URL is. start_auth answers a URL synchronously so connecting is a described external destination; subscribe fetches the URL asynchronously and opens it itself, so the route can only ask. Whether "go here" is describable turns on whether the address is known when the description is built. It deletes about thirty-five lines of loading-flag bookkeeping: two Instant fields, two thirty-second timeouts, and the rule that a checkout error clears one flag but a fetch error must not. Readiness::Pending says it, and the renderer owns what waiting looks like. The money paths are bounds-checked in the route rather than trusted from the field, because the address is reachable by typing, and a cap asked for before pricing has arrived is refused rather than guessed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 13:23 UTC
Signed with PGP, not checked
Commit: 54d0e78e0d7529bdc4e2fe97617afcda12b2a6b4
Parent: ec1d6ef
4 files changed, +659 insertions, -22 deletions
M Cargo.lock +12 -12
@@ -7541,6 +7541,18 @@
7541 7541 "winnow 1.0.4",
7542 7542 ]
7543 7543
7544 + [[patch.unused]]
7545 + name = "kberg"
7546 + version = "0.1.0"
7547 +
7548 + [[patch.unused]]
7549 + name = "ops-status"
7550 + version = "0.1.0"
7551 +
7552 + [[patch.unused]]
7553 + name = "painhours"
7554 + version = "0.1.0"
7555 +
7544 7556 [[patch.unused]]
7545 7557 name = "quasi-axum"
7546 7558 version = "0.2.0"
@@ -7560,15 +7572,3 @@
7560 7572 [[patch.unused]]
7561 7573 name = "quasi-webview"
7562 7574 version = "0.2.0"
7563 -
7564 - [[patch.unused]]
7565 - name = "kberg"
7566 - version = "0.1.0"
7567 -
7568 - [[patch.unused]]
7569 - name = "ops-status"
7570 - version = "0.1.0"
7571 -
7572 - [[patch.unused]]
7573 - name = "painhours"
7574 - version = "0.1.0"
@@ -136,6 +136,34 @@
136 136 Syncing,
137 137 }
138 138
139 + /// A subscription, as the description needs to name it.
140 + #[derive(Debug, Clone, PartialEq, Eq)]
141 + pub struct Subscription {
142 + /// Whether it is paid up and running.
143 + pub active: bool,
144 + /// What was bought, in bytes.
145 + pub limit_bytes: i64,
146 + /// What is used, in bytes.
147 + pub used_bytes: i64,
148 + /// `monthly` or `annual`, as the wire spells it.
149 + pub interval: String,
150 + /// A cap change already queued for the next renewal.
151 + pub pending_limit_bytes: Option<i64>,
152 + }
153 +
154 + /// What a cap may be and what it costs.
155 + ///
156 + /// The bounds, plus the ability to price a cap. The *quote* is a method rather
157 + /// than a table because pricing is the server's and the app is not going to
158 + /// reimplement it: see [`sync`]'s header on why the price cannot follow a
159 + /// slider.
160 + pub struct Pricing {
161 + /// The smallest cap that may be bought, in bytes.
162 + pub min_bytes: i64,
163 + /// The largest, in bytes.
164 + pub max_bytes: i64,
165 + }
166 +
139 167 /// Cloud sync, as much of it as a described screen needs.
140 168 ///
141 169 /// The same narrowing [`Config`] makes, for the same three reasons, and it lands
@@ -180,6 +208,32 @@
180 208
181 209 /// Stop syncing this vault.
182 210 fn disconnect(&self);
211 +
212 + /// The subscription, once it has been fetched.
213 + ///
214 + /// `None` is "not known yet" rather than "none": the manager fetches it
215 + /// asynchronously, which is what the described screen reports as
216 + /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending).
217 + fn subscription(&self) -> Option<Subscription>;
218 +
219 + /// What a cap may be, once pricing has been fetched.
220 + fn pricing(&self) -> Option<Pricing>;
221 +
222 + /// What this cap costs at this cadence, in cents.
223 + fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64;
224 +
225 + /// Go and ask what the subscription is.
226 + fn refresh_subscription(&self);
227 +
228 + /// Buy this cap at this cadence.
229 + ///
230 + /// Answers nothing, and that is the shape rather than an omission: the
231 + /// checkout URL is fetched asynchronously and the manager opens it itself,
232 + /// so unlike [`connect`](Self::connect) there is no address to hand back.
233 + fn subscribe(&self, cap_bytes: i64, annual: bool);
234 +
235 + /// Change the cap on a running subscription, at the next renewal.
236 + fn queue_cap_change(&self, cap_bytes: i64);
183 237 }
184 238
185 239 /// The app's sync manager, as the narrow thing a described screen borrows.
@@ -237,6 +291,53 @@
237 291 fn disconnect(&self) {
238 292 self.0.disconnect();
239 293 }
294 +
295 + fn subscription(&self) -> Option<Subscription> {
296 + let status = self.0.status();
297 + status.subscription.map(|sub| Subscription {
298 + active: sub.active,
299 + limit_bytes: sub.storage_limit_bytes.unwrap_or(0),
300 + used_bytes: sub.storage_used_bytes.unwrap_or(0),
301 + interval: sub
302 + .interval
303 + .map_or_else(|| "monthly".to_owned(), |i| i.as_str().to_owned()),
304 + pending_limit_bytes: sub.pending_storage_limit_bytes,
305 + })
306 + }
307 +
308 + fn pricing(&self) -> Option<Pricing> {
309 + self.0.status().pricing.map(|pricing| Pricing {
310 + min_bytes: pricing.min_cap_bytes,
311 + max_bytes: pricing.max_cap_bytes,
312 + })
313 + }
314 +
315 + fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 {
316 + self.0.status().pricing.map_or(0, |pricing| {
317 + pricing.quote_cents(cap_bytes, interval_of(annual)).0
318 + })
319 + }
320 +
321 + fn refresh_subscription(&self) {
322 + self.0.fetch_subscription_status();
323 + }
324 +
325 + fn subscribe(&self, cap_bytes: i64, annual: bool) {
326 + self.0.subscribe(cap_bytes, interval_of(annual));
327 + }
328 +
329 + fn queue_cap_change(&self, cap_bytes: i64) {
330 + self.0.queue_cap_change(cap_bytes);
331 + }
332 + }
333 +
334 + /// A cadence as the client spells it.
335 + fn interval_of(annual: bool) -> audiofiles_sync::BillingInterval {
336 + if annual {
337 + audiofiles_sync::BillingInterval::Annual
338 + } else {
339 + audiofiles_sync::BillingInterval::Monthly
340 + }
240 341 }
241 342
242 343 /// Sync that is not configured on this machine.
@@ -271,6 +372,18 @@
271 372 fn set_interval(&self, _minutes: u32) {}
272 373 fn clear_error(&self) {}
273 374 fn disconnect(&self) {}
375 + fn subscription(&self) -> Option<Subscription> {
376 + None
377 + }
378 + fn pricing(&self) -> Option<Pricing> {
379 + None
380 + }
381 + fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 {
382 + 0
383 + }
384 + fn refresh_subscription(&self) {}
385 + fn subscribe(&self, _cap_bytes: i64, _annual: bool) {}
386 + fn queue_cap_change(&self, _cap_bytes: i64) {}
274 387 }
275 388
276 389 /// A theme the host resolved, as the description needs to name it.
@@ -55,13 +55,49 @@
55 55 //! the user" — a fact about the content, which the renderer then answers with a
56 56 //! cadence of its own choosing. Filed rather than invented here.
57 57 //!
58 - //! # Out of scope, deliberately
58 + //! # The subscription section, and the two findings it produced
59 59 //!
60 - //! The subscription and storage-cap section (`draw_subscription_section`,
61 - //! `draw_cap_slider`, `draw_cap_picker`, ~250 lines). It is a purchase flow with
62 - //! money in it, an external checkout and a pricing fetch, and it deserves its own
63 - //! pass rather than riding along with the state machine. Nothing about it looks
64 - //! undescribable; it is simply a second screen wearing this one's window.
60 + //! Added in a second pass. It is a purchase flow: a cap to choose, a price that
61 + //! depends on it, two cadences, and a Stripe checkout in a browser. Most of it
62 + //! describes cleanly and two things do not.
63 + //!
64 + //! **A price cannot follow a slider.** The shipped picker is a logarithmic
65 + //! slider with a live quote beside it, recomputed per drag frame from
66 + //! `AppPricing::quote_cents` — a *server* pricing model the app happens to hold
67 + //! a copy of. A described field carries a value and `Field::changes` fires a
68 + //! route when it changes, so following the drag means a request per step. The
69 + //! port therefore quotes what is **committed**, not what is under the thumb, and
70 + //! the finding is the general one: **nothing describes a display derived from a
71 + //! control's own uncommitted value.** Filed rather than papered over.
72 + //!
73 + //! Note what is *not* the answer: shipping the pricing model in the description
74 + //! so the renderer can compute. That is a formula travelling as data, and the
75 + //! next change to it silently prices three renderers differently.
76 + //!
77 + //! **A form has one submit, and this offers two priced choices over one value.**
78 + //! Annual and monthly are two actions over the same cap.
79 + //! [`Node::Form`](quasi_router::Node::Form) carries one `action` and one
80 + //! `submit`, so this is unsayable as drawn. The port makes the cadence a
81 + //! described [`Choice`] inside the form and submits once, which is arguably the
82 + //! better screen — the two buttons *are* a radio wearing button clothes — but it
83 + //! is a redesign forced by the vocabulary rather than chosen, and that is worth
84 + //! recording as such.
85 + //!
86 + //! **The checkout URL is not `Outcome::Goto`, and the auth URL is.** Same
87 + //! affordance, two shapes, and the difference is not aesthetic:
88 + //! `SyncManager::start_auth` answers a URL synchronously, so connecting is a
89 + //! described `Destination::External`. `subscribe` fetches the checkout URL
90 + //! asynchronously and opens it itself, so the route can only ask and answer with
91 + //! the screen. Whether "go here" is describable turns on whether the address is
92 + //! known when the description is built.
93 + //!
94 + //! # What it deleted
95 + //!
96 + //! About thirty-five lines of loading-flag bookkeeping: two `Instant` fields,
97 + //! two thirty-second timeouts, and the rule that a checkout error clears one
98 + //! flag but a fetch error must not. A described screen says
99 + //! [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) and the
100 + //! renderer owns what waiting looks like.
65 101
66 102 use quasi_router::layout::{FieldKind, Selector, Tone};
67 103 use quasi_router::{
@@ -69,11 +105,17 @@
69 105 Slot,
70 106 };
71 107
72 - use super::{State, Status, Sync};
108 + use super::{State, Status, Subscription, Sync};
73 109
74 110 /// The region the whole screen answers into.
75 111 const BODY: &str = "sync-body";
76 112
113 + /// One gibibyte, which is what a cap is counted in.
114 + const GIB: i64 = 1024 * 1024 * 1024;
115 +
116 + /// The field a cap is chosen with.
117 + const CAP: &str = "cap_gib";
118 +
77 119 /// The cadences the panel offers, in minutes.
78 120 ///
79 121 /// The same four the shipped panel has. A [`Selector::Segmented`] rather than a
@@ -93,6 +135,9 @@
93 135 .post("/sync/interval", interval)
94 136 .post("/sync/error/clear", clear_error)
95 137 .post("/sync/disconnect", disconnect)
138 + .post("/sync/subscription/refresh", refresh_subscription)
139 + .post("/sync/subscribe", subscribe)
140 + .post("/sync/cap", cap)
96 141 }
97 142
98 143 /// `GET /sync`
@@ -175,6 +220,58 @@
175 220 Ok(screen(state.sync).into())
176 221 }
177 222
223 + /// `POST /sync/subscription/refresh`
224 + fn refresh_subscription(
225 + state: &super::Panels<'_>,
226 + _request: Request,
227 + ) -> Result<Response, RouteError> {
228 + state.sync.refresh_subscription();
229 + Ok(screen(state.sync).into())
230 + }
231 +
232 + /// `POST /sync/subscribe`
233 + ///
234 + /// Answers with the screen rather than with somewhere to go, which is the
235 + /// contrast the module header draws: the checkout URL is fetched asynchronously
236 + /// and the manager opens it, so there is no address to put in an
237 + /// [`Outcome::Goto`](quasi_router::Outcome::Goto).
238 + fn subscribe(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
239 + let cap = cap_from(state, &request)?;
240 + let annual = request.payload.get("cadence") == Some("annual");
241 + state.sync.subscribe(cap, annual);
242 + Ok(Response::from(screen(state.sync)).toast(Tone::Info, "Opening checkout in your browser."))
243 + }
244 +
245 + /// `POST /sync/cap`
246 + fn cap(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> {
247 + let cap = cap_from(state, &request)?;
248 + state.sync.queue_cap_change(cap);
249 + Ok(Response::from(screen(state.sync))
250 + .toast(Tone::Success, "The cap changes at your next renewal."))
251 + }
252 +
253 + /// The cap a request asked for, in bytes, refused if it is outside what is sold.
254 + ///
255 + /// Bounds-checked here and not only in the field, on the rule the interval route
256 + /// already follows: `Field::min` and `max` are what a renderer draws, and a
257 + /// route is reachable by typing.
258 + fn cap_from(state: &super::Panels<'_>, request: &Request) -> Result<i64, RouteError> {
259 + let gib: i64 = request
260 + .payload
261 + .get(CAP)
262 + .and_then(|value| value.parse().ok())
263 + .ok_or_else(|| RouteError::not_found("no cap named"))?;
264 + let bytes = gib.saturating_mul(GIB);
265 + let pricing = state
266 + .sync
267 + .pricing()
268 + .ok_or_else(|| RouteError::internal("Pricing is not loaded yet."))?;
269 + if bytes < pricing.min_bytes || bytes > pricing.max_bytes {
270 + return Err(RouteError::not_found("that cap is not on offer"));
271 + }
272 + Ok(bytes)
273 + }
274 +
178 275 /// `POST /sync/disconnect`
179 276 fn disconnect(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> {
180 277 state.sync.disconnect();
@@ -195,7 +292,7 @@
195 292 State::Disconnected => disconnected(body),
196 293 State::Authenticating => authenticating(body),
197 294 State::NeedsEncryption { has_server_key } => needs_encryption(body, has_server_key),
198 - State::Ready | State::Syncing => ready(body, &status),
295 + State::Ready | State::Syncing => ready(body, &status, sync),
199 296 };
200 297
201 298 // The error banner, on every state, because a failure can arrive in any of
@@ -276,7 +373,7 @@
276 373 }
277 374
278 375 /// Connected, and what it is doing.
279 - fn ready(body: Slot, status: &Status) -> Slot {
376 + fn ready(body: Slot, status: &Status, sync: &dyn Sync) -> Slot {
280 377 let syncing = matches!(status.state, State::Syncing);
281 378 let mut body = body.with(Node::text(if syncing { "Syncing..." } else { "Connected" }));
282 379
@@ -321,9 +418,174 @@
321 418 chosen: Some(status.sync_interval_minutes.to_string()),
322 419 action: Some(Action::post("/sync/interval")),
323 420 })
421 + .with(Node::section("Audio file sync"))
422 + .with(subscription(sync))
324 423 .with(Node::Act(
325 424 Act::new("Disconnect", Action::post("/sync/disconnect"))
326 425 .tone(Tone::Danger)
327 426 .confirm("Disconnect this vault from cloud sync?"),
328 427 ))
329 428 }
429 +
430 + /// What is bought, or what may be.
431 + ///
432 + /// Three shapes: not fetched yet, subscribed, or on offer. The first is a region
433 + /// rather than a sentence, because "not fetched yet" is exactly what
434 + /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) says and the
435 + /// renderer already knows how to draw waiting.
436 + fn subscription(sync: &dyn Sync) -> Node {
437 + let Some(pricing) = sync.pricing() else {
438 + return waiting("Loading pricing...");
439 + };
440 + match sync.subscription() {
441 + None => waiting("Checking subscription..."),
442 + Some(sub) if sub.active => subscribed(sync, &sub, &pricing),
443 + Some(_) => on_offer(sync, &pricing),
444 + }
445 + }
446 +
447 + /// A region that is waiting on something.
448 + ///
449 + /// The whole of what the shipped panel spends two `Instant` fields, two
450 + /// thirty-second timeouts and a spinner on. How long to wait and what to draw
451 + /// while waiting are the renderer's, which is why neither is here.
452 + fn waiting(says: &str) -> Node {
453 + Node::Region(
454 + Slot::new("subscription", RegionKind::Pane)
455 + .pending()
456 + .with(Node::text(says))
457 + .with(Node::Act(Act::new(
458 + "Retry",
459 + Action::post("/sync/subscription/refresh"),
460 + ))),
461 + )
462 + }
463 +
464 + /// A running subscription: what it holds, how full it is, and how to change it.
465 + fn subscribed(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Node {
466 + let mut slot = Slot::new("subscription", RegionKind::Pane).with(Node::text(format!(
467 + "Subscribed: {} ({})",
468 + gib_of(sub.limit_bytes),
469 + sub.interval
470 + )));
471 +
472 + if sub.limit_bytes > 0 {
473 + // A real proportion, so a real `Meter`: used against bought, both known.
474 + // Counted in GiB rather than bytes because the bar is read by a person
475 + // and `Meter` takes two `u32`s.
476 + slot = slot.with(Node::Meter(
477 + quasi_router::Meter::new(gib_count(sub.used_bytes), gib_count(sub.limit_bytes).max(1))
478 + .label("GiB used")
479 + // At ninety percent, not past it: this is a cap that stops syncing
480 + // when it fills, and the point of saying so is to say it before
481 + // that happens.
482 + .tone(if sub.used_bytes * 10 >= sub.limit_bytes * 9 {
483 + Tone::Warning
484 + } else {
485 + Tone::Neutral
486 + }),
487 + ));
488 + }
489 +
490 + if let Some(pending) = sub.pending_limit_bytes {
491 + slot = slot.with(Node::text(format!(
492 + "Pending: cap changes to {} at next renewal.",
493 + gib_of(pending)
494 + )));
495 + }
496 +
497 + let annual = sub.interval == "annual";
498 + Node::Region(slot.with(Node::Form {
499 + fields: vec![cap_field(pricing, sub.limit_bytes).hint(format!(
500 + "{} at this cap, {}.",
501 + money(sync.quote_cents(sub.limit_bytes, annual)),
502 + if annual { "per year" } else { "per month" }
503 + ))],
504 + submit: "Update cap".to_owned(),
505 + action: Action::post("/sync/cap"),
506 + }))
507 + }
508 +
509 + /// No subscription yet: pick a cap and a cadence.
510 + ///
511 + /// One form with the cadence in it, rather than one cap and two priced buttons.
512 + /// That is the redesign the module header records: a form carries one action and
513 + /// one submit, so two priced choices over one value is unsayable as drawn.
514 + fn on_offer(sync: &dyn Sync, pricing: &super::Pricing) -> Node {
515 + let start = pricing.min_bytes;
516 + Node::Region(
517 + Slot::new("subscription", RegionKind::Pane)
518 + .with(Node::text("Pick a storage cap for audio file sync."))
519 + .with(Node::text(
520 + "Annual is two months free: fewer Stripe fees, and we pass the savings on.",
521 + ))
522 + .with(Node::Form {
523 + fields: vec![
524 + cap_field(pricing, start),
525 + Field::select(
526 + "cadence",
527 + "Billing",
528 + vec![
529 + Choice::new(
530 + "annual",
531 + format!("Annual, {} a year", money(sync.quote_cents(start, true))),
532 + ),
533 + Choice::new(
534 + "monthly",
535 + format!(
536 + "Monthly, {} a month",
537 + money(sync.quote_cents(start, false))
538 + ),
539 + ),
540 + ],
541 + )
542 + .hint("Prices shown are for the smallest cap; the exact figure is on the checkout page."),
543 + ],
544 + submit: "Subscribe".to_owned(),
545 + action: Action::post("/sync/subscribe"),
546 + }),
547 + )
548 + }
549 +
550 + /// The cap, as a bounded number.
551 + ///
552 + /// `min` and `max` are the bounds a renderer draws as a track and a route checks
553 + /// again. What is *not* said is that the shipped slider is logarithmic: that is
554 + /// a scale, which is how a renderer spends the space it has, and a description
555 + /// that named it would be naming a widget.
556 + fn cap_field(pricing: &super::Pricing, current: i64) -> Field {
557 + Field::new(FieldKind::Number, CAP, "Storage cap (GiB)")
558 + .value(gib_count(current.max(pricing.min_bytes)).to_string())
559 + .required()
560 + }
561 +
562 + /// A byte count as whole GiB, for a person.
563 + fn gib_count(bytes: i64) -> u32 {
564 + u32::try_from(bytes / GIB).unwrap_or(u32::MAX)
565 + }
566 +
567 + /// A byte count as a cap, spelled the way the shipped panel spells it.
568 + fn gib_of(bytes: i64) -> String {
569 + let gib = bytes / GIB;
570 + if gib >= 1024 {
571 + #[expect(
572 + clippy::cast_precision_loss,
573 + reason = "a cap in TiB is small enough that f64 is exact here"
574 + )]
575 + let tib = gib as f64 / 1024.0;
576 + format!("{tib:.1} TiB")
577 + } else {
578 + format!("{gib} GiB")
579 + }
580 + }
581 +
582 + /// Cents as money, the way the shipped panel writes it.
583 + fn money(cents: i64) -> String {
584 + let dollars = cents / 100;
585 + let pennies = cents % 100;
586 + if pennies == 0 {
587 + format!("${dollars}")
588 + } else {
589 + format!("${dollars}.{pennies:02}")
590 + }
591 + }
@@ -11,7 +11,7 @@
11 11 use audiofiles_core::config_key::ConfigKey;
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 - use super::{Config, Panels, State, Status, Sync, ThemeChoice, router};
14 + use super::{Config, Panels, Pricing, State, Status, Subscription, Sync, ThemeChoice, router};
15 15
16 16 /// A config store in memory.
17 17 ///
@@ -84,6 +84,18 @@
84 84 fn set_interval(&self, _minutes: u32) {}
85 85 fn clear_error(&self) {}
86 86 fn disconnect(&self) {}
87 + fn subscription(&self) -> Option<Subscription> {
88 + None
89 + }
90 + fn pricing(&self) -> Option<Pricing> {
91 + None
92 + }
93 + fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 {
94 + 0
95 + }
96 + fn refresh_subscription(&self) {}
97 + fn subscribe(&self, _cap_bytes: i64, _annual: bool) {}
98 + fn queue_cap_change(&self, _cap_bytes: i64) {}
87 99 }
88 100
89 101 fn themes() -> Vec<ThemeChoice> {
@@ -332,6 +344,10 @@
332 344 struct FakeSync {
333 345 status: Status,
334 346 calls: RefCell<Vec<String>>,
347 + /// What the subscription fetch has answered, if it has.
348 + subscription: Option<Subscription>,
349 + /// Whether pricing has arrived.
350 + priced: bool,
335 351 }
336 352
337 353 impl FakeSync {
@@ -346,6 +362,8 @@
346 362 sync_interval_minutes: 15,
347 363 },
348 364 calls: RefCell::new(Vec::new()),
365 + subscription: None,
366 + priced: true,
349 367 }
350 368 }
351 369
@@ -399,6 +417,55 @@
399 417 fn disconnect(&self) {
400 418 self.note("disconnect");
401 419 }
420 +
421 + fn subscription(&self) -> Option<Subscription> {
422 + self.subscription.clone()
423 + }
424 +
425 + fn pricing(&self) -> Option<Pricing> {
426 + self.priced.then_some(Pricing {
427 + min_bytes: 10 * GIB,
428 + max_bytes: 2048 * GIB,
429 + })
430 + }
431 +
432 + fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 {
433 + // A stand-in for the server's model: a dollar a gibibyte a month, and
434 + // two months free on the year. The screen never computes a price, so
435 + // what matters here is only that the number reaches the label.
436 + let monthly = (cap_bytes / GIB) * 100;
437 + if annual { monthly * 10 } else { monthly }
438 + }
439 +
440 + fn refresh_subscription(&self) {
441 + self.note("refresh_subscription");
442 + }
443 +
444 + fn subscribe(&self, cap_bytes: i64, annual: bool) {
445 + self.note(&format!(
446 + "subscribe:{}:{}",
447 + cap_bytes / GIB,
448 + if annual { "annual" } else { "monthly" }
449 + ));
450 + }
451 +
452 + fn queue_cap_change(&self, cap_bytes: i64) {
453 + self.note(&format!("cap:{}", cap_bytes / GIB));
454 + }
455 + }
456 +
457 + /// One gibibyte, as the routes count caps.
458 + const GIB: i64 = 1024 * 1024 * 1024;
459 +
460 + /// A subscription that is running.
461 + fn active(limit_gib: i64, used_gib: i64) -> Subscription {
462 + Subscription {
463 + active: true,
464 + limit_bytes: limit_gib * GIB,
465 + used_bytes: used_gib * GIB,
466 + interval: "monthly".to_owned(),
467 + pending_limit_bytes: None,
468 + }
402 469 }
403 470
404 471 /// A router call against a sync in this state.
@@ -593,3 +660,198 @@
593 660 );
594 661 }
595 662 }
663 +
664 + /// Every form on a screen, as (submit label, action path, field names).
665 + fn forms(screen: &Screen) -> Vec<(String, String, Vec<String>)> {
666 + screen
667 + .slots
668 + .iter()
669 + .flat_map(|slot| &slot.body)
670 + .flat_map(|node| match node {
671 + Node::Region(slot) => slot.body.clone(),
672 + other => vec![other.clone()],
673 + })
674 + .filter_map(|node| match node {
675 + Node::Form {
676 + submit,
677 + action,
678 + fields,
679 + } => Some((
680 + submit,
681 + action.destination.as_str().to_owned(),
682 + fields.iter().map(|f| f.name.clone()).collect(),
683 + )),
684 + _ => None,
685 + })
686 + .collect()
687 + }
688 +
689 + #[test]
690 + fn a_subscription_that_has_not_arrived_is_pending_rather_than_absent() {
691 + // `None` is "not fetched yet", which is what Readiness says and what the
692 + // shipped panel spends two Instants and a thirty-second timeout on.
693 + let sync = FakeSync::in_state(State::Ready);
694 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
695 + let region = screen_of(&response)
696 + .slots
697 + .iter()
698 + .flat_map(|slot| &slot.body)
699 + .find_map(|node| match node {
700 + Node::Region(slot) if slot.id == "subscription" => Some(slot),
701 + _ => None,
702 + })
703 + .expect("the screen has a subscription region");
704 + assert_eq!(region.readiness, quasi_router::layout::Readiness::Pending);
705 + }
706 +
707 + #[test]
708 + fn one_form_carries_the_cap_and_the_cadence_because_a_form_has_one_submit() {
709 + // The redesign the vocabulary forced: the shipped panel has one cap and two
710 + // priced buttons, and `Node::Form` carries one action and one submit.
711 + let mut sync = FakeSync::in_state(State::Ready);
712 + sync.subscription = Some(Subscription {
713 + active: false,
714 + limit_bytes: 0,
715 + used_bytes: 0,
716 + interval: "monthly".to_owned(),
717 + pending_limit_bytes: None,
718 + });
719 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
720 + let found = forms(screen_of(&response));
721 + let offer = found
722 + .iter()
723 + .find(|(_, action, _)| action == "/sync/subscribe")
724 + .expect("the screen offers a subscription");
725 + assert_eq!(offer.0, "Subscribe");
726 + assert_eq!(offer.2, ["cap_gib", "cadence"], "{offer:?}");
727 + }
728 +
729 + #[test]
730 + fn subscribing_sends_the_cap_and_the_cadence_it_was_given() {
731 + let mut sync = FakeSync::in_state(State::Ready);
732 + sync.subscription = Some(Subscription {
733 + active: false,
734 + limit_bytes: 0,
735 + used_bytes: 0,
736 + interval: "monthly".to_owned(),
737 + pending_limit_bytes: None,
738 + });
739 + syncing(
740 + &sync,
741 + Request::post("/sync/subscribe").sending(
742 + Params::new()
743 + .with("cap_gib".to_owned(), "100".to_owned())
744 + .with("cadence".to_owned(), "annual".to_owned()),
745 + ),
746 + )
747 + .expect("answered");
748 + assert_eq!(sync.called(), ["subscribe:100:annual"]);
749 + }
750 +
751 + #[test]
752 + fn a_cap_outside_what_is_sold_never_reaches_the_manager() {
753 + // Money: the route checks the bounds again rather than trusting the field,
754 + // because the address is reachable by typing.
755 + let sync = FakeSync::in_state(State::Ready);
756 + for out_of_range in ["1", "9999"] {
757 + let refused = syncing(
758 + &sync,
759 + Request::post("/sync/subscribe").sending(
760 + Params::new()
761 + .with("cap_gib".to_owned(), out_of_range.to_owned())
762 + .with("cadence".to_owned(), "monthly".to_owned()),
763 + ),
764 + );
765 + assert!(refused.is_err(), "{out_of_range} GiB was accepted");
766 + }
767 + assert!(
768 + sync.called().is_empty(),
769 + "a cap outside the offer reached the manager"
770 + );
771 +
772 + // And one inside it does.
773 + syncing(
774 + &sync,
775 + Request::post("/sync/cap")
776 + .sending(Params::new().with("cap_gib".to_owned(), "50".to_owned())),
777 + )
778 + .expect("answered");
779 + assert_eq!(sync.called(), ["cap:50"]);
780 + }
781 +
782 + #[test]
783 + fn a_cap_asked_for_before_pricing_arrived_is_refused_rather_than_guessed() {
784 + // Without pricing there are no bounds, and a purchase route that cannot
785 + // check its bounds must not proceed.
786 + let mut sync = FakeSync::in_state(State::Ready);
787 + sync.priced = false;
788 + let refused = syncing(
789 + &sync,
790 + Request::post("/sync/subscribe").sending(
791 + Params::new()
792 + .with("cap_gib".to_owned(), "50".to_owned())
793 + .with("cadence".to_owned(), "monthly".to_owned()),
794 + ),
795 + );
796 + assert!(refused.is_err());
797 + assert!(sync.called().is_empty());
798 + }
799 +
800 + #[test]
801 + fn a_running_subscription_shows_what_it_holds_and_how_full_it_is() {
802 + let mut sync = FakeSync::in_state(State::Ready);
803 + sync.subscription = Some(active(100, 90));
804 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
805 +
806 + let meter = screen_of(&response)
807 + .slots
808 + .iter()
809 + .flat_map(|slot| &slot.body)
810 + .flat_map(|node| match node {
811 + Node::Region(slot) => slot.body.clone(),
812 + other => vec![other.clone()],
813 + })
814 + .find_map(|node| match node {
815 + Node::Meter(meter) => Some(meter),
816 + _ => None,
817 + })
818 + .expect("a subscription draws how full it is");
819 +
820 + assert_eq!(meter.done, 90);
821 + assert_eq!(meter.total, 100);
822 + // The tone is carried because no renderer can work it out: 90% of a paid cap
823 + // is a warning and 90% of a subtask rollup is a success.
824 + assert_eq!(meter.tone, quasi_router::layout::Tone::Warning);
825 + }
826 +
827 + #[test]
828 + fn the_price_shown_is_the_committed_cap_and_not_a_live_quote() {
829 + // The finding, asserted so it cannot be quietly "fixed" by making the field
830 + // fire per keystroke: the hint prices what is stored, because nothing
831 + // describes a display derived from a control's own uncommitted value.
832 + let mut sync = FakeSync::in_state(State::Ready);
833 + sync.subscription = Some(active(20, 1));
834 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
835 + let screen = screen_of(&response);
836 + assert!(
837 + forms(screen)
838 + .iter()
839 + .any(|(_, action, _)| action == "/sync/cap"),
840 + "a running subscription offers a cap change"
841 + );
842 + let hint = screen
843 + .slots
844 + .iter()
845 + .flat_map(|slot| &slot.body)
846 + .flat_map(|node| match node {
847 + Node::Region(slot) => slot.body.clone(),
848 + other => vec![other.clone()],
849 + })
850 + .find_map(|node| match node {
851 + Node::Form { fields, .. } => fields.iter().find(|f| f.name == "cap_gib")?.hint.clone(),
852 + _ => None,
853 + })
854 + .expect("the cap field is priced");
855 + // 20 GiB at the fixture's dollar-a-gibibyte, monthly.
856 + assert!(hint.contains("$20"), "{hint}");
857 + }