Files
punktfunk/clients/windows/tests/reactor_semantics.rs
T
enricobuehlerandClaude Fable 5 9907ce6229 test(client/windows): the re-render rules become measured, and the switcher survives a collapsed pane
Adopts windows-reactor's own headless harness (the `test` feature + the upstream repo's
`test_reactor`/RecordingBackend crate, same pinned rev) and pins the three semantics this
client's state placement depends on, in tests/reactor_semantics.rs:

* A child's sync `use_state` re-renders it through element-equal wrappers — the pre-bump
  reconciler pruned at the wrapper; fixed upstream (`nested_state_rerender.rs`).
* A sync write from a backend-fired event handler re-renders — the "MenuFlyout handlers
  bypass the flush" claim does not hold at the engine level on this rev.
* A child-owned `AsyncSetState` write still does NOT re-render its owner — newly diagnosed:
  every component RenderCx draws a fresh HostId but rerender callbacks are registered only
  for the root, so the child's request is a registry miss, silently dropped; the value
  surfaces on the next unrelated pass. The test asserts the broken behaviour on purpose, so
  the bump that fixes it upstream turns the test red and the discipline can be relaxed.
  (Upstream's own async test installs the guard for the very cx it tests and cannot see
  this; worth reporting with this repro.)

The stale discipline note in app/mod.rs — written against the June rev — is replaced by the
measured rules, each citing its test. Net: per-screen EVENT-driven UI state may live in the
screen's own component; THREAD-driven state stays hoisted at root, now for a proven reason.

Riding along (field report from the live review): the scope switcher no longer clips when
the NavigationView collapses. WinUI's Auto mode shrinks the pane below 1008 epx and CLIPS
pane-footer content to the rail; reactor exposes no pane-opened event, so root now reads
the window size and the switcher follows the same threshold — expanded pane → pane footer,
collapsed → a slim row leading the content column (an Auto/Star grid row, not a vstack,
so the scroll viewport survives).

Test builds use their own CARGO_TARGET_DIR by convention: windows-reactor-setup stages
resources.pri next to the test binary and a RUNNING dev client holds that file mapped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00

328 lines
11 KiB
Rust

