Fail when the last film stock cannot be reached

The film list bug gave no failure anywhere: the data was right, the
markup compiled, and the list rendered. Two guards now check that the
list can be walked to its end, both by driving input rather than by
reading markup.

tests/film_list_reaches_every_stock.rs runs in CI and needs no display.
It builds the real AppWindow on Slint's testing backend and gives it 28
stocks. It dispatches window events through the same routing a window
uses: popup, Flickables, arbitration. It then checks that the last stock
is on screen, that is, not clipped away:
- after Down past the end, and that Enter chooses it;
- after drags on the list;
- after a run of wheel events with a still pointer;
- after dragging the scrollbar thumb.

Element queries need the Slint compiler's debug tables, which build.rs
emitted only for the `automation` feature. It now emits them for every
debug build too. Release builds, the ones that ship, are unchanged. The
testing backend is a dev-dependency at the same pinned version the
automation feature already uses, so no new crate enters the lockfile.
The test is compiled out of release test runs.

film_reach in tools/manual/scenes.py is the same check on the recording
rig: a real X pointer from xdotool, the release build, and the demo
library. It makes no picture, so it adds nothing to the manual. It runs
with every recording, or alone with `record.sh LIBRARY film_reach`, and
fails the run if the last stock (Ilford HP5 Plus) is out of reach by
the wheel, a drag, the scrollbar or the keys.

