mod common;
use common::{MONO, SERIF, TestUi};
use async_trait::async_trait;
use bevy::prelude::*;
use bevy_markup::bevy_fluent::BundleAsset;
use bevy_markup::prelude::*;
use proptest::prelude::*;
use proptest_stateful::{ModelState, ProptestStatefulConfig};
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Theme {
#[default]
Framed,
Plain,
Broken,
}
impl Theme {
fn path(self) -> &'static str {
match self {
Theme::Framed => "framed.css",
Theme::Plain => "plain.css",
Theme::Broken => "broken.css",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Locale {
#[default]
Raw,
En,
De,
}
#[derive(Clone, Debug, Default)]
struct UiModel {
theme: Theme,
own: Option<Theme>,
locale: Locale,
n: i64,
outline: bool,
fonts_swapped: bool,
}
#[derive(Clone, Debug)]
enum Op {
SetN(i64),
SetTheme(Theme),
SetOwnSheet(Theme),
RemoveOwnSheet,
SetLocale(Locale),
ToggleOutline,
SwapFonts,
}
impl UiModel {
fn effective(&self) -> Theme {
match self.own {
Some(Theme::Broken) | None => self.theme,
Some(own) => own,
}
}
fn expected(&self) -> (String, Option<String>) {
let root = match self.effective() {
Theme::Framed => "html-ui border=16,16,16,16 padding=10,10,10,10 slice=frame.png 4,4,4,4 stretch",
Theme::Plain => "html-ui padding=4,4,4,4",
Theme::Broken => "html-ui padding=3,3,3,3",
};
let text = match self.locale {
Locale::Raw => self.n.to_string(),
Locale::En => format!("{} items", self.n),
Locale::De => format!("{} Dinge", self.n),
};
let face = if self.fonts_swapped { "mono" } else { "serif" };
let style = match self.effective() {
Theme::Framed => format!("{face} 20px #ffffff"),
Theme::Plain => format!("{face} 12px #00ff00"),
Theme::Broken => "default 16px #ffffff".to_owned(),
};
let run = (!self.outline).then(|| format!("{text:?} {style}"));
(root.to_owned(), run)
}
fn rebuilds(&self, op: &Op) -> bool {
!matches!(op, Op::SetTheme(_)) || matches!(self.own, None | Some(Theme::Broken))
}
}
#[async_trait(?Send)]
impl ModelState for UiModel {
type Operation = Op;
type RunContext = TestUi;
type OperationStrategy = BoxedStrategy<Op>;
fn op_generators(&self) -> Vec<BoxedStrategy<Op>> {
let themes = || prop_oneof![Just(Theme::Framed), Just(Theme::Plain), Just(Theme::Broken)];
vec![
(0i64..1000).prop_map(Op::SetN).boxed(),
themes().prop_map(Op::SetTheme).boxed(),
themes().prop_map(Op::SetOwnSheet).boxed(),
Just(Op::RemoveOwnSheet).boxed(),
prop_oneof![Just(Locale::En), Just(Locale::De), Just(Locale::Raw)]
.prop_map(Op::SetLocale)
.boxed(),
Just(Op::ToggleOutline).boxed(),
Just(Op::SwapFonts).boxed(),
]
}
fn preconditions_met(&self, op: &Op) -> bool {
match *op {
Op::SetN(n) => n != self.n,
Op::SetTheme(theme) => theme != self.theme,
Op::SetOwnSheet(theme) => self.own != Some(theme),
Op::RemoveOwnSheet => self.own.is_some(),
Op::SetLocale(locale) => locale != self.locale,
Op::ToggleOutline | Op::SwapFonts => true,
}
}
fn next_state(&mut self, op: &Op) {
match *op {
Op::SetN(n) => self.n = n,
Op::SetTheme(theme) => self.theme = theme,
Op::SetOwnSheet(theme) => self.own = Some(theme),
Op::RemoveOwnSheet => self.own = None,
Op::SetLocale(locale) => self.locale = locale,
Op::ToggleOutline => self.outline = !self.outline,
Op::SwapFonts => self.fonts_swapped = !self.fonts_swapped,
}
}
async fn init_test_run(&self) -> TestUi {
let files = [
("page.html", r#"<p data-l10n-id="count" data-l10n-args='{"n": {{ n }}}'>{{ n }}</p>"#),
("framed.css", r#"html { color: #ffffff; font-family: Spectral; font-size: 20px; border-image: url("frame.png") 4 fill stretch; border-width: 16px; padding: 10px }"#),
("plain.css", "html { color: #00ff00; font-family: Spectral; font-size: 12px; padding: 4px }"),
("broken.css", "{ not css"),
("locales/en-US/main.ftl.ron", r#"(locale: "en-US", resources: ["ui.ftl"])"#),
("locales/en-US/ui.ftl", "count = { $n } items"),
("locales/de/main.ftl.ron", r#"(locale: "de", resources: ["ui.ftl"])"#),
("locales/de/ui.ftl", "count = { $n } Dinge"),
];
let mut ui = TestUi::new("stateful-ui", &files)
.stylesheet("framed.css")
.spawn(
"page.html",
TemplateContext::new().with("n", &self.n),
Node { padding: UiRect::all(Val::Px(3.0)), ..default() },
);
let _: Handle<Stylesheet> = ui.load("plain.css");
let _: Handle<Stylesheet> = ui.load("broken.css");
let _: Handle<BundleAsset> = ui.load("locales/en-US/main.ftl.ron");
let _: Handle<BundleAsset> = ui.load("locales/de/main.ftl.ron");
ui.settle();
ui
}
async fn run_op(&self, op: &Op, ctxt: &mut TestUi) {
let root = ctxt.root();
match *op {
Op::SetN(n) => {
ctxt.world_mut()
.get_mut::<TemplateContext>(root)
.unwrap()
.insert("n", &n);
}
Op::SetTheme(theme) => {
let handle: Handle<Stylesheet> = ctxt.load(theme.path());
ctxt.world_mut().insert_resource(DefaultStylesheet::new(handle));
}
Op::SetOwnSheet(theme) => {
let handle: Handle<Stylesheet> = ctxt.load(theme.path());
ctxt.world_mut().entity_mut(root).insert(HtmlStylesheet(handle));
}
Op::RemoveOwnSheet => {
ctxt.world_mut().entity_mut(root).remove::<HtmlStylesheet>();
}
Op::SetLocale(locale) => {
let active = match locale {
Locale::Raw => ActiveLocale(None),
Locale::En => ActiveLocale::new(ctxt.load("locales/en-US/main.ftl.ron")),
Locale::De => ActiveLocale::new(ctxt.load("locales/de/main.ftl.ron")),
};
ctxt.world_mut().insert_resource(active);
}
Op::ToggleOutline => {
if self.outline {
ctxt.world_mut().entity_mut(root).remove::<HtmlDebugOutline>();
} else {
ctxt.world_mut().entity_mut(root).insert(HtmlDebugOutline);
}
}
Op::SwapFonts => {
let faces = if self.fonts_swapped {
FontFaces::new(SERIF[0].clone())
.with_bold(SERIF[1].clone())
.with_italic(SERIF[2].clone())
.with_bold_italic(SERIF[3].clone())
} else {
FontFaces::new(MONO.clone())
};
ctxt.world_mut().resource_mut::<FontFamilies>().insert("Spectral", faces);
}
}
if self.rebuilds(op) {
ctxt.settle();
} else {
ctxt.settle_quiet();
}
}
async fn check_postconditions(&self, ctxt: &mut TestUi) {
let dump = ctxt.dump();
let (root, run) = self.expected();
let first = dump.lines().next().unwrap_or_default();
assert_eq!(first, root, "root box mismatch (model {self:?})\n--- dump ---\n{dump}");
if let Some(run) = run {
assert!(dump.contains(&run), "missing run {run:?} (model {self:?})\n--- dump ---\n{dump}");
} else {
let marker = match self.locale {
Locale::Raw => "data-l10n-id=\\\"count\\\"".to_owned(),
Locale::En => format!("l10n \\\"{} items\\\"", self.n),
Locale::De => format!("l10n \\\"{} Dinge\\\"", self.n),
};
assert!(dump.contains(&marker), "outline missing {marker:?} (model {self:?})\n--- dump ---\n{dump}");
}
}
async fn clean_up_test_run(&self, _ctxt: &mut TestUi) {
}
}
#[test]
fn ui_state_machine_matches_model() {
proptest_stateful::test::<UiModel>(ProptestStatefulConfig {
min_ops: 1,
max_ops: 8,
test_case_timeout: Duration::from_secs(60),
proptest_config: ProptestConfig {
cases: 32,
..ProptestConfig::default()
},
});
}