mod oneshot;
#[cfg(target_os = "android")]
mod android_alert;
#[cfg(target_os = "android")]
mod android_host;
#[cfg(target_os = "macos")]
mod appkit_alert;
#[cfg(target_os = "ios")]
mod apple_alert;
#[cfg(any(target_os = "ios", target_os = "macos"))]
mod apple_host;
#[cfg(target_os = "ios")]
mod apple_sheet;
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
mod unsupported;
#[cfg(target_os = "android")]
use android_alert::Host as PlatformHost;
#[cfg(target_os = "macos")]
use appkit_alert::Host as PlatformHost;
#[cfg(target_os = "ios")]
use apple_alert::Host as PlatformHost;
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
use unsupported::Host as PlatformHost;
#[cfg(not(target_os = "ios"))]
use UnsupportedSheet as SheetPlatformHost;
#[cfg(target_os = "ios")]
use apple_sheet::Host as SheetPlatformHost;
pub(crate) use oneshot::Sender;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::task::{Context, Poll};
use std::time::Instant;
pub const MAX_ALERT_ACTIONS: usize = 3;
#[derive(Clone, Debug, PartialEq)]
pub struct AlertSpec {
pub title: String,
pub message: String,
pub actions: Vec<AlertAction>,
pub cancelable: bool,
pub style: AlertStyle,
pub anchor: Option<AnchorRect>,
}
impl AlertSpec {
pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: message.into(),
actions: Vec::new(),
cancelable: true,
style: AlertStyle::Alert,
anchor: None,
}
}
#[must_use]
pub fn with_action(
mut self,
id: impl Into<String>,
label: impl Into<String>,
role: ActionRole,
) -> Self {
self.actions.push(AlertAction {
id: id.into(),
label: label.into(),
role,
});
self
}
pub fn validate(&self) -> Result<(), PresentError> {
if self.actions.is_empty() {
return Err(PresentError::InvalidSpec(
"an alert needs at least one action".to_string(),
));
}
if self.actions.len() > MAX_ALERT_ACTIONS {
return Err(PresentError::InvalidSpec(format!(
"an alert takes at most {MAX_ALERT_ACTIONS} actions, got {}",
self.actions.len()
)));
}
for (index, action) in self.actions.iter().enumerate() {
if action.id.is_empty() {
return Err(PresentError::InvalidSpec(format!(
"action {index} has an empty id"
)));
}
if self.actions[..index].iter().any(|a| a.id == action.id) {
return Err(PresentError::InvalidSpec(format!(
"duplicate action id {:?}",
action.id
)));
}
}
let cancels = self
.actions
.iter()
.filter(|a| a.role == ActionRole::Cancel)
.count();
if cancels > 1 {
return Err(PresentError::InvalidSpec(format!(
"at most one action may take the Cancel role, got {cancels}"
)));
}
if let Some(anchor) = &self.anchor
&& !anchor.is_valid()
{
return Err(PresentError::InvalidSpec(
"the anchor rect must be finite with a non-negative size".to_string(),
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlertAction {
pub id: String,
pub label: String,
pub role: ActionRole,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ActionRole {
#[default]
Default,
Cancel,
Destructive,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum AlertStyle {
#[default]
Alert,
ActionSheet,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AnchorRect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl AnchorRect {
fn is_valid(&self) -> bool {
[self.x, self.y, self.width, self.height]
.iter()
.all(|v| v.is_finite())
&& self.width >= 0.0
&& self.height >= 0.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum AlertOutcome {
Action(String),
Cancelled,
Dismissed,
HostLost,
}
pub const MAX_SHEET_ACTIONS: usize = 3;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SheetContent {
pub title: Option<String>,
pub message: Option<String>,
pub image: Option<Arc<[u8]>>,
pub actions: Vec<SheetAction>,
}
impl SheetContent {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub fn with_image(mut self, bytes: impl Into<Arc<[u8]>>) -> Self {
self.image = Some(bytes.into());
self
}
#[must_use]
pub fn with_action(
mut self,
id: impl Into<String>,
label: impl Into<String>,
role: ActionRole,
) -> Self {
self.actions.push(SheetAction {
id: id.into(),
label: label.into(),
role,
});
self
}
fn is_empty(&self) -> bool {
self.title.as_deref().is_none_or(str::is_empty)
&& self.message.as_deref().is_none_or(str::is_empty)
&& self.image.is_none()
&& self.actions.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SheetAction {
pub id: String,
pub label: String,
pub role: ActionRole,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Detent {
Medium,
Large,
Custom(f64),
}
impl Detent {
fn is_valid(self) -> bool {
match self {
Self::Medium | Self::Large => true,
Self::Custom(fraction) => fraction.is_finite() && fraction > 0.0 && fraction <= 1.0,
}
}
#[cfg_attr(not(any(test, target_os = "ios")), allow(dead_code))]
pub(crate) fn system_fallback(self) -> Self {
match self {
Self::Custom(fraction) if fraction <= 0.75 => Self::Medium,
Self::Custom(_) => Self::Large,
other => other,
}
}
}
#[derive(Clone)]
struct DetentListener(Arc<dyn Fn(Detent) + Send + Sync>);
impl fmt::Debug for DetentListener {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("DetentListener(..)")
}
}
impl PartialEq for DetentListener {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SheetSpec {
pub content: SheetContent,
pub detents: Vec<Detent>,
pub selected: Option<Detent>,
pub grabber: bool,
pub scrolling_expands: bool,
pub largest_undimmed: Option<Detent>,
pub corner_radius: Option<f64>,
pub dismissible: bool,
pub tint: Option<u32>,
pub dark: Option<bool>,
on_detent: Option<DetentListener>,
}
impl SheetSpec {
pub fn new(content: SheetContent) -> Self {
Self {
content,
detents: vec![Detent::Medium, Detent::Large],
selected: None,
grabber: true,
scrolling_expands: true,
largest_undimmed: None,
corner_radius: None,
dismissible: true,
tint: None,
dark: None,
on_detent: None,
}
}
#[must_use]
pub fn with_detents(mut self, detents: impl IntoIterator<Item = Detent>) -> Self {
self.detents = detents.into_iter().collect();
self
}
#[must_use]
pub fn with_selected(mut self, detent: Detent) -> Self {
self.selected = Some(detent);
self
}
#[must_use]
pub fn with_dismissible(mut self, dismissible: bool) -> Self {
self.dismissible = dismissible;
self
}
#[must_use]
pub fn on_detent(mut self, listener: impl Fn(Detent) + Send + Sync + 'static) -> Self {
self.on_detent = Some(DetentListener(Arc::new(listener)));
self
}
#[cfg_attr(not(any(test, target_os = "ios")), allow(dead_code))]
pub(crate) fn detent_listener(&self) -> Option<Arc<dyn Fn(Detent) + Send + Sync>> {
self.on_detent
.as_ref()
.map(|listener| Arc::clone(&listener.0))
}
pub fn validate(&self) -> Result<(), PresentError> {
let invalid = |message: String| Err(PresentError::InvalidSpec(message));
if self.content.is_empty() {
return invalid(
"a sheet needs content: a title, a message, an image or an action".to_string(),
);
}
if self.content.image.as_deref().is_some_and(<[u8]>::is_empty) {
return invalid("the sheet's image bytes are empty".to_string());
}
let actions = &self.content.actions;
if actions.len() > MAX_SHEET_ACTIONS {
return invalid(format!(
"a sheet takes at most {MAX_SHEET_ACTIONS} actions, got {}",
actions.len()
));
}
for (index, action) in actions.iter().enumerate() {
if action.id.is_empty() {
return invalid(format!("action {index} has an empty id"));
}
if actions[..index].iter().any(|a| a.id == action.id) {
return invalid(format!("duplicate action id {:?}", action.id));
}
}
if self.detents.is_empty() {
return invalid("a sheet needs at least one detent".to_string());
}
for (index, detent) in self.detents.iter().enumerate() {
if !detent.is_valid() {
return invalid(format!(
"detent {index} ({detent:?}): a custom fraction must be finite with \
0 < f <= 1"
));
}
if self.detents[..index].contains(detent) {
return invalid(format!("duplicate detent {detent:?}"));
}
}
for (name, detent) in [
("selected", self.selected),
("largest_undimmed", self.largest_undimmed),
] {
if let Some(detent) = detent
&& !self.detents.contains(&detent)
{
return invalid(format!("{name} ({detent:?}) is not one of the detents"));
}
}
if let Some(radius) = self.corner_radius
&& !(radius.is_finite() && radius >= 0.0)
{
return invalid("the corner radius must be finite and non-negative".to_string());
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum DismissReason {
User,
Programmatic,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SheetOutcome {
Action(String),
Dismissed(DismissReason),
HostLost,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SheetHandle {
generation: u64,
}
impl SheetHandle {
pub fn presentation(&self) -> PresentationHandle {
PresentationHandle {
generation: self.generation,
}
}
pub fn select_detent(&self, detent: Detent) {
select_detent_on::<SheetPlatformHost>(self, detent);
}
pub fn dismiss(&self) {
dismiss_sheet_on::<SheetPlatformHost>(self);
}
}
impl Presentation<SheetOutcome> {
pub fn sheet_handle(&self) -> Option<SheetHandle> {
self.handle.map(|handle| SheetHandle {
generation: handle.generation,
})
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PresentError {
#[error("native presentation: another presentation is live")]
Busy,
#[error("native presentation: no host to present over")]
NoHost,
#[error("native presentation: unsupported on this platform")]
Unsupported,
#[error("native presentation: invalid spec: {0}")]
InvalidSpec(String),
#[error("native presentation: platform error: {0}")]
Platform(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PresentationHandle {
generation: u64,
}
#[must_use = "a presentation's outcome is only observed by polling it; dropping it abandons the outcome"]
pub struct Presentation<T> {
state: PresentationState<T>,
handle: Option<PresentationHandle>,
}
enum PresentationState<T> {
Ready(Option<Result<T, PresentError>>),
Pending(oneshot::Receiver<T>),
}
impl<T> Presentation<T> {
fn ready(result: Result<T, PresentError>) -> Self {
Self {
state: PresentationState::Ready(Some(result)),
handle: None,
}
}
fn pending(receiver: oneshot::Receiver<T>, generation: u64) -> Self {
Self {
state: PresentationState::Pending(receiver),
handle: Some(PresentationHandle { generation }),
}
}
pub fn handle(&self) -> Option<PresentationHandle> {
self.handle
}
#[cfg_attr(not(feature = "frust-api"), allow(dead_code))]
pub(crate) fn take_refusal(&mut self) -> Option<PresentError> {
match &mut self.state {
PresentationState::Ready(slot) if matches!(slot, Some(Err(_))) => {
slot.take().and_then(Result::err)
}
_ => None,
}
}
}
impl<T> fmt::Debug for Presentation<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state = match &self.state {
PresentationState::Ready(Some(_)) => "ready",
PresentationState::Ready(None) => "taken",
PresentationState::Pending(_) => "pending",
};
f.debug_struct("Presentation")
.field("state", &state)
.field("handle", &self.handle)
.finish()
}
}
impl<T> Unpin for Presentation<T> {}
impl<T> Future for Presentation<T> {
type Output = Result<T, PresentError>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match &mut self.get_mut().state {
PresentationState::Ready(value) => match value.take() {
Some(value) => Poll::Ready(value),
None => Poll::Pending,
},
PresentationState::Pending(receiver) => Pin::new(receiver).poll(cx),
}
}
}
pub(crate) trait AlertHost {
fn show_alert(
spec: AlertSpec,
tx: Sender<AlertOutcome>,
generation: u64,
) -> Result<(), PresentError>;
fn dismiss(generation: u64);
}
struct ActiveSlot {
generation: u64,
claimed_at: Option<Instant>,
}
static ACTIVE: Mutex<Option<ActiveSlot>> = Mutex::new(None);
static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
fn next_generation() -> u64 {
loop {
let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
if generation != 0 {
return generation;
}
}
}
#[inline]
fn claim_clock() -> Option<Instant> {
#[cfg(target_arch = "wasm32")]
{
None
}
#[cfg(not(target_arch = "wasm32"))]
{
Some(Instant::now())
}
}
fn busy_refusal_message(generation: u64, age: Option<std::time::Duration>) -> String {
match age {
Some(duration) => format!(
"frust-native-widgets: presentation request refused: presentation {} has been live for {:.1}s \
— dismiss it through its handle or drop its future to free the slot",
generation,
duration.as_secs_f64()
),
None => format!(
"frust-native-widgets: presentation request refused: presentation {} has been live for \
(age unavailable on this target) — dismiss it through its handle or drop its future to free the slot",
generation
),
}
}
fn lock_active() -> MutexGuard<'static, Option<ActiveSlot>> {
ACTIVE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn release_if_live(generation: u64) {
let mut active = lock_active();
if active
.as_ref()
.is_some_and(|slot| slot.generation == generation)
{
*active = None;
}
}
#[cfg(test)]
pub(crate) fn live_since() -> Option<(u64, Option<std::time::Duration>)> {
lock_active().as_ref().map(|slot| {
(
slot.generation,
slot.claimed_at.map(|instant| instant.elapsed()),
)
})
}
fn submit<T>(start: impl FnOnce(Sender<T>, u64) -> Result<(), PresentError>) -> Presentation<T> {
let generation = {
let mut active = lock_active();
if let Some(live) = active.as_ref() {
let age = live.claimed_at.map(|instant| instant.elapsed());
let message = busy_refusal_message(live.generation, age);
log::warn!("{}", message);
return Presentation::ready(Err(PresentError::Busy));
}
let generation = next_generation();
*active = Some(ActiveSlot {
generation,
claimed_at: claim_clock(),
});
generation
};
let (tx, rx) = oneshot::channel(generation);
match start(tx, generation) {
Ok(()) => Presentation::pending(rx, generation),
Err(err) => {
drop(rx);
Presentation::ready(Err(err))
}
}
}
fn show_alert_on<H: AlertHost>(spec: AlertSpec) -> Presentation<AlertOutcome> {
if let Err(err) = spec.validate() {
return Presentation::ready(Err(err));
}
submit(|tx, generation| H::show_alert(spec, tx, generation))
}
fn dismiss_on<H: AlertHost>(handle: &PresentationHandle) {
if is_live(handle.generation) {
H::dismiss(handle.generation);
}
}
pub fn show_alert(spec: AlertSpec) -> Presentation<AlertOutcome> {
show_alert_on::<PlatformHost>(spec)
}
pub fn dismiss(handle: &PresentationHandle) {
dismiss_on::<PlatformHost>(handle);
}
pub(crate) trait SheetHost {
fn show_sheet(
spec: SheetSpec,
tx: Sender<SheetOutcome>,
generation: u64,
) -> Result<(), PresentError>;
fn dismiss(generation: u64);
fn select_detent(generation: u64, detent: Detent);
}
#[cfg_attr(target_os = "ios", allow(dead_code))]
pub(crate) struct UnsupportedSheet;
impl SheetHost for UnsupportedSheet {
fn show_sheet(
_spec: SheetSpec,
_tx: Sender<SheetOutcome>,
_generation: u64,
) -> Result<(), PresentError> {
Err(PresentError::Unsupported)
}
fn dismiss(_generation: u64) {}
fn select_detent(_generation: u64, _detent: Detent) {}
}
fn show_sheet_on<H: SheetHost>(spec: SheetSpec) -> Presentation<SheetOutcome> {
if let Err(err) = spec.validate() {
return Presentation::ready(Err(err));
}
submit(|tx, generation| H::show_sheet(spec, tx, generation))
}
fn is_live(generation: u64) -> bool {
lock_active()
.as_ref()
.is_some_and(|slot| slot.generation == generation)
}
fn dismiss_sheet_on<H: SheetHost>(handle: &SheetHandle) {
if is_live(handle.generation) {
H::dismiss(handle.generation);
}
}
fn select_detent_on<H: SheetHost>(handle: &SheetHandle, detent: Detent) {
if !detent.is_valid() {
log::warn!(
"frust-native-widgets: select_detent({detent:?}) ignored: a custom fraction must be \
finite with 0 < f <= 1"
);
return;
}
if is_live(handle.generation) {
H::select_detent(handle.generation, detent);
}
}
pub fn show_sheet(spec: SheetSpec) -> Presentation<SheetOutcome> {
show_sheet_on::<SheetPlatformHost>(spec)
}
#[cfg_attr(not(any(test, target_os = "android")), allow(dead_code))]
pub(crate) mod wire {
use super::{AlertOutcome, PresentError};
pub(crate) const OUTCOME_ACTION: i32 = 0;
pub(crate) const OUTCOME_CANCELLED: i32 = 1;
pub(crate) const OUTCOME_DISMISSED: i32 = 2;
pub(crate) const OUTCOME_HOST_LOST: i32 = 3;
pub(crate) const OUTCOME_NO_HOST: i32 = 4;
pub(crate) const OUTCOME_FAILED: i32 = 5;
pub(crate) fn alert_outcome(
code: i32,
action_index: i32,
action_ids: &[String],
) -> Result<AlertOutcome, PresentError> {
match code {
OUTCOME_ACTION => usize::try_from(action_index)
.ok()
.and_then(|index| action_ids.get(index))
.map(|id| AlertOutcome::Action(id.clone()))
.ok_or_else(|| {
PresentError::Platform(format!(
"action index {action_index} out of range for {} actions",
action_ids.len()
))
}),
OUTCOME_CANCELLED => Ok(AlertOutcome::Cancelled),
OUTCOME_DISMISSED => Ok(AlertOutcome::Dismissed),
OUTCOME_HOST_LOST => Ok(AlertOutcome::HostLost),
OUTCOME_NO_HOST => Err(PresentError::NoHost),
OUTCOME_FAILED => Err(PresentError::Platform(
"the presenter failed to show the alert".to_string(),
)),
other => Err(PresentError::Platform(format!(
"unknown presenter outcome code {other}"
))),
}
}
}
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, MutexGuard};
use super::{
ActiveSlot, Presentation, Sender, claim_clock, lock_active, next_generation, oneshot,
};
static TEST_LOCK: Mutex<()> = Mutex::new(());
pub(crate) fn serialize() -> MutexGuard<'static, ()> {
TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg_attr(not(feature = "frust-api"), allow(dead_code))]
pub(crate) fn pending_pair<T>() -> (Sender<T>, Presentation<T>) {
let (tx, rx) = oneshot::channel(u64::MAX);
(tx, Presentation::pending(rx, u64::MAX))
}
#[cfg_attr(not(feature = "frust-api"), allow(dead_code))]
pub(crate) fn with_slot_held<R>(f: impl FnOnce() -> R) -> R {
let _guard = serialize();
*lock_active() = Some(ActiveSlot {
generation: next_generation(),
claimed_at: claim_clock(),
});
let result = f();
*lock_active() = None;
result
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::sync::atomic::Ordering as AtomicOrdering;
use std::task::Waker;
use super::oneshot::tests::counting_waker;
use super::*;
#[derive(Default)]
struct FakeState {
refuse: Option<PresentError>,
live: Option<(u64, Sender<AlertOutcome>)>,
shown: Vec<AlertSpec>,
dismissed: Vec<u64>,
}
thread_local! {
static FAKE: RefCell<FakeState> = RefCell::new(FakeState::default());
}
struct FakeHost;
impl AlertHost for FakeHost {
fn show_alert(
spec: AlertSpec,
tx: Sender<AlertOutcome>,
generation: u64,
) -> Result<(), PresentError> {
FAKE.with(|fake| {
let mut fake = fake.borrow_mut();
fake.shown.push(spec);
if let Some(err) = fake.refuse.clone() {
return Err(err);
}
fake.live = Some((generation, tx));
Ok(())
})
}
fn dismiss(generation: u64) {
let taken = FAKE.with(|fake| {
let mut fake = fake.borrow_mut();
fake.dismissed.push(generation);
match &fake.live {
Some((live, _)) if *live == generation => fake.live.take(),
_ => None,
}
});
if let Some((_, tx)) = taken {
tx.send(Ok(AlertOutcome::Dismissed));
}
}
}
fn platform_resolves(outcome: Result<AlertOutcome, PresentError>) -> bool {
match FAKE.with(|fake| fake.borrow_mut().live.take()) {
Some((_, tx)) => {
tx.send(outcome);
true
}
None => false,
}
}
fn isolated<R>(f: impl FnOnce() -> R) -> R {
let _guard = test_support::serialize();
let reset = || {
*lock_active() = None;
FAKE.with(|fake| *fake.borrow_mut() = FakeState::default());
};
reset();
let result = f();
reset();
result
}
fn poll_with<T>(
presentation: &mut Presentation<T>,
waker: &Waker,
) -> Poll<Result<T, PresentError>> {
let mut cx = Context::from_waker(waker);
Pin::new(presentation).poll(&mut cx)
}
fn poll_once<T>(presentation: &mut Presentation<T>) -> Poll<Result<T, PresentError>> {
poll_with(presentation, &counting_waker().0)
}
fn two_button_spec() -> AlertSpec {
AlertSpec::new("Delete draft?", "This cannot be undone.")
.with_action("keep", "Keep", ActionRole::Cancel)
.with_action("delete", "Delete", ActionRole::Destructive)
}
#[test]
fn the_outcome_is_delivered_exactly_once() {
isolated(|| {
let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
assert!(presentation.handle().is_some());
let (waker, woken) = counting_waker();
assert_eq!(poll_with(&mut presentation, &waker), Poll::Pending);
assert!(platform_resolves(Ok(AlertOutcome::Action("delete".into()))));
assert_eq!(woken.load(AtomicOrdering::SeqCst), 1);
assert!(!platform_resolves(Ok(AlertOutcome::Cancelled)));
assert_eq!(
poll_with(&mut presentation, &waker),
Poll::Ready(Ok(AlertOutcome::Action("delete".into())))
);
assert_eq!(poll_with(&mut presentation, &waker), Poll::Pending);
assert_eq!(woken.load(AtomicOrdering::SeqCst), 1);
});
}
#[test]
fn a_second_show_while_one_is_live_is_busy() {
isolated(|| {
let mut first = show_alert_on::<FakeHost>(two_button_spec());
let mut second = show_alert_on::<FakeHost>(two_button_spec());
assert_eq!(second.handle(), None);
assert_eq!(poll_once(&mut second), Poll::Ready(Err(PresentError::Busy)));
assert_eq!(
FAKE.with(|fake| fake.borrow().shown.len()),
1,
"a Busy request must never reach the host"
);
assert_eq!(poll_once(&mut first), Poll::Pending);
assert!(platform_resolves(Ok(AlertOutcome::Cancelled)));
assert_eq!(
poll_once(&mut first),
Poll::Ready(Ok(AlertOutcome::Cancelled))
);
});
}
#[test]
fn a_busy_refusal_reports_the_live_generation() {
isolated(|| {
let first = show_alert_on::<FakeHost>(two_button_spec());
let first_generation = first.handle().expect("accepted").generation;
let mut second = show_alert_on::<FakeHost>(two_button_spec());
assert_eq!(poll_once(&mut second), Poll::Ready(Err(PresentError::Busy)));
let (generation, age) = live_since().expect("the first presentation is still live");
assert_eq!(generation, first_generation);
if let Some(duration) = age {
assert!(duration < std::time::Duration::from_secs(5));
}
drop(first);
});
}
#[test]
fn a_busy_refusal_message_is_formatted_with_and_without_age() {
let age_some = Some(std::time::Duration::from_secs_f64(1.5));
let msg_with_age = busy_refusal_message(42, age_some);
assert!(msg_with_age.contains("42"), "message includes generation");
assert!(msg_with_age.contains("1.5"), "message includes age");
assert!(
msg_with_age.contains("has been live for"),
"message includes time phrase"
);
assert!(
!msg_with_age.contains("unavailable"),
"message does not say unavailable"
);
let msg_no_age = busy_refusal_message(43, None);
assert!(msg_no_age.contains("43"), "message includes generation");
assert!(
msg_no_age.contains("age unavailable on this target"),
"message explains no age"
);
assert!(
msg_no_age.contains("has been live for"),
"message includes time phrase"
);
}
#[test]
fn the_slot_is_free_again_once_the_outcome_is_sent() {
isolated(|| {
let first = show_alert_on::<FakeHost>(two_button_spec());
assert!(platform_resolves(Ok(AlertOutcome::Cancelled)));
let mut next = show_alert_on::<FakeHost>(two_button_spec());
assert!(next.handle().is_some());
assert_eq!(poll_once(&mut next), Poll::Pending);
drop(first);
});
}
#[test]
fn dismiss_resolves_dismissed_once() {
isolated(|| {
let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
let handle = presentation.handle().expect("accepted");
dismiss_on::<FakeHost>(&handle);
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Ok(AlertOutcome::Dismissed))
);
dismiss_on::<FakeHost>(&handle);
assert_eq!(
FAKE.with(|fake| fake.borrow().dismissed.clone()),
vec![handle.generation]
);
assert_eq!(poll_once(&mut presentation), Poll::Pending);
});
}
#[test]
fn a_late_dismiss_for_an_old_generation_is_ignored() {
isolated(|| {
let old = show_alert_on::<FakeHost>(two_button_spec());
let old_handle = old.handle().expect("accepted");
assert!(platform_resolves(Ok(AlertOutcome::Action("keep".into()))));
drop(old);
let mut current = show_alert_on::<FakeHost>(two_button_spec());
let current_handle = current.handle().expect("accepted");
assert_ne!(old_handle, current_handle);
dismiss_on::<FakeHost>(&old_handle);
assert!(FAKE.with(|fake| fake.borrow().dismissed.is_empty()));
assert_eq!(poll_once(&mut current), Poll::Pending);
FakeHost::dismiss(old_handle.generation);
assert_eq!(poll_once(&mut current), Poll::Pending);
dismiss_on::<FakeHost>(¤t_handle);
assert_eq!(
poll_once(&mut current),
Poll::Ready(Ok(AlertOutcome::Dismissed))
);
});
}
#[test]
fn host_lost_resolves_and_frees_the_slot() {
isolated(|| {
let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
let handle = presentation.handle().expect("accepted");
assert!(platform_resolves(Ok(AlertOutcome::HostLost)));
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Ok(AlertOutcome::HostLost))
);
assert!(live_since().is_none());
dismiss_on::<FakeHost>(&handle);
assert!(FAKE.with(|fake| fake.borrow().dismissed.is_empty()));
});
}
#[test]
fn dropping_the_presentation_releases_the_guard() {
isolated(|| {
let abandoned = show_alert_on::<FakeHost>(two_button_spec());
drop(abandoned);
assert!(live_since().is_none());
let stale_sender = FAKE.with(|fake| fake.borrow_mut().live.take());
let mut current = show_alert_on::<FakeHost>(two_button_spec());
let (_, stale_tx) = stale_sender.expect("the fake held the first sender");
assert!(!stale_tx.send(Ok(AlertOutcome::Cancelled)), "discarded");
assert_eq!(poll_once(&mut current), Poll::Pending);
assert_eq!(
live_since().map(|(generation, _)| generation),
current.handle().map(|h| h.generation)
);
});
}
#[test]
fn a_host_refusal_resolves_the_error_and_frees_the_slot() {
isolated(|| {
FAKE.with(|fake| fake.borrow_mut().refuse = Some(PresentError::NoHost));
let mut refused = show_alert_on::<FakeHost>(two_button_spec());
assert_eq!(refused.handle(), None);
assert_eq!(
poll_once(&mut refused),
Poll::Ready(Err(PresentError::NoHost))
);
assert!(live_since().is_none());
});
}
#[test]
fn an_arm_dropping_its_sender_resolves_platform_and_frees_the_slot() {
isolated(|| {
let mut presentation = show_alert_on::<FakeHost>(two_button_spec());
drop(FAKE.with(|fake| fake.borrow_mut().live.take()));
assert!(live_since().is_none());
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Err(PresentError::Platform(
oneshot::DROPPED_WITHOUT_OUTCOME.to_string()
)))
);
});
}
#[test]
fn an_invalid_spec_is_refused_before_claiming_the_slot() {
isolated(|| {
let zero_actions = AlertSpec::new("t", "m");
let four = AlertSpec::new("t", "m")
.with_action("a", "A", ActionRole::Default)
.with_action("b", "B", ActionRole::Default)
.with_action("c", "C", ActionRole::Default)
.with_action("d", "D", ActionRole::Default);
let duplicate = AlertSpec::new("t", "m")
.with_action("a", "A", ActionRole::Default)
.with_action("a", "Again", ActionRole::Default);
let empty_id = AlertSpec::new("t", "m").with_action("", "A", ActionRole::Default);
let two_cancels = AlertSpec::new("t", "m")
.with_action("a", "A", ActionRole::Cancel)
.with_action("b", "B", ActionRole::Cancel);
let mut bad_anchor =
AlertSpec::new("t", "m").with_action("a", "A", ActionRole::Default);
bad_anchor.style = AlertStyle::ActionSheet;
bad_anchor.anchor = Some(AnchorRect {
x: f64::NAN,
y: 0.0,
width: 10.0,
height: 10.0,
});
let mut negative_anchor = bad_anchor.clone();
negative_anchor.anchor = Some(AnchorRect {
x: 0.0,
y: 0.0,
width: -1.0,
height: 10.0,
});
for spec in [
zero_actions,
four,
duplicate,
empty_id,
two_cancels,
bad_anchor,
negative_anchor,
] {
let mut presentation = show_alert_on::<FakeHost>(spec.clone());
assert!(
matches!(
poll_once(&mut presentation),
Poll::Ready(Err(PresentError::InvalidSpec(_)))
),
"{spec:?} should be refused"
);
assert!(live_since().is_none());
}
assert!(FAKE.with(|fake| fake.borrow().shown.is_empty()));
});
}
#[test]
fn a_refusal_is_taken_once_and_an_accepted_request_has_none() {
isolated(|| {
let mut accepted = show_alert_on::<FakeHost>(two_button_spec());
assert_eq!(accepted.take_refusal(), None);
assert_eq!(poll_once(&mut accepted), Poll::Pending);
let mut busy = show_alert_on::<FakeHost>(two_button_spec());
assert_eq!(busy.take_refusal(), Some(PresentError::Busy));
assert_eq!(busy.take_refusal(), None);
assert_eq!(poll_once(&mut busy), Poll::Pending);
});
}
#[test]
fn a_valid_spec_passes() {
let mut sheet = two_button_spec().with_action("share", "Share", ActionRole::Default);
sheet.style = AlertStyle::ActionSheet;
sheet.anchor = Some(AnchorRect {
x: 10.0,
y: 20.0,
width: 0.0,
height: 0.0,
});
assert_eq!(sheet.validate(), Ok(()));
assert_eq!(
AlertSpec::new("", "")
.with_action("a", "A", ActionRole::Default)
.validate(),
Ok(())
);
}
#[test]
fn a_zero_action_spec_is_rejected() {
assert_eq!(
AlertSpec::new("t", "m").validate(),
Err(PresentError::InvalidSpec(
"an alert needs at least one action".to_string()
))
);
}
#[test]
fn generations_are_never_zero_and_always_fresh() {
let a = next_generation();
let b = next_generation();
assert_ne!(a, 0);
assert_ne!(b, 0);
assert_ne!(a, b);
}
#[test]
fn the_wire_table_maps_every_code() {
let ids = ["keep".to_string(), "delete".to_string()];
assert_eq!(
wire::alert_outcome(wire::OUTCOME_ACTION, 1, &ids),
Ok(AlertOutcome::Action("delete".into()))
);
assert!(matches!(
wire::alert_outcome(wire::OUTCOME_ACTION, 2, &ids),
Err(PresentError::Platform(_))
));
assert!(matches!(
wire::alert_outcome(wire::OUTCOME_ACTION, -1, &ids),
Err(PresentError::Platform(_))
));
assert_eq!(
wire::alert_outcome(wire::OUTCOME_CANCELLED, -1, &ids),
Ok(AlertOutcome::Cancelled)
);
assert_eq!(
wire::alert_outcome(wire::OUTCOME_DISMISSED, -1, &ids),
Ok(AlertOutcome::Dismissed)
);
assert_eq!(
wire::alert_outcome(wire::OUTCOME_HOST_LOST, -1, &ids),
Ok(AlertOutcome::HostLost)
);
assert_eq!(
wire::alert_outcome(wire::OUTCOME_NO_HOST, -1, &ids),
Err(PresentError::NoHost)
);
assert!(matches!(
wire::alert_outcome(wire::OUTCOME_FAILED, -1, &ids),
Err(PresentError::Platform(_))
));
assert!(matches!(
wire::alert_outcome(99, -1, &ids),
Err(PresentError::Platform(_))
));
}
#[derive(Default)]
struct FakeSheetState {
live: Option<(u64, Sender<SheetOutcome>)>,
shown: usize,
dismissed: Vec<u64>,
moved: Vec<(u64, Detent)>,
}
thread_local! {
static FAKE_SHEET: RefCell<FakeSheetState> = RefCell::new(FakeSheetState::default());
}
struct FakeSheetHost;
impl SheetHost for FakeSheetHost {
fn show_sheet(
_spec: SheetSpec,
tx: Sender<SheetOutcome>,
generation: u64,
) -> Result<(), PresentError> {
FAKE_SHEET.with(|fake| {
let mut fake = fake.borrow_mut();
fake.shown += 1;
fake.live = Some((generation, tx));
});
Ok(())
}
fn dismiss(generation: u64) {
let taken = FAKE_SHEET.with(|fake| {
let mut fake = fake.borrow_mut();
fake.dismissed.push(generation);
match &fake.live {
Some((live, _)) if *live == generation => fake.live.take(),
_ => None,
}
});
if let Some((_, tx)) = taken {
tx.send(Ok(SheetOutcome::Dismissed(DismissReason::Programmatic)));
}
}
fn select_detent(generation: u64, detent: Detent) {
FAKE_SHEET.with(|fake| fake.borrow_mut().moved.push((generation, detent)));
}
}
fn sheet_resolves(outcome: Result<SheetOutcome, PresentError>) -> bool {
match FAKE_SHEET.with(|fake| fake.borrow_mut().live.take()) {
Some((_, tx)) => {
tx.send(outcome);
true
}
None => false,
}
}
fn isolated_sheet<R>(f: impl FnOnce() -> R) -> R {
isolated(|| {
let reset = || FAKE_SHEET.with(|fake| *fake.borrow_mut() = FakeSheetState::default());
reset();
let result = f();
reset();
result
})
}
fn sheet_spec() -> SheetSpec {
SheetSpec::new(
SheetContent::new()
.with_title("Share draft")
.with_message("Pick where it goes.")
.with_action("copy", "Copy link", ActionRole::Default)
.with_action("delete", "Delete", ActionRole::Destructive),
)
}
fn assert_invalid_sheet(spec: &SheetSpec) {
assert!(
matches!(spec.validate(), Err(PresentError::InvalidSpec(_))),
"{spec:?} should be refused"
);
}
#[test]
fn a_valid_sheet_spec_passes() {
assert_eq!(sheet_spec().validate(), Ok(()));
for content in [
SheetContent::new().with_title("t"),
SheetContent::new().with_message("m"),
SheetContent::new().with_image(vec![0x89, b'P', b'N', b'G']),
SheetContent::new().with_action("ok", "OK", ActionRole::Default),
] {
let spec = SheetSpec::new(content)
.with_detents([Detent::Custom(0.25), Detent::Medium, Detent::Custom(1.0)])
.with_selected(Detent::Custom(0.25));
assert_eq!(spec.validate(), Ok(()), "{spec:?}");
}
}
#[test]
fn empty_sheet_content_is_refused() {
assert_invalid_sheet(&SheetSpec::new(SheetContent::new()));
assert_invalid_sheet(&SheetSpec::new(
SheetContent::new().with_title("").with_message(""),
));
assert_invalid_sheet(&SheetSpec::new(
SheetContent::new()
.with_title("t")
.with_image(Vec::<u8>::new()),
));
}
#[test]
fn more_than_three_sheet_actions_are_refused() {
let content = (0..=MAX_SHEET_ACTIONS).fold(SheetContent::new(), |content, i| {
content.with_action(format!("a{i}"), "A", ActionRole::Default)
});
assert_eq!(content.actions.len(), MAX_SHEET_ACTIONS + 1);
assert_invalid_sheet(&SheetSpec::new(content));
}
#[test]
fn sheet_action_ids_must_be_non_empty_and_unique() {
assert_invalid_sheet(&SheetSpec::new(SheetContent::new().with_action(
"",
"A",
ActionRole::Default,
)));
assert_invalid_sheet(&SheetSpec::new(
SheetContent::new()
.with_action("a", "A", ActionRole::Default)
.with_action("a", "Again", ActionRole::Cancel),
));
}
#[test]
fn a_custom_fraction_must_lie_in_zero_exclusive_to_one_inclusive() {
for fraction in [0.0, -0.25, 1.000_001, 2.0, f64::NAN, f64::INFINITY] {
assert_invalid_sheet(&sheet_spec().with_detents([Detent::Custom(fraction)]));
}
for fraction in [f64::MIN_POSITIVE, 0.5, 1.0] {
assert_eq!(
sheet_spec()
.with_detents([Detent::Custom(fraction)])
.validate(),
Ok(())
);
}
}
#[test]
fn a_custom_detent_falls_back_to_the_nearest_system_detent() {
assert_eq!(Detent::Custom(0.1).system_fallback(), Detent::Medium);
assert_eq!(Detent::Custom(0.75).system_fallback(), Detent::Medium);
assert_eq!(Detent::Custom(0.76).system_fallback(), Detent::Large);
assert_eq!(Detent::Custom(1.0).system_fallback(), Detent::Large);
assert_eq!(Detent::Medium.system_fallback(), Detent::Medium);
assert_eq!(Detent::Large.system_fallback(), Detent::Large);
}
#[test]
fn detent_rules_are_enforced() {
assert_invalid_sheet(&sheet_spec().with_detents([]));
assert_invalid_sheet(&sheet_spec().with_detents([Detent::Medium, Detent::Medium]));
assert_invalid_sheet(
&sheet_spec()
.with_detents([Detent::Medium])
.with_selected(Detent::Large),
);
let mut undimmed = sheet_spec().with_detents([Detent::Large]);
undimmed.largest_undimmed = Some(Detent::Medium);
assert_invalid_sheet(&undimmed);
let mut radius = sheet_spec();
radius.corner_radius = Some(-1.0);
assert_invalid_sheet(&radius);
radius.corner_radius = Some(f64::NAN);
assert_invalid_sheet(&radius);
radius.corner_radius = Some(0.0);
assert_eq!(radius.validate(), Ok(()));
}
#[test]
fn an_invalid_sheet_never_reaches_the_host_or_the_slot() {
isolated_sheet(|| {
let mut refused = show_sheet_on::<FakeSheetHost>(SheetSpec::new(SheetContent::new()));
assert_eq!(refused.sheet_handle(), None);
assert!(matches!(
poll_once(&mut refused),
Poll::Ready(Err(PresentError::InvalidSpec(_)))
));
assert!(live_since().is_none());
assert_eq!(FAKE_SHEET.with(|fake| fake.borrow().shown), 0);
});
}
#[test]
fn the_unsupported_sheet_arm_refuses_and_frees_the_slot() {
isolated_sheet(|| {
let mut refused = show_sheet_on::<UnsupportedSheet>(sheet_spec());
assert_eq!(refused.sheet_handle(), None);
assert_eq!(
poll_once(&mut refused),
Poll::Ready(Err(PresentError::Unsupported))
);
assert!(live_since().is_none());
UnsupportedSheet::dismiss(u64::MAX);
UnsupportedSheet::select_detent(u64::MAX, Detent::Large);
});
}
#[cfg(not(target_os = "ios"))]
#[test]
fn show_sheet_is_unsupported_off_ios() {
isolated_sheet(|| {
let mut presentation = show_sheet(sheet_spec());
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Err(PresentError::Unsupported))
);
assert!(live_since().is_none());
});
}
#[test]
fn a_sheet_action_resolves_exactly_once() {
isolated_sheet(|| {
let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
let handle = presentation.sheet_handle().expect("accepted");
assert_eq!(Some(handle.presentation()), presentation.handle());
assert_eq!(poll_once(&mut presentation), Poll::Pending);
assert!(sheet_resolves(Ok(SheetOutcome::Action("copy".into()))));
assert!(!sheet_resolves(Ok(SheetOutcome::Dismissed(
DismissReason::User
))));
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Ok(SheetOutcome::Action("copy".into())))
);
assert_eq!(poll_once(&mut presentation), Poll::Pending);
assert!(live_since().is_none());
});
}
#[test]
fn a_user_swipe_resolves_dismissed_user() {
isolated_sheet(|| {
let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
assert!(sheet_resolves(Ok(SheetOutcome::Dismissed(
DismissReason::User
))));
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Ok(SheetOutcome::Dismissed(DismissReason::User)))
);
});
}
#[test]
fn the_sheet_handle_dismisses_once_and_goes_stale() {
isolated_sheet(|| {
let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
let handle = presentation.sheet_handle().expect("accepted");
dismiss_sheet_on::<FakeSheetHost>(&handle);
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Ok(SheetOutcome::Dismissed(DismissReason::Programmatic)))
);
dismiss_sheet_on::<FakeSheetHost>(&handle);
select_detent_on::<FakeSheetHost>(&handle, Detent::Large);
FAKE_SHEET.with(|fake| {
let fake = fake.borrow();
assert_eq!(fake.dismissed, vec![handle.generation]);
assert!(fake.moved.is_empty());
});
});
}
#[test]
fn select_detent_reaches_the_host_only_while_live_and_valid() {
isolated_sheet(|| {
let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
let handle = presentation.sheet_handle().expect("accepted");
select_detent_on::<FakeSheetHost>(&handle, Detent::Large);
select_detent_on::<FakeSheetHost>(&handle, Detent::Custom(0.0));
select_detent_on::<FakeSheetHost>(&handle, Detent::Custom(f64::NAN));
assert_eq!(
FAKE_SHEET.with(|fake| fake.borrow().moved.clone()),
vec![(handle.generation, Detent::Large)]
);
assert_eq!(poll_once(&mut presentation), Poll::Pending);
assert!(live_since().is_some());
});
}
#[test]
fn a_lost_host_resolves_the_sheet_and_frees_the_slot() {
isolated_sheet(|| {
let mut presentation = show_sheet_on::<FakeSheetHost>(sheet_spec());
assert!(sheet_resolves(Ok(SheetOutcome::HostLost)));
assert_eq!(
poll_once(&mut presentation),
Poll::Ready(Ok(SheetOutcome::HostLost))
);
assert!(live_since().is_none());
});
}
#[test]
fn alerts_and_sheets_share_the_one_busy_slot() {
isolated_sheet(|| {
let alert = show_alert_on::<FakeHost>(two_button_spec());
let mut sheet = show_sheet_on::<FakeSheetHost>(sheet_spec());
assert_eq!(sheet.sheet_handle(), None);
assert_eq!(poll_once(&mut sheet), Poll::Ready(Err(PresentError::Busy)));
assert_eq!(FAKE_SHEET.with(|fake| fake.borrow().shown), 0);
drop(alert);
let live_sheet = show_sheet_on::<FakeSheetHost>(sheet_spec());
assert!(live_sheet.sheet_handle().is_some());
let mut alert = show_alert_on::<FakeHost>(two_button_spec());
assert_eq!(poll_once(&mut alert), Poll::Ready(Err(PresentError::Busy)));
assert!(sheet_resolves(Ok(SheetOutcome::Dismissed(
DismissReason::User
))));
let mut next = show_alert_on::<FakeHost>(two_button_spec());
assert!(next.handle().is_some());
assert_eq!(poll_once(&mut next), Poll::Pending);
drop(live_sheet);
});
}
#[test]
fn the_detent_listener_is_carried_and_compared_by_identity() {
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let spec = sheet_spec().on_detent(move |detent| {
sink.lock().expect("unpoisoned").push(detent);
});
let listener = spec.detent_listener().expect("registered");
listener(Detent::Large);
listener(Detent::Custom(0.4));
assert_eq!(
*seen.lock().expect("unpoisoned"),
vec![Detent::Large, Detent::Custom(0.4)]
);
assert_eq!(spec.clone(), spec, "a clone shares the listener");
assert_ne!(spec.clone().on_detent(|_| {}), spec);
assert!(sheet_spec().detent_listener().is_none());
}
const PRESENTER_KT: &str = include_str!(
"../../platform/android/src/main/kotlin/dev/frust/nativewidgets/FrustNativePresenter.kt"
);
const ANDROID_HOST_RS: &str = include_str!("android_host.rs");
fn kotlin_package() -> &'static str {
PRESENTER_KT
.lines()
.find_map(|line| line.trim().strip_prefix("package "))
.expect("FrustNativePresenter.kt declares a package")
.trim()
}
fn kotlin_object_name() -> String {
PRESENTER_KT
.lines()
.find_map(|line| {
line.trim()
.strip_prefix("object ")
.filter(|rest| rest.starts_with(char::is_uppercase))
})
.expect("FrustNativePresenter.kt declares a named `object`")
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect()
}
#[test]
fn presenter_outcome_codes_match_between_kotlin_and_rust() {
let mut kotlin = BTreeMap::new();
for line in PRESENTER_KT.lines() {
let Some(rest) = line.trim().strip_prefix("const val OUTCOME_") else {
continue;
};
let (name, value) = rest.split_once('=').expect("`NAME = value`");
let value: i32 = value
.trim()
.parse()
.unwrap_or_else(|e| panic!("OUTCOME_{name}: {e}"));
kotlin.insert(name.trim().to_string(), value);
}
let rust = BTreeMap::from([
("ACTION".to_string(), wire::OUTCOME_ACTION),
("CANCELLED".to_string(), wire::OUTCOME_CANCELLED),
("DISMISSED".to_string(), wire::OUTCOME_DISMISSED),
("HOST_LOST".to_string(), wire::OUTCOME_HOST_LOST),
("NO_HOST".to_string(), wire::OUTCOME_NO_HOST),
("FAILED".to_string(), wire::OUTCOME_FAILED),
]);
assert_eq!(
kotlin, rust,
"FrustNativePresenter.kt's OUTCOME_* constants and present::wire must be edited \
together"
);
}
#[test]
fn presenter_class_and_export_match_the_kotlin_declaration() {
let package = kotlin_package();
let object = kotlin_object_name();
let binary = format!("\"{package}.{object}\"");
assert!(
ANDROID_HOST_RS.contains(&format!("const PRESENTER_CLASS_BINARY: &str = {binary};")),
"android_host.rs's PRESENTER_CLASS_BINARY must be {binary}"
);
assert!(
PRESENTER_KT.contains(
"external fun nativeOnOutcome(generation: Long, code: Int, actionIndex: Int)"
),
"FrustNativePresenter.kt must declare the nativeOnOutcome(Long, Int, Int) callback"
);
let symbol = format!(
"pub extern \"system\" fn Java_{}_{object}_nativeOnOutcome",
package.replace('.', "_")
);
assert!(
ANDROID_HOST_RS.contains(&symbol),
"android_host.rs must export `{symbol}` — the JVM binds `external` methods by \
mangled name alone"
);
}
}