Both have to add the popup's position back. The testing backend reports
anything inside a popup relative to the popup, and so does the
automation hook built on it. They take the popup's position from the
Film row and Slint's clamp into the window.
This commit is contained in:
2026-09-26 07:24:46 -04:00
parent e957d483fc
commit 2ef971bf37
5 changed files with 331 additions and 6 deletions
File diff suppressed because one or more lines are too long
+84
View File
@@ -979,6 +979,90 @@ def film():
undo_all()
FILM_LAST = 'Ilford HP5 Plus' # the last stock `DevelopSession::film_choices` lists
FILM_ROWS = 28 # None and the 27 camera stocks
FILM_ROW, FILM_LIST_MAX = 32, 320
def film_list_open():
"""The film list, open, at its top: closed first if it was open. Not
by pressing the Film row again: a press outside an open list closes it
and goes no further, so that press would only close it."""
if film_list_is_open():
key('Escape')
pause(0.6)
dr.click(*in_column('Film@Button'))
dr.wait_for('Fujifilm C200@Text', 5)
pause(0.6)
def film_list_is_open():
"""Any stock's row on screen. The Film row names the chosen stock too,
so this is only true of an open list while the choice is None."""
return any(e['role'] == 'Text' for maker in ('Fujifilm', 'Kodak', 'Ilford')
for e in dr.ask(f'labels {maker}'))
def film_list_origin():
"""Where the open list's popup is in the window. The hook reports what
is inside a popup relative to the popup, so a scene adds this back. The
popup asks to sit `gap-sm` (6px) under the Film row, as wide as it, and Slint moves it
up into the window when it would run off the bottom — the same clamp."""
x0, _, x1, y1 = dr.rect('Film@Button')
W, H = dr.window_size()
h = min(FILM_ROWS * FILM_ROW, FILM_LIST_MAX)
return min(max(0, x0), W - (x1 - x0)), min(max(0, y1 + 6), H - h), x1 - x0, h
def film_reached(how):
if not dr.present(f'{FILM_LAST}@Text'):
raise AssertionError(f'the film list: {FILM_LAST} cannot be reached by {how}')
print(f' {FILM_LAST} reached by {how}', flush=True)
@scene(media=[], sources=DEVELOP_SRC + ['core/dr-film/src/**'])
def film_reach():
"""Not a picture — a check, run with every recording: the last stock in
the film list can be reached by each way there is to scroll it. It was
once a scroller inside the develop column's, and the column took the
drag and, under a still pointer, the wheel: eighteen stocks, every
black-and-white one, were on the list and out of reach."""
at_develop()
# The wheel, in one run with the pointer still: the column used to take
# a run like this whenever it had scrolled under that pointer last.
film_list_open()
px, py, pw, ph = film_list_origin()
wheel(12, px + pw // 2, py + ph // 2)
pause(0.8)
film_reached('the wheel')
# A drag on the rows, which is what a flick is to a mouse.
film_list_open()
for _ in range(3):
dr.drag(px + pw // 3, py + ph - 20, px + pw // 3, py + 10, 25)
pause(0.8)
film_reached('a drag')
# The scrollbar's thumb, dragged to the bottom of its track.
film_list_open()
dr.drag(px + pw - 5, py + 6, px + pw - 5, py + ph + 40, 25)
pause(0.6)
film_reached('the scrollbar')
# The keys, chosen with Enter: the summary names what was chosen.
film_list_open()
for _ in range(FILM_ROWS + 2):
dr.x('key', 'Down')
key('Return')
pause(1.5)
if dr.present('Fujifilm C200@Text') or not dr.present(f'{FILM_LAST}@Text'):
# Closed, and the Film row now names the last stock.
raise AssertionError(f'the film list: Down and Enter did not choose {FILM_LAST}')
print(f' {FILM_LAST} chosen by the keys', flush=True)
undo_all()
@scene(media=['presets.png'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/presets.slint', 'ui/dr-ui/src/presets.rs'])
def presets():
at_develop()
+4
View File
@@ -168,3 +168,7 @@ env_logger.workspace = true
# Standing in a test backend behind `dyn RemoteBackend`, which is an
# `#[async_trait]` trait — implementing one needs the same attribute.
async-trait.workspace = true
# Runs the interface without a display: the film list's reachability test
# drives the real window's event dispatch on Slint's testing backend. The same
# pinned release as the `automation` dependency above, so nothing new is built.
i-slint-backend-testing = { version = "=1.17.1", default-features = false }
+8 -3
View File
@@ -135,9 +135,14 @@ fn main() {
}
// The recording hook's element queries walk a tree the compiler only
// describes when asked to (src/automation.rs). Only that build asks: the
// extra tables are dead weight to everyone else.
if std::env::var_os("CARGO_FEATURE_AUTOMATION").is_some() {
// describes when asked to (src/automation.rs), and so do the tests that
// run the interface on Slint's testing backend
// (tests/film_list_reaches_every_stock.rs). So that build and every debug
// build ask; a release build is the one the extra tables are dead weight
// to.
if std::env::var_os("CARGO_FEATURE_AUTOMATION").is_some()
|| std::env::var("PROFILE").as_deref() == Ok("debug")
{
config = config.with_debug_info(true);
}
@@ -0,0 +1,232 @@
// TRACES: FR-DEV-3f
//! Every film stock can be reached: by the keys, by a drag, by the wheel and
//! by the scrollbar.
//!
//! The list once showed ten of twenty-eight stocks and could not be scrolled
//! to the rest. It was a `Flickable` inside the develop column's `Flickable`,
//! and Slint offers a gesture to the outermost one first: the column took
//! every drag, and every wheel that came soon after it had scrolled. The
//! eighteen stocks past Kodachrome 64 — all seven black-and-white ones among
//! them — were in the model and out of reach, and to the photographer they
//! looked deleted. Nothing failed: the data was right and the markup
//! compiled, which is why this runs the interface rather than reading it.
//!
//! It runs on Slint's testing backend, which needs no display and draws
//! nothing, so CI can run it; the events go through the same dispatch a
//! window's do — the popup, the Flickables, their arbitration. The recorded
//! counterpart, on a real X server and a real pointer, is the `film_reach`
//! scene in `tools/manual/scenes.py`.
//!
//! The element queries need the compiler's debug tables, which `build.rs`
//! emits for debug builds and the `automation` feature, so this is compiled
//! out of a release test run.
#![cfg(debug_assertions)]
use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use dr_ui::{Adjustments, AppWindow, Develop};
use i_slint_backend_testing::{init_no_event_loop, mock_elapsed_time, ElementHandle};
use slint::platform::{Key, PointerEventButton, WindowEvent};
use slint::{ComponentHandle, LogicalPosition, ModelRc, SharedString, VecModel};
const STOCKS: usize = 28;
fn stock(i: usize) -> String {
format!("Stock {i:02}")
}
fn last() -> String {
stock(STOCKS - 1)
}
/// The develop view with an image open and a film group of `STOCKS` stocks.
fn develop() -> (AppWindow, Rc<Cell<i32>>) {
let app = AppWindow::new().expect("the window builds on the testing backend");
// Tall enough that the column holds the film row without scrolling:
// this is about the list's own scrolling, not the column's.
app.window().set_size(slint::LogicalSize::new(1600., 2400.));
app.global::<Develop>().set_enabled(true);
let adjustments = app.global::<Adjustments>();
adjustments.set_film_in_group(true);
adjustments.set_film_selected(0);
let names: Vec<SharedString> = (0..STOCKS).map(|i| stock(i).into()).collect();
adjustments.set_film_stocks(ModelRc::new(VecModel::from(names)));
let picked = Rc::new(Cell::new(-1));
adjustments.on_film_picked({
let picked = picked.clone();
move |i| picked.set(i)
});
app.show().unwrap();
(app, picked)
}
/// Whether a stock's row is drawn — `find_by_accessible_label` skips what a
/// clip hides, which is what a scrolled-away row is.
fn shown(app: &AppWindow, label: &str) -> bool {
ElementHandle::find_by_accessible_label(app, label)
.next()
.is_some()
}
fn element(app: &AppWindow, label: &str) -> ElementHandle {
ElementHandle::find_by_accessible_label(app, label)
.next()
.unwrap_or_else(|| panic!("{label} is not on screen"))
}
fn centre(e: &ElementHandle) -> LogicalPosition {
let (at, size) = (e.absolute_position(), e.size());
LogicalPosition::new(at.x + size.width / 2., at.y + size.height / 2.)
}
fn key(app: &AppWindow, k: Key) {
let text: SharedString = k.into();
app.window()
.dispatch_event(WindowEvent::KeyPressed { text: text.clone() });
app.window()
.dispatch_event(WindowEvent::KeyReleased { text });
}
fn film_row(app: &AppWindow) -> ElementHandle {
ElementHandle::find_by_accessible_label(app, "Film")
.find(|e| e.accessible_role() == Some(i_slint_backend_testing::AccessibleRole::Button))
.expect("the film summary row is on screen")
}
/// Where the open list's popup is. What is inside a popup reports its
/// position relative to the popup, and the pointer is in the window, so
/// this is added back: the popup sits `gap-sm` (6px) under the Film row,
/// and the window here is tall enough that Slint does not move it.
fn origin(app: &AppWindow) -> (f32, f32) {
let row = film_row(app);
let (at, size) = (row.absolute_position(), row.size());
(at.x, at.y + size.height + 6.)
}
/// `e`'s centre in the window, for an element inside the popup.
fn in_popup(app: &AppWindow, e: &ElementHandle) -> LogicalPosition {
let (ox, oy) = origin(app);
let c = centre(e);
LogicalPosition::new(ox + c.x, oy + c.y)
}
fn open(app: &AppWindow) {
// Not by clicking the Film row again: a press outside an open popup
// closes it and goes no further. Stock 00 is left out because the Film
// row names the chosen stock too.
if (1..STOCKS).any(|i| shown(app, &stock(i))) {
key(app, Key::Escape);
settle();
}
film_row(app).mock_single_click(PointerEventButton::Left);
settle();
assert!(
shown(app, &stock(1)),
"clicking the film row opens the list"
);
assert!(!shown(app, &last()), "the list opens at its top, bounded");
}
/// Let flicks and wheel animations run out.
fn settle() {
mock_elapsed_time(Duration::from_millis(2000));
}
/// A pointer drag, as a hand makes one: small steps a frame apart.
fn drag(app: &AppWindow, from: LogicalPosition, to: LogicalPosition) {
let w = app.window();
w.dispatch_event(WindowEvent::PointerMoved { position: from });
w.dispatch_event(WindowEvent::PointerPressed {
position: from,
button: PointerEventButton::Left,
});
let steps = 40;
for i in 1..=steps {
let t = i as f32 / steps as f32;
let at = LogicalPosition::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
mock_elapsed_time(Duration::from_millis(16));
w.dispatch_event(WindowEvent::PointerMoved { position: at });
}
mock_elapsed_time(Duration::from_millis(300));
w.dispatch_event(WindowEvent::PointerReleased {
position: to,
button: PointerEventButton::Left,
});
settle();
}
#[test]
fn every_film_stock_can_be_reached() {
init_no_event_loop();
let (app, picked) = develop();
// The keys: Down past the end, then Enter chooses the last stock.
open(&app);
for _ in 0..STOCKS + 5 {
key(&app, Key::DownArrow);
}
assert!(
shown(&app, &last()),
"Down walks the list to its last stock"
);
key(&app, Key::Return);
settle();
assert_eq!(
picked.get(),
STOCKS as i32 - 1,
"Enter chooses the stock the keys are on"
);
assert!(!shown(&app, &stock(1)), "choosing closes the list");
// A drag on the rows, which is a flick to a finger.
app.global::<Adjustments>().set_film_selected(0);
open(&app);
for _ in 0..4 {
if shown(&app, &last()) {
break;
}
// Bottom of the list to its top, wherever it has scrolled to.
let (ox, oy) = origin(&app);
drag(
&app,
LogicalPosition::new(ox + 100., oy + 300.),
LogicalPosition::new(ox + 100., oy + 20.),
);
}
assert!(
shown(&app, &last()),
"dragging the list reaches its last stock"
);
// The wheel, in one run with the pointer still.
open(&app);
let over = in_popup(&app, &element(&app, &stock(3)));
for _ in 0..40 {
app.window().dispatch_event(WindowEvent::PointerScrolled {
position: over,
delta_x: 0.,
delta_y: -60.,
});
mock_elapsed_time(Duration::from_millis(40));
}
settle();
assert!(shown(&app, &last()), "the wheel reaches the last stock");
// The scrollbar's thumb, dragged to the bottom of its track.
open(&app);
let bar = ElementHandle::find_by_element_type_name(&app, "ScrollBar")
.find(|e| e.size().height > 0.)
.expect("the open list wears a scrollbar");
let (ox, oy) = origin(&app);
let (at, size) = (bar.absolute_position(), bar.size());
let x = ox + at.x + size.width / 2.;
let top = oy + at.y;
drag(
&app,
LogicalPosition::new(x, top + 4.),
LogicalPosition::new(x, top + size.height + 40.),
);
assert!(shown(&app, &last()), "the scrollbar reaches the last stock");
}