#![cfg(windows)]
//! Characterization tests for the windows-reactor re-render semantics this client's
//! architecture depends on. These are not tests of the client — they encode the CONTRACT
//! the client's state placement assumes (see the re-render discipline note in `app/mod.rs`),
//! against the same pinned rev, using reactor's own headless harness (`RecordingBackend`).
//! Run them after every SHA bump: a red test here means the discipline note is stale again,
//! in one direction or the other.
//!
//! History that motivates each case:
//! * The June 2026 rev dropped a background `AsyncSetState` write on the floor unless the
//! owning component's props changed — that bug forced ALL thread-driven state into the
//! root component. Fixed July 2026 (`dirty: Arc<AtomicBool>` in the engine).
//! * The July 2026 pre-bump rev still pruned a dirty component sitting under an
//! element-equal non-component wrapper (`border`, `scroll_viewer`) — the reconciler's
//! dirty lookup was one level deep. Fixed by the 2026-07-29 pin (upstream regression
//! test: `nested_state_rerender.rs`).
//! * Event handlers wired in the WinUI backend (`add_Click` etc.) were believed to bypass
//! the render flush, so a sync `use_state` write from one never repainted. The harness
//! fires events through the same backend attach path; the third test pins the engine
//! side of that claim.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::thread;
use test_reactor::{Op, RecordingBackend};
use windows_reactor::{
border, component, vstack, AsyncSetState, ChannelDispatcher, Component, ControlKind,
Dispatcher, DispatcherQueuePriority, Element, Event, Prop, PropValue, RenderCx, RenderHost,
SetState, TextBlock, ToggleSwitch,
};
type Job = Box<dyn FnOnce()>;
/// The upstream tests' single-threaded "UI thread": jobs queue until drained.
#[derive(Clone, Default)]
struct TestDispatcher {
queue: Rc<RefCell<Vec<Job>>>,
}
impl TestDispatcher {
fn drain(&self) {
loop {
let item = {
let mut q = self.queue.borrow_mut();
if q.is_empty() {
None
} else {
Some(q.remove(0))
}
};
match item {
Some(f) => f(),
None => break,
}
}
}
}
impl Dispatcher for TestDispatcher {
fn enqueue(&self, _p: DispatcherQueuePriority, f: Box<dyn FnOnce()>) -> bool {
self.queue.borrow_mut().push(f);
true
}
}
fn last_text(ops: &[Op]) -> Option<String> {
ops.iter().rev().find_map(|op| match op {
Op::SetProp {
prop: Prop::Text,
value: PropValue::Str(s),
..
} => Some(s.clone()),
_ => None,
})
}
// --- Case 1: sync `use_state` in a child under an element-equal `border` wrapper ---------
// The client wraps every screen in an animated `border` that compares equal once the
// entrance tween settles; `pair.rs` documents a live workaround for this exact topology.
struct SyncInner {
renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<SetState<u64>>>>,
}
impl Component for SyncInner {
fn render(&self, _props: &(), cx: &mut RenderCx) -> Element {
let (n, set) = cx.use_state(0_u64);
*self.setter_out.borrow_mut() = Some(set);
self.renders.set(self.renders.get() + 1);
Element::TextBlock(TextBlock::new(format!("sync-{n}")))
}
}
struct SyncRoot {
renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<SetState<u64>>>>,
}
impl Component for SyncRoot {
fn render(&self, _props: &(), _cx: &mut RenderCx) -> Element {
border(component(
SyncInner {
renders: Rc::clone(&self.renders),
setter_out: Rc::clone(&self.setter_out),
},
(),
))
.into()
}
}
#[test]
fn sync_state_child_under_element_equal_border_rerenders() {
let dispatcher = TestDispatcher::default();
let setter_out = Rc::new(RefCell::new(None));
let renders = Rc::new(Cell::new(0));
let host = RenderHost::new(
RecordingBackend::new(),
Box::new(SyncRoot {
renders: Rc::clone(&renders),
setter_out: Rc::clone(&setter_out),
}) as Box<dyn Component>,
dispatcher.clone(),
);
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 1, "inner mounts once");
setter_out.borrow().as_ref().unwrap().call(1);
dispatcher.drain();
assert_eq!(
renders.get(),
2,
"a dirty child under an element-equal border must re-render \
(the pre-2026-07-29 reconciler pruned at the wrapper)"
);
assert_eq!(
host.with_reconciler(|r| last_text(&r.backend.ops)),
Some("sync-1".to_string())
);
}
// --- Case 2: background `AsyncSetState` write into a child with unchanged props ----------
// THE case the root-hoisting discipline was built around. MEASURED BROKEN at the 2026-07-29
// pin, for a new reason: every component's `RenderCx` draws a fresh `HostId`
// (`RenderCx::new` → `HostId::next()`), but rerender callbacks are only registered for the
// ROOT's id (`RenderHost::set_marshaller` / the app bootstrap). A child-owned async write
// stores the value and sets the dirty flag, then calls
// `request_ui_rerender_on_ui_thread(child_host_id)` — a registry miss, silently dropped.
// The new value appears whenever anything ELSE triggers a pass. (Upstream's
// `async_setter_marks_owning_component_dirty_for_rerender` installs the guard for the very
// cx it tests, so it never sees this.) The test asserts the BROKEN behaviour on purpose:
// when a future bump fixes it upstream, this fails loudly and the root-hoisting rule for
// thread-driven state in `app/mod.rs` can finally be relaxed.
struct AsyncInner {
renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<AsyncSetState<u64>>>>,
}
impl Component for AsyncInner {
fn render(&self, _props: &(), cx: &mut RenderCx) -> Element {
let (n, set) = cx.use_async_state(0_u64);
*self.setter_out.borrow_mut() = Some(set);
self.renders.set(self.renders.get() + 1);
Element::TextBlock(TextBlock::new(format!("async-{n}")))
}
}
struct AsyncRoot {
renders: Rc<Cell<u32>>,
root_renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<AsyncSetState<u64>>>>,
}
impl Component for AsyncRoot {
fn render(&self, _props: &(), _cx: &mut RenderCx) -> Element {
self.root_renders.set(self.root_renders.get() + 1);
border(component(
AsyncInner {
renders: Rc::clone(&self.renders),
setter_out: Rc::clone(&self.setter_out),
},
(),
))
.into()
}
}
#[test]
fn async_state_child_under_element_equal_border_rerenders() {
let dispatcher = TestDispatcher::default();
let channel = ChannelDispatcher::new();
let setter_out: Rc<RefCell<Option<AsyncSetState<u64>>>> = Rc::new(RefCell::new(None));
let renders = Rc::new(Cell::new(0));
let root_renders = Rc::new(Cell::new(0));
let host = RenderHost::new(
RecordingBackend::new(),
Box::new(AsyncRoot {
renders: Rc::clone(&renders),
root_renders: Rc::clone(&root_renders),
setter_out: Rc::clone(&setter_out),
}) as Box<dyn Component>,
dispatcher.clone(),
);
host.set_marshaller(Some(channel.marshaller()));
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 1, "inner mounts once");
// The write happens OFF the UI thread, exactly like discovery/HUD/speed-test threads.
let set = setter_out.borrow().as_ref().unwrap().clone();
thread::spawn(move || set.call(1)).join().unwrap();
// Marshal back onto the "UI thread", then run the render it requested.
channel.drain();
dispatcher.drain();
assert_eq!(
root_renders.get(),
1,
"CONTRACT CHANGED: a child-owned async write now triggers a render pass — the \
host-id registry miss was fixed upstream. Thread-driven state may move out of \
root(); update the discipline note in app/mod.rs and this test"
);
assert_eq!(
renders.get(),
1,
"CONTRACT CHANGED: the child re-rendered from its own async write — see above"
);
// The write itself was NOT lost: it lands on the next pass someone else triggers.
// (This is exactly the 'appears after unrelated navigation' failure mode the client
// keeps paying for, and why thread-driven state lives in root().)
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 2);
assert_eq!(
host.with_reconciler(|r| last_text(&r.backend.ops)),
Some("async-1".to_string()),
"the buffered write must surface on the next externally-triggered pass"
);
}
// --- Case 3: sync `use_state` written from a backend-fired event handler -----------------
// The client hoists `forget`/`rename`/`hover` to root because WinUI-wired handlers
// (MenuFlyout `add_Click`, pointer enter/exit) were believed to mark dirty without ever
// pumping the reconciler. The harness fires through the same backend attach path.
struct ToggleInner {
renders: Rc<Cell<u32>>,
}
impl Component for ToggleInner {
fn render(&self, _props: &(), cx: &mut RenderCx) -> Element {
let (n, set) = cx.use_state(0_u64);
self.renders.set(self.renders.get() + 1);
vstack((
TextBlock::new(format!("evt-{n}")),
ToggleSwitch::new(false).on_toggled(move |_| set.call(n + 1)),
))
.into()
}
}
struct ToggleRoot {
renders: Rc<Cell<u32>>,
}
impl Component for ToggleRoot {
fn render(&self, _props: &(), _cx: &mut RenderCx) -> Element {
border(component(
ToggleInner {
renders: Rc::clone(&self.renders),
},
(),
))
.into()
}
}
#[test]
fn sync_state_from_backend_fired_event_rerenders() {
let dispatcher = TestDispatcher::default();
let renders = Rc::new(Cell::new(0));
let host = RenderHost::new(
RecordingBackend::new(),
Box::new(ToggleRoot {
renders: Rc::clone(&renders),
}) as Box<dyn Component>,
dispatcher.clone(),
);
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 1);
let toggle_id = host.with_reconciler(|r| {
r.backend
.ops
.iter()
.find_map(|op| match op {
Op::Create {
id,
kind: ControlKind::ToggleSwitch,
} => Some(*id),
_ => None,
})
.expect("the toggle was created")
});
host.with_reconciler(|r| r.backend.fire_bool(toggle_id, Event::Toggled, true));
dispatcher.drain();
assert_eq!(
renders.get(),
2,
"a sync use_state write from a backend-fired handler must schedule and run a \
re-render — if this fails, the engine really does drop handler-driven dirt, and \
the root-hoisting of forget/rename/hover stays justified at the engine level"
);
assert_eq!(
host.with_reconciler(|r| last_text(&r.backend.ops)),
Some("evt-1".to_string())
);
}