//! Smoke test: every page template loads from the embedded environment and
//! renders with a plausible context. Templates are data, not code, so a typo
//! in one is invisible to `cargo check` — this is what catches it.
use minijinja::{context, Environment};
use fieri_eternal::{add_config_globals, build_environment, Config};
const AREA_ID: &str = "0198f0aa-0000-7000-8000-00000000000a";
const RESTAURANT_ID: &str = "0198f0aa-0000-7000-8000-00000000000b";
fn env() -> Environment<'static> {
let mut env = build_environment();
add_config_globals(&mut env, &Config::from_env());
env
}
fn user() -> minijinja::Value {
context! {
id => "0198f0aa-0000-7000-8000-000000000001",
username => "alice",
email => "alice@example.com",
is_active => true,
created_at => "2026-08-03T00:00:00Z",
last_login_at => "2026-08-03T12:00:00Z",
}
}
fn area() -> minijinja::Value {
context! { id => AREA_ID, name => "Boston office", lat => 42.36, lon => -71.06, zoom => 15 }
}
fn restaurant(archived: bool) -> minijinja::Value {
context! {
id => RESTAURANT_ID,
area_id => AREA_ID,
name => "Tandoor's",
osm_ref => "N1915398676",
lat => 42.361,
lon => -71.059,
cuisine => "indian",
address => "12 Main St",
website => "https://example.com",
notes => "get the lamb",
archived => archived,
my_rank => "positive",
visits => 3,
last_visit => "2026-10-20",
}
}
fn scored(vetoed: bool) -> minijinja::Value {
context! {
id => RESTAURANT_ID,
name => "Tandoor's",
preference => 2.5,
recency => 0.4,
score => 1.0,
probability => 0.42,
last_visit_days => 8,
fans => vec!["alice"],
vetoed_by => if vetoed { vec!["bob"] } else { vec![] },
closed => vetoed,
}
}
/// Every full page renders and carries the app name from `APP_NAME`.
#[test]
fn renders_every_page() {
let env = env();
let cases: Vec<(&str, minijinja::Value)> = vec![
(
"index.html",
context! { title => "Home", user => user(), areas => vec![area()], registration_open => false },
),
("index.html", context! { title => "Home", user => user(), areas => Vec::<i32>::new() }),
("index.html", context! { title => "Home", registration_open => true }),
("auth/login.html", context! { title => "Login" }),
(
"cuisines/list.html",
context! {
title => "Cuisines", user => user(), area => area(), untagged => 2,
cuisines => vec![context! { name => "fish and chips", count => 3 }],
},
),
("cuisines/list.html", context! { title => "Cuisines", user => user(), cuisines => Vec::<i32>::new() }),
(
"cuisines/show.html",
context! {
title => "indian", user => user(), cuisine => "indian", total => 1,
groups => vec![context! {
area_name => "Boston office", area_id => AREA_ID,
rows => vec![context! {
restaurant => restaurant(false), cuisines => vec!["indian"], my_rank => "obsessed",
visits => 2, last_visit => "2026-10-20", lunch_today => "open",
}],
}],
},
),
("cuisines/show.html", context! { title => "x", user => user(), cuisine => "x", area => area(), total => 0, groups => Vec::<i32>::new() }),
(
"auth/register.html",
context! { title => "Register", invite_token => "tok-123", registration_open => false },
),
("auth/profile.html", context! { title => "Profile", user => user() }),
(
"auth/invites.html",
context! {
title => "Invites",
user => user(),
now => "2026-08-03T00:00:00Z",
invites => vec![context! {
token => "tok-123",
email => "friend@example.com",
expires_at => "2026-09-03T00:00:00Z",
is_used => false,
}],
},
),
(
"areas/list.html",
context! { title => "Areas", user => user(), areas => vec![context! { area => area(), restaurants => 4 }] },
),
("areas/new.html", context! { title => "New area", user => user() }),
(
"areas/show.html",
context! {
title => "Boston office",
user => user(),
area => area(),
restaurants => vec![restaurant(false), restaurant(true)],
refreshed => 2,
error => "taken",
osm_linked => 1,
open_outings => vec![context! { id => "o1", on_date => "2026-10-28", going => vec!["alice"] }],
markers => vec![context! { name => "Tandoor's", lat => 42.361, lon => -71.059, rank => "always", url => "/r" }],
},
),
(
"restaurants/new.html",
context! { title => "Add", user => user(), area => area(), markers => Vec::<i32>::new() },
),
(
"restaurants/show.html",
context! {
title => "Tandoor's",
user => user(),
restaurant => restaurant(false),
area => area(),
today => "2026-10-28",
cuisines => vec!["indian", "nepalese"],
hours => context! { raw => "Mo-Fr 11:00-15:00", at_lunch => "closed" },
my_rank => "never",
everyone => vec![
context! { username => "alice", rank => "never" },
context! { username => "carol", rank => "obsessed", since => "2019-04-01" },
context! { username => "bob" },
],
visits => vec![context! { id => "x", went_on => "2026-10-20", attendees => vec!["alice", "bob"] }],
},
),
(
"lunch/plan.html",
context! {
title => "Lunch", user => user(), area => area(), people => vec![user()],
today => "2026-10-28", meal_time => "12:00", max_meals => 14, default_options => 3, max_options => 6,
},
),
(
"lunch/history.html",
context! {
title => "History",
user => user(),
area => area(),
lunches => vec![context! {
id => "x", went_on => "2026-10-20", restaurant_id => RESTAURANT_ID,
restaurant => "Tandoor's", attendees => vec!["alice"],
}],
},
),
];
for (name, ctx) in cases {
let tmpl = env.get_template(name).unwrap_or_else(|e| panic!("load {name}: {e}"));
let html = tmpl.render(ctx).unwrap_or_else(|e| panic!("render {name}: {e}"));
assert!(
html.contains(fieri_eternal::APP_NAME),
"{name} did not render the app name"
);
}
}
/// The empty-collection branches of the list pages.
#[test]
fn renders_empty_lists() {
let env = env();
let pages = [
("auth/invites.html", "invites"),
("areas/list.html", "areas"),
("areas/show.html", "restaurants"),
("lunch/history.html", "lunches"),
];
for (name, key) in pages {
let ctx = context! { user => user(), area => area(), now => "2026-08-03T00:00:00Z", markers => Vec::<i32>::new(), ..
minijinja::Value::from_iter([(key, Vec::<minijinja::Value>::new())]) };
let tmpl = env.get_template(name).unwrap();
tmpl.render(ctx).unwrap_or_else(|e| panic!("render empty {name}: {e}"));
}
}
/// htmx fragments must keep the id their trigger swaps by, or the swap selects
/// nothing and the widget silently disappears.
#[test]
fn fragments_keep_their_swap_ids() {
let env = env();
let rank = env
.get_template("restaurants/_rank.html")
.unwrap()
.render(context! { restaurant_id => RESTAURANT_ID, my_rank => "always" })
.unwrap();
assert!(rank.contains(&format!(r#"id="rank-{RESTAURANT_ID}""#)));
assert!(rank.contains(r#"aria-pressed="true""#));
assert!(!rank.contains(r#"name="since""#), "since editor shown for a non-obsession");
let obsessed = env
.get_template("restaurants/_rank.html")
.unwrap()
.render(context! { restaurant_id => RESTAURANT_ID, my_rank => "obsessed", my_since => "2019-04-01" })
.unwrap();
assert!(obsessed.contains(r#"title="Obsessed since 2019-04-01""#));
assert!(obsessed.contains(r#"name="since" value="2019-04-01""#));
assert!(obsessed.contains("btn-warning"));
let candidates = env
.get_template("places/_candidates.html")
.unwrap()
.render(context! {
fit => true,
failed => false,
candidates => vec![context! {
place => context! { name => "O'Brien's", kind => "pub", lat => 1.0, lon => 2.0, distance_m => 40 },
json => context! { name => "O'Brien's", lat => 1.0, lon => 2.0 },
existing_id => RESTAURANT_ID,
}],
})
.unwrap();
assert!(candidates.contains(r#"id="candidates""#));
// The JSON sits in a single-quoted attribute; an apostrophe in a name
// must not end it.
assert!(!candidates.contains("O'Brien's\""), "unescaped quote in data-candidate");
assert!(candidates.contains("already added"));
let plan = env.get_template("lunch/_plan.html").unwrap();
for ctx in [
context! { area => area(), empty_group => true },
context! { area => area(), restaurant_count => 0, going => vec![user()], plan => Vec::<i32>::new() },
context! {
area => area(),
restaurant_count => 2,
going => vec![user()],
plan => vec![context! {
on => "2026-10-28",
pick => scored(false),
restaurant => restaurant(false),
field => vec![scored(false), scored(true)],
}],
},
context! {
area => area(),
restaurant_count => 2,
going => vec![user()],
plan => vec![
context! { on => "2026-10-28", field => vec![scored(true)] },
context! { on => "2026-10-29", pick => scored(false), restaurant => restaurant(false), field => vec![scored(false)] },
],
},
] {
let html = plan.render(ctx).unwrap_or_else(|e| panic!("render plan: {e}"));
assert!(html.contains(r#"id="plan-result""#));
}
}
fn outing(status: &str, rolls: i32) -> minijinja::Value {
context! {
id => "0198f0aa-0000-7000-8000-00000000000c", on_date => "2026-10-28", meal_time => "12:00:00",
status => status, rolls => rolls,
}
}
fn option(votes: Vec<&str>, mine: bool, roll: i32, vetoed: bool) -> minijinja::Value {
context! {
restaurant => restaurant(false),
probability => 0.3,
roll => roll,
votes => votes.len(),
voters => votes,
mine => mine,
vetoed_by => if vetoed { Some(vec!["bob"]) } else { None },
}
}
/// The outing page in each state. While open the panel must poll itself on
/// the SSE event and the page must hold the SSE connection outside the
/// panel; once settled, neither.
#[test]
fn outing_states() {
let env = env();
let page = env.get_template("outings/show.html").unwrap();
let base = |status: &str, options: Vec<minijinja::Value>| {
context! {
title => "Lunch",
user => user(),
area => area(),
outing => outing(status, 2),
going => vec!["alice", "bob"],
am_going => true,
options => options,
leader_votes => 1,
all_voted => false,
rolled_away => 2,
decided => restaurant(false),
}
};
let open = page
.render(base("open", vec![option(vec!["bob"], false, 1, false), option(vec![], false, 2, true)]))
.unwrap();
assert!(open.contains(r#"sse-connect="/outings/"#));
assert!(open.contains(r#"hx-trigger="sse:changed, every 30s""#));
assert!(open.contains("leading") && open.contains(">new<") && open.contains("ranked this"));
let empty = page.render(base("open", vec![])).unwrap();
assert!(empty.contains("Nothing to vote on"));
for status in ["decided", "cancelled"] {
let html = page.render(base(status, vec![])).unwrap();
assert!(!html.contains("sse-connect"), "{status} outing still streams");
assert!(!html.contains("sse:changed"), "{status} outing still polls");
}
}
/// Cuisine links must URL-encode the tag ("fish and chips") and keep the
/// area scope; the "I went" date must default to today.
#[test]
fn cuisine_links_and_i_went_date() {
let env = env();
let html = env
.get_template("cuisines/list.html")
.unwrap()
.render(context! {
user => user(), area => area(),
cuisines => vec![context! { name => "fish and chips", count => 3 }],
})
.unwrap();
assert!(html.contains(&format!("/cuisines/fish%20and%20chips?area={AREA_ID}")), "{html}");
let page = env
.get_template("restaurants/show.html")
.unwrap()
.render(context! {
user => user(), restaurant => restaurant(false), area => area(), today => "2026-10-28",
cuisines => vec!["indian"], everyone => Vec::<i32>::new(), visits => Vec::<i32>::new(),
})
.unwrap();
assert!(page.contains(r#"name="went_on" value="2026-10-28""#));
}
/// Collaboration pages render, and role gating hides what a role can't do.
#[test]
fn collaboration_pages_and_role_gating() {
let env = env();
let member = |name: &str, role: &str, me: bool, last_admin: bool| {
context! { id => format!("id-{name}"), username => name, role => role, me => me, link => "friends", last_admin => last_admin }
};
let roles = vec!["voter", "editor", "admin"];
let members = env.get_template("areas/members.html").unwrap();
let admin_view = members
.render(context! {
user => user(), area => area(), role => "admin", roles => roles.clone(), notice => "last-admin",
members => vec![member("alice", "admin", true, true), member("bob", "voter", false, false)],
invitations => vec![context! { user_id => "id-carol", username => "carol", role => "editor" }],
suggestions => vec![context! { id => "id-dave", username => "dave" }],
})
.unwrap();
assert!(admin_view.contains("members/invite") && admin_view.contains("withdraw"));
assert!(admin_view.contains("at least one admin"));
// The last admin gets no Leave button.
assert!(!admin_view.contains(">Leave<"));
let voter_view = members
.render(context! {
user => user(), area => area(), role => "voter", roles => roles.clone(),
members => vec![member("alice", "admin", false, true), member("bob", "voter", true, false)],
})
.unwrap();
assert!(!voter_view.contains("members/invite") && !voter_view.contains("/role\""));
assert!(voter_view.contains(">Leave<"));
let show = env.get_template("areas/show.html").unwrap();
let ctx = |role: &str| context! {
user => user(), area => area(), role => role, member_count => 3, osm_linked => 1,
restaurants => vec![restaurant(false)], markers => Vec::<i32>::new(),
};
let voter = show.render(ctx("voter")).unwrap();
assert!(!voter.contains("restaurants/new") && !voter.contains("/rename") && !voter.contains("/refresh"));
let editor = show.render(ctx("editor")).unwrap();
assert!(editor.contains("restaurants/new") && editor.contains("/refresh") && !editor.contains("/rename"));
assert!(show.render(ctx("admin")).unwrap().contains("/rename"));
let friends = env
.get_template("friends.html")
.unwrap()
.render(context! {
user => user(), notice => "requested",
friends => vec![context! { id => "1", username => "bob" }],
incoming => vec![context! { id => "2", username => "carol" }],
outgoing => vec![context! { id => "3", username => "dave" }],
suggestions => vec![context! { id => "4", username => "erin", shared_areas => 2 }],
})
.unwrap();
assert!(friends.contains("/friends/2/accept") && friends.contains("2 shared areas"));
let home = env
.get_template("index.html")
.unwrap()
.render(context! {
user => user(), areas => vec![area()], friend_requests => 1,
invitations => vec![context! { area => area(), role => "editor", invited_by => "alice" }],
})
.unwrap();
assert!(home.contains(&format!("/invitations/{AREA_ID}/accept")));
assert!(home.contains("as an editor") && home.contains("1 friend request waiting"));
let plan = env
.get_template("lunch/plan.html")
.unwrap()
.render(context! {
user => user(), area => area(), today => "2026-10-28", meal_time => "12:00",
max_meals => 14, default_options => 3, max_options => 6,
people => vec![
context! { id => "me", username => "alice", me => true, friend => false, shared => 0 },
context! { id => "b", username => "bob", me => false, friend => true, shared => 3 },
],
others => vec![context! { id => "c", username => "carol", me => false, friend => false, shared => 0 }],
})
.unwrap();
assert!(plan.contains("Everyone else in Boston office (1)"));
assert!(plan.contains("3 lunches together here"));
let invites = env
.get_template("auth/invites.html")
.unwrap()
.render(context! {
user => user(), now => "2026-10-28T00:00:00Z", roles => roles,
admin_areas => vec![context! { id => AREA_ID, name => "Boston office" }],
invites => vec![context! {
token => "t", email => None::<String>, expires_at => "2026-11-01T00:00:00Z", is_used => false,
areas => vec![context! { name => "Boston office", role => "editor" }],
}],
})
.unwrap();
assert!(invites.contains(&format!(r#"name="role_{AREA_ID}""#)));
assert!(invites.contains("Boston office <span class=\"opacity-60\">(editor)</span>"));
}
/// "Find similar nearby": editors get one-click Add forms, voters don't,
/// and a restaurant with no cuisine explains why there's nothing.
#[test]
fn similar_fragment() {
let env = env();
let tmpl = env.get_template("restaurants/_similar.html").unwrap();
let ctx = |can_add: bool| context! {
restaurant => restaurant(false), cuisines => vec!["pizza"], radius => 1500, can_add => can_add,
in_area => vec![context! { restaurant => restaurant(false), distance_m => 608, my_rank => "positive", cuisines => vec!["pizza"] }],
nearby => vec![context! {
name => "Lily's", osm_ref => "N1", lat => 42.0, lon => -71.0, cuisine => "pizza",
address => "33 Batterymarch St", website => "https://example.com", opening_hours => "Mo-Su 11:00-22:00", distance_m => 373,
}],
};
let editor = tmpl.render(ctx(true)).unwrap();
assert!(editor.contains(r#"id="similar""#) && editor.contains("373 m") && editor.contains("608 m"));
assert!(editor.contains(r#"name="opening_hours" value="Mo-Su 11:00-22:00""#));
assert!(editor.contains("within 1.5 km"));
assert!(!tmpl.render(ctx(false)).unwrap().contains(r#"action="/restaurants""#));
let none = tmpl
.render(context! { restaurant => restaurant(false), cuisines => Vec::<String>::new(), radius => 1500 })
.unwrap();
assert!(none.contains("No cuisine set"));
}
/// The area table's Last column goes through |timesince.
#[test]
fn area_last_column_uses_timesince() {
let env = env();
let html = env
.get_template("areas/show.html")
.unwrap()
.render(context! {
user => user(), area => area(), role => "voter", member_count => 1, osm_linked => 0,
restaurants => vec![restaurant(false)], markers => Vec::<i32>::new(),
})
.unwrap();
// restaurant() last visited 2026-10-20: some duration "ago", dated in the title.
assert!(html.contains(r#"<span title="2026-10-20">"#), "{html}");
assert!(html.contains(" ago</span>") || html.contains(">today</span>"));
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
20.4 KiB · text
History
3d2ee7c