Files
benilla-octo/crates/benilla-app/src/ui_stable.rs
T
SamandClaude Fable 5.1 bc1a2428dd the loading screen lets nothing through, and the micro buttons go stock
- units could be hovered through the loading screen — the sword cursor gave it away: the world pick chain has no state gate, one of fifty-one input sites, so a gate per consumer is a rule that regresses; the cut is at the source instead — one `PreUpdate` system empties the button planes, drains the raw queues, zeroes the accumulators and blanks the window's cursor position while the cover is up (the alt-tabbed-away state every consumer already handles); a press under the cover never happened, a held button is released once, a gesture in flight is cancelled, and a wall test counts the channels; the reference does the same — its raise registers a veto stub (`0x406a50`) on five event-bus categories — and one gap closed with it: the reference parks the plain arrow and drops any item overlay before the screen goes up, where benilla kept a held item's icon over the loading art (1986, 1990)
- the micro-button row is the reference's own `MainMenuBarMicroButtons.xml`, our transcription deleted — no engine verb was missing, and the whole engine-side cost is one alias, `SoundOptionsFrame = OptionsFrame`, because the stock file reads the reference's three options windows and ours is the one window for all three; what the stock file brings back: `UpdateTalentButton`'s own −2 re-seat (the five buttons after the talent button sit 1 px further right — the reference's own pixel), the raw binding token in the hover label (`(C)`, `(SHIFT-P)`), the sixty-second talent-button pulse on a ding while the sheet is closed (0304's scope-out retired), the keyring arm, and "Abilities" on the spellbook hover for a character with no spells; `BenillaActionBarArt_SeatAbove` goes with the file — a child is born one level above its parent (`SetParent` `0x76ab65`), the seat it set by hand (1987)
- hovering the player arrow on the stock world map showed an empty tooltip: narrower than 4:3 the map is scaled under 1, and our `MouseIsOver` had dropped the reference's division by `GetEffectiveScale` as "always 1" — the frame getters answer in the frame's own units, `GetCursorPosition` in the screen's; the division is back, and the region getters divide by their owner's scale too; and the ready check times out: the stock `UIParent.xml` ticks `CheckReadyCheckTime` every frame, the one unbound name in that file's way, so the mechanism behind it is built — a 30 s deadline and a "has not answered" flag per roster entry, the leader getting no popup while members print "%s has initiated a ready check" and get it, and on expiry `RAID_MEMBERS_AFK` with the unanswered names or `READY_CHECK_NO_AFK`, the last answer closing the check at once (1985, 1989)
- a review of the floor grind closed eight stale-input and bypassed-side-effect siblings the gates had left open: a never-placed card places on a still frame, the dormant-emitter skip sits below the emitter's lifetime blocks, PVS changes (not only spawns) un-still the gates, the appear ramp's retirement and a far-side unmark come due, a host born under a parked camera is judged, the projection counts as a still input, a fresh palette range is zeroed, the stable feed runs once more after a close, the unit publisher's viewer query is filtered, solo UI materials are reused by key, and the draw-list cache compares exactly; and three doc links the chat window's stock move (1948) left dangling point at what replaced them — the colour table and the tell ring are the stock Lua's now — which is what had the doc-links gate (1925) red on main

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:01:38 +07:00

530 lines
24 KiB
Rust

//! The app-side **stable feed** (decision 1676) — `PetStableFrame`'s data, the inward half of the
//! stable seam around [`benilla_ui::script`]'s `stable` module, and the twin of
//! [`crate::ui_trainer`]'s trainer feed.
//!
//! The net bridge fills [`StableOpen`] from the wire (`MSG_LIST_STABLED_PETS`, which the server
//! sends unprompted when the gossip stable option is chosen — the trainer's and the vendor's
//! arrangement exactly). Each frame [`feed_stable`] resolves each wire [`StabledPet`] into a
//! Lua-facing [`StablePetSlot`], pushes the snapshot, and fires `PET_STABLE_SHOW` on open /
//! `PET_STABLE_UPDATE` on a content change / `PET_STABLE_CLOSED` on clear. [`drain_stable`] pulls
//! the intents back out into the four wire verbs.
//!
//! ## The resolve, and the one round trip it needs
//!
//! The wire names a `creature_template` **entry** and a loyalty **level**; the window wants an
//! icon, a family word, a loyalty name and a diet. Three of those come from tables already loaded
//! for the live pet ([`PetFamilyTables`], [`PetStatTables`] — decisions 1062/1005); the fourth,
//! the family *id*, is not on the wire at all and must come from the creature query
//! ([`NameCache::resolve_creature`], ask-once).
//!
//! **So a stabled pet's row fills in two steps**, and that is not a defect to paper over: the name
//! and level render immediately off the wire, and the icon/family/diet appear when the template
//! answer lands a frame or two later. The feed diffs, so the arrival re-pushes and fires
//! `PET_STABLE_UPDATE` on its own. The reference has the same two-step (its own template cache
//! misses the first time too); what it does not have is our *empty* first step, because
//! `creaturecache.wdb` persists across sessions and ours does not yet.
//!
//! ## Why the current pet's row is read from the wire, not from the live pet
//!
//! Slot 0 can be occupied while `UnitExists("pet")` is false — a dismissed pet, or one left too far
//! away to be summoned, still gets a row from the server's character-pet cache. The reference
//! prefers the live unit and falls back to `GetStablePetInfo(0)` (`PetStable.lua:120-146`), so both
//! sources must be right; this feed fills the wire half for every row uniformly and lets the Lua
//! choose, rather than special-casing slot 0 into a live-unit read that would be blank for exactly
//! the hunter who needs the window most.
use benilla_protocol::messages::StabledPet;
use bevy::prelude::*;
use benilla_ui::script::{StableIntent, StablePetSlot, StableState, UiScript, NUM_STABLE_SLOTS};
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_pet_stats::{PetFamilyTables, PetStatTables};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
pub(crate) struct UiStablePlugin;
impl Plugin for UiStablePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StableOpen>()
.init_resource::<StableErrors>()
.add_systems(
Update,
(
// Range-close before the feed so the clear becomes PET_STABLE_CLOSED the same
// frame; push before the input pass so an open/close is on screen the same frame;
// drain after it (the trainer/merchant/gossip ordering).
close_npc_session_out_of_range::<StableOpen>.before(feed_stable),
feed_stable.before(UiInput),
drain_stable.after(UiInput),
// AFTER the VM ticks, unlike every other booth feed: this one reads the
// *selection*, which a click on a slot writes during `UiInput` — and
// `PetStable_Update`'s own re-pick (`PetStable.lua:44-59`) runs inside the
// event dispatch `feed_stable` performs. Reading it before the tick would show
// the previous pet for a frame after every click.
feed_stable_booth
.after(UiInput)
// Nothing samples the booth while no stable is open, and the feed's
// first line is a name scan over the whole frame arena (1979's floor).
.run_if(
|open: Res<StableOpen>, booth: Res<crate::portrait::StableBooth>| {
// …and one more run after the close: the feed is also what
// EMPTIES the booth (unit/display_id → None), and a gate that
// shut on the close frame left the pet standing in a closed
// window's pane for good (review of 2026-09-04).
open.npc.is_some()
|| booth.unit.is_some()
|| booth.display_id.is_some()
},
),
),
);
}
}
/// The open stable, filled by the net bridge ([`crate::net`]) and read by [`feed_stable`]. Holds
/// the stable master's guid and the pet rows exactly as the wire delivered them. Cleared on a
/// client-side close and on disconnect.
#[derive(Resource, Default)]
pub(crate) struct StableOpen {
/// The stable master whose window is open; `None` = no stable open.
pub(crate) npc: Option<u64>,
/// Stable slots **purchased** (0..=2) — the wire's `numStableSlots`, not a count of occupied
/// ones.
pub(crate) num_stable_slots: u8,
/// The wire rows, each already carrying its rebased client slot index.
pub(crate) pets: Vec<StabledPet>,
/// A list packet has landed and the feed has not yet turned it into `PET_STABLE_SHOW`.
///
/// The distinction this latch draws is the carve's: `PET_STABLE_SHOW` fires **unconditionally
/// at the tail of every `MSG_LIST_STABLED_PETS` handler** (`0x4cac9b`) — every list, not just
/// the first — while `PET_STABLE_UPDATE`/`_PADERDOLL` fire from the **creature-cache
/// callbacks** (`0x4cb3bf`, `0x4cba5f`), i.e. exactly when a late template answer fills a row
/// in. Our two-step resolve has the same two edges, so the events land on the same causes.
pub(crate) fresh_list: bool,
}
impl StableOpen {
/// Open (or replace) the window with a stable master's freshly-listed pets.
pub(crate) fn open(&mut self, npc: u64, num_stable_slots: u8, pets: Vec<StabledPet>) {
self.npc = Some(npc);
self.num_stable_slots = num_stable_slots;
self.pets = pets;
self.fresh_list = true;
}
pub(crate) fn clear(&mut self) {
self.npc = None;
self.num_stable_slots = 0;
self.pets.clear();
self.fresh_list = false;
}
}
/// The stable window is an NPC session: the standardized range guard ([`crate::ui_session`])
/// client-side-closes it when the player walks out of the stable master's service range or the NPC
/// despawns. This matters more here than for most windows — the server re-checks the interaction on
/// **every** stable verb (`CheckStableMaster`), so a window left open at a distance would fail each
/// button with an indistinguishable generic error instead of simply not being there.
impl NpcSession for StableOpen {
fn npc(&self) -> Option<u64> {
self.npc
}
fn close(&mut self) {
self.clear();
}
}
/// Resolve one wire row into the Lua-facing slot. The template answer drives icon/family/diet and
/// arrives asynchronously (module doc), so each of those is independently `None`-able rather than
/// gating the whole row: a pet whose query is in flight still shows its name and level.
fn resolve_pet(
wire: &StabledPet,
names: &mut NameCache,
families: Option<&PetFamilyTables>,
stats: Option<&PetStatTables>,
commands: &NetCommands,
) -> StablePetSlot {
// Ask-once for the template; `guid: 0` is the template-only convention (this pet has no spawn
// to name — it is asleep in a stable, which is the whole point).
let _ = names.resolve_creature(wire.creature_entry, 0, commands);
let family_id = names
.creature_record(wire.creature_entry)
.map(|r| r.pet_family)
.unwrap_or(0);
let family_row = families.and_then(|t| t.families.get(family_id));
StablePetSlot {
pet_number: wire.pet_number,
// The family's own `CreatureFamily.dbc` icon column — a pet has no item behind it, so this
// is the only icon there is. `None` until the query lands; the reference's
// `SetItemButtonTexture` renders the empty-slot art for it, which is right (decision 1046:
// a path resolving to nothing would draw WHITE).
icon: families
.and_then(|t| t.families.icon(family_id))
.map(str::to_string),
// The pet's given name, straight off the wire — never the template's.
name: wire.name.clone(),
level: wire.level,
family: family_row.map(|f| f.name.clone()),
// The wire carries a loyalty LEVEL; the window shows the `PetLoyalty.dbc` name for it, the
// same table `GetPetLoyalty` reads for the live pet (decision 1005).
loyalty: stats
.and_then(|t| t.loyalty.name(wire.loyalty))
.map(str::to_string),
// The family row's food mask expanded to localized diet names — `GetPetFoodTypes`'s law
// (decision 1062). Legitimately empty for a zero mask; the reference guards on that.
diet: family_row
.map(|f| {
families
.map(|t| {
t.foods
.for_mask(f.pet_food_mask)
.into_iter()
.map(str::to_string)
.collect()
})
.unwrap_or_default()
})
.unwrap_or_default(),
}
}
/// Build the Lua-facing snapshot from [`StableOpen`] — `None` when no stable is open.
#[allow(clippy::too_many_arguments)]
fn snapshot(
open: &StableOpen,
names: &mut NameCache,
families: Option<&PetFamilyTables>,
stats: Option<&PetStatTables>,
next_slot_cost: u32,
has_live_pet: bool,
commands: &NetCommands,
) -> Option<StableState> {
open.npc?;
let mut slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS] = Default::default();
for wire in &open.pets {
// Seat each row by its OWN slot, never by position: the current-pet row is absent for a
// petless hunter, so `pets[0]` is not slot 0 (decision 1676's decode note). A slot byte
// past the window's three is dropped rather than panicking — the server never sends one,
// and a malformed list must not take the window down.
match slots.get_mut(usize::from(wire.slot)) {
Some(seat) => *seat = Some(resolve_pet(wire, names, families, stats, commands)),
None => debug!(
"ui_stable: pet {} arrived in slot {} — past the {NUM_STABLE_SLOTS} the window has, dropped",
wire.pet_number, wire.slot
),
}
}
Some(StableState {
num_stable_slots: u32::from(open.num_stable_slots),
next_slot_cost,
slots,
has_live_pet,
})
}
/// Push the current stable into the VM and fire the show/update/close events on a transition (or a
/// content change). Diffed against a `Local` memory, exactly like the trainer/merchant feeds.
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
fn feed_stable(
script: Option<NonSendMut<UiScript>>,
// ResMut only to consume the fresh-list latch; the feed never authors stable content.
mut open: ResMut<StableOpen>,
bar: Res<crate::ui_pet::PetBar>,
families: Option<Res<PetFamilyTables>>,
stats: Option<Res<PetStatTables>>,
prices: Option<Res<StableSlotPrices>>,
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
mut errors: ResMut<StableErrors>,
mut last: Local<crate::ui_script::VmMemo<Option<StableState>>>,
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
mut sink: crate::ui_action::MessageSink,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
let last_npc = last_npc.get(&script);
// Refusals go to the surface — and the voice — their message record names (1815).
let lines: Vec<_> = errors
.0
.drain(..)
.filter_map(|key| {
let text = script.lua().globals().get::<String>(key).ok()?;
(!text.is_empty()).then(|| crate::ui_action::Shown::keyed(key, text))
})
.collect();
crate::ui_action::show_messages(&mut script, &mut sink, "ui_stable", lines);
// The next slot's price is the client's own job (the wire never sends it) — `StableSlotPrices`
// row `purchased + 1`, the bank's arrangement. `0` past the table, a state in which the
// reference has already hidden the row that would show it.
let next_slot_cost = prices
.as_deref()
.and_then(|p| p.0.next_slot_price(open.num_stable_slots))
.unwrap_or(0);
// The client's own `[0xb714a0]|[0xb714a4]` test — a LIVE pet guid, which a dismissed pet does
// not have even though the server still sends its slot-0 row.
let has_live_pet = bar.spells.pet_guid != 0;
let fresh = snapshot(
&open,
&mut names,
families.as_deref(),
stats.as_deref(),
next_slot_cost,
has_live_pet,
&commands,
);
let switched = npc_switched(*last_npc, open.npc);
let fresh_list = std::mem::take(&mut open.fresh_list);
if fresh == *last && !switched && !fresh_list {
return;
}
// PUSH before firing: `fire_event` dispatches the Lua handlers synchronously, so the snapshot
// must already be in the VM when `PetStable_Update` reads it back (decision 1073's rule).
script.set_stable(fresh.clone());
if switched {
// A different stable master while the window is open is a real close+open — the reference's
// ShowUIPanel early-returns when visible, so the open sound only re-plays after a hide
// (decision 0096). Consume the close intent the CLOSED→OnHide→ClosePetStables round queues,
// so the drain does not clear the stable we just re-opened to.
script.fire_event("PET_STABLE_CLOSED", vec![]);
script.fire_event("PET_STABLE_SHOW", vec![]);
let _ = script.take_stable_close();
} else if fresh_list && fresh.is_some() {
// **Every list fires SHOW**, not just the first (`0x4cac9b` is unconditional). The window
// is already visible for a refresh, and the reference's own `ShowUIPanel` early-return is
// what makes that idempotent rather than a re-open.
script.fire_event("PET_STABLE_SHOW", vec![]);
} else {
match (&*last, &fresh) {
// A content change with no new list is a late creature-query answer filling a row in —
// the client's cache-callback edge, and the only thing those callbacks fire.
(Some(_), Some(_)) => {
script.fire_event("PET_STABLE_UPDATE", vec![]);
script.fire_event("PET_STABLE_UPDATE_PAPERDOLL", vec![]);
}
(Some(_), None) => script.fire_event("PET_STABLE_CLOSED", vec![]),
// A first snapshot with no list latch cannot happen (the latch is set by the packet
// that creates it); a None→None is nothing at all.
(None, _) => {}
}
}
*last = fresh;
*last_npc = open.npc;
}
/// Drain the Lua intents into the four wire verbs, and the close into a local clear (no packet
/// exists — vmangos has no close opcode).
///
/// Every verb is addressed to the **open session's** NPC: the guid is the app's, never Lua's, so an
/// addon cannot aim a stable verb at an arbitrary unit.
fn drain_stable(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<StableOpen>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
for intent in script.take_stable_intents() {
let Some(npc) = open.npc else {
debug!("ui_stable: {intent:?} with no open stable — ignored");
continue;
};
debug!("ui_stable: {intent:?} (stable master {npc:#x})");
let command = match intent {
StableIntent::Stable => ClientCommand::StablePet { npc },
StableIntent::Unstable(pet_number) => ClientCommand::UnstablePet { npc, pet_number },
StableIntent::Swap(pet_number) => ClientCommand::StableSwapPet { npc, pet_number },
StableIntent::BuySlot => ClientCommand::BuyStableSlot { npc },
};
let _ = commands.0.send(command);
}
if script.take_stable_close() {
debug!("ui_stable: client-side close (no packet)");
open.clear();
}
}
/// Point the stable window's model pane at the pet `GetSelectedStablePet()` names — the app half of
/// the reference's `SetPetStablePaperdoll` (wow-re `ui/scratch/stable-master-window.md` §7.1).
///
/// **The reference's own fork, and it is the whole of this system.** On `[0xb72250] == -1` — the
/// summoned pet — it resolves the live pet by GUID and takes `[unit+0xb30]`'s model; the resolve
/// failing (`0x4cb9bc`) falls through to the *same* path everything else takes: map the selection
/// back to a record, and go through the **creature cache** on that record's creature entry, with a
/// callback that fires `PET_STABLE_UPDATE_PAPERDOLL` when a late answer lands.
///
/// So the pane has two sources and one of them is not a unit at all:
///
/// - A **live** pet gets [`StableBooth::unit`], and the pane is the pet paper doll's case exactly
/// (decision 1057) — the booth mirrors the world body.
/// - Everything else gets [`StableBooth::display_id`], off the creature query's
/// [`crate::names::CreatureRecord::display_id`]. That covers every stabled pet *and* slot 0 while
/// the pet is dismissed or too far to summon: the server still sends its row, and the reference
/// still draws it.
///
/// A selection with no answer yet is `None`/`None`, which empties the pane; the query's arrival
/// re-points it on a later frame, which is what the reference's cache callback is for.
fn feed_stable_booth(
script: Option<NonSendMut<UiScript>>,
open: Res<StableOpen>,
bar: Res<crate::ui_pet::PetBar>,
pet: crate::ui_pet::PetUnit,
names: Res<NameCache>,
mut booth: ResMut<crate::portrait::StableBooth>,
) {
let Some(mut script) = script else {
return;
};
// Written every frame, selection or none — a stale yaw would snap the model the moment one is
// picked (the pet paper doll's arrangement).
booth.yaw = script.model_pane_facing("PetStableModel");
// `GetSelectedStablePet()`'s own answer: `0` = the summoned pet, `1..=2` a bought stable slot,
// `-1` nothing. Reading the binding's translation rather than the raw petNumber is deliberate —
// it is what already degrades a *stale* selection (a pet the latest list no longer holds) to
// "nothing" instead of to a wrong slot.
let slot = script.stable_selection();
let live_pet = bar.spells.pet_guid;
let (unit, display_id) = stable_subject(
slot,
(live_pet != 0).then(|| pet.entity(live_pet)).flatten(),
&open.pets,
|entry| names.creature_record(entry).map(|r| r.display_id),
);
booth.unit = unit;
booth.display_id = display_id;
}
/// The selection → `(live unit, display id)` fork, pure so the law is testable without a VM, a
/// wire or a world (module doc on [`feed_stable_booth`] for the reference's shape).
///
/// **The live arm consults no row at all** — §7.1's `[0xb72250] == -1` branch reads the live pet's
/// GUID out of `[0xb714a0]` and takes its model; the stable array is not in that path. So a
/// summoned pet is drawn from its world body whether or not the list happened to carry its row.
///
/// **The display arm is resolved regardless**, because that is the reference's own order: the live
/// resolve failing (a dismissed pet, an object that has not streamed) falls straight through to the
/// creature cache, and having the answer already in hand means the pane does not blank for the
/// frames a pet spends being dismissed. `0` is the template's "no display" and reads as no answer —
/// the reference's cache miss draws nothing either.
fn stable_subject(
slot: i32,
live_pet: Option<Entity>,
pets: &[StabledPet],
display_of: impl Fn(u32) -> Option<u32>,
) -> (Option<Entity>, Option<u32>) {
let unit = if slot == 0 { live_pet } else { None };
let display = u8::try_from(slot)
.ok()
.and_then(|slot| pets.iter().find(|p| p.slot == slot))
.and_then(|row| display_of(row.creature_entry))
.filter(|&d| d != 0);
(unit, display)
}
/// Stable refusals staged for the feed, as **message-catalog keys** — only ever
/// `ERR_NOT_ENOUGH_MONEY`, the single code the client speaks for (decision 1677), reached through
/// `DisplayError(0x25)` (wow-re `system/ui/scratch/stable-master-window.md` §5, VERIFIED). A key
/// rather than the resolved text because that row carries error-speech line `0x28` too — the
/// character says it aloud (decision 1815).
#[derive(Resource, Default)]
pub(crate) struct StableErrors(pub(crate) Vec<&'static str>);
/// `StableSlotPrices.dbc`, loaded once at startup — the purchase row's price oracle. A missing
/// table quotes `0`, which is the same shape as "past the ladder": degraded, never wrong.
#[derive(Resource)]
pub(crate) struct StableSlotPrices(pub(crate) benilla_formats::StableSlotPrices);
#[cfg(test)]
mod tests {
use super::*;
fn row(slot: u8, entry: u32) -> StabledPet {
StabledPet {
pet_number: u32::from(slot) + 100,
creature_entry: entry,
level: 30,
name: format!("pet{slot}"),
loyalty: 6,
slot,
}
}
/// One display per entry; entry `9` is the template that ships none.
fn display_of(entry: u32) -> Option<u32> {
match entry {
1 => Some(4449),
2 => Some(822),
9 => Some(0),
_ => None,
}
}
/// The whole fork, arm by arm — this is what a blank pane looks like when it is wrong, and no
/// build gate can see it.
#[test]
fn the_selection_forks_between_a_live_body_and_a_bare_display() {
let pets = [row(0, 1), row(1, 2)];
let live = Entity::from_raw_u32(7).expect("a test entity id");
// Nothing selected: neither source, so the pane empties.
assert_eq!(
stable_subject(-1, Some(live), &pets, display_of),
(None, None)
);
// Slot 0 with the pet out: the world body, and the display resolved beside it so a dismiss
// does not blank the pane.
assert_eq!(
stable_subject(0, Some(live), &pets, display_of),
(Some(live), Some(4449))
);
// Slot 0 with the pet dismissed (a row, no live guid): the display alone — the case that
// has no world entity anywhere and the whole reason the stand-in exists.
assert_eq!(
stable_subject(0, None, &pets, display_of),
(None, Some(4449))
);
// A stabled slot NEVER takes the live body, even while a pet is out: the reference's live
// arm is gated on the selection being the summoned pet, not on one existing.
assert_eq!(
stable_subject(1, Some(live), &pets, display_of),
(None, Some(822))
);
// A slot with no row (never bought, or empty) and a slot past the window: nothing.
assert_eq!(
stable_subject(2, Some(live), &pets, display_of),
(None, None)
);
}
/// A row whose creature query has not answered yet, and one whose template ships display `0`,
/// both read as "no answer" — the reference's cache miss, which draws nothing rather than
/// drawing wrong.
#[test]
fn an_unanswered_or_display_less_template_empties_the_pane() {
assert_eq!(
stable_subject(1, None, &[row(1, 404)], display_of),
(None, None),
"the query has not landed yet"
);
assert_eq!(
stable_subject(1, None, &[row(1, 9)], display_of),
(None, None),
"the template ships no display"
);
}
}