13.6 KiB · text History 6dd7389
//! 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""#));
}