Compare commits
4
Commits
de46f150f8
...
0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b77fb18398 | ||
|
|
2fe8f18589 | ||
|
|
a1076a0731 | ||
|
|
20c2afb5ad |
Generated
+1
-1
@@ -1778,7 +1778,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sustenance"
|
name = "sustenance"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sustenance"
|
name = "sustenance"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -96,3 +96,31 @@ test("removing a meal from a list removes its ingredients", async ({ page }) =>
|
|||||||
await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0);
|
await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0);
|
||||||
await expect(row).toHaveCount(0);
|
await expect(row).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a meal can be checked off without removing it from the list", async ({ page }) => {
|
||||||
|
await registerAndLogin(page, "alice@example.com");
|
||||||
|
await createMealWithIngredients(page, "Spaghetti Bolognese", [{ name: "Penne" }]);
|
||||||
|
await createList(page, "Weekly shop");
|
||||||
|
|
||||||
|
await page.click(".add-meal-button");
|
||||||
|
const picker = page.locator(".meal-picker-backdrop");
|
||||||
|
await picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" }).click();
|
||||||
|
|
||||||
|
const panel = page.locator("#list-meals-panel");
|
||||||
|
const row = panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator(".list-meal-check-button")).not.toHaveClass(/is-.*checked/);
|
||||||
|
|
||||||
|
// Check the meal off as eaten.
|
||||||
|
await row.locator(".list-meal-check-button").click();
|
||||||
|
await expect(row).toHaveClass(/is-checked/);
|
||||||
|
await expect(row.locator(".list-meal-check-button")).toHaveText("✓");
|
||||||
|
// The meal is still on the list, not removed.
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
|
||||||
|
|
||||||
|
// Unchecking restores it.
|
||||||
|
await row.locator(".list-meal-check-button").click();
|
||||||
|
await expect(row).not.toHaveClass(/is-checked/);
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE list_meals ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
|
||||||
+27
-16
@@ -48,12 +48,23 @@ fn content_hash(data: &[u8]) -> String {
|
|||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Constructs a `StaticAssetStore` from `name => path` pairs, embedding each
|
/// Returns the file name portion of a path, e.g. `"../static/style.css"`
|
||||||
/// file's bytes at compile time via `include_bytes!`.
|
/// becomes `"style.css"`. Used to derive an asset's registry key from its
|
||||||
|
/// path.
|
||||||
|
fn file_name(path: &'static str) -> &'static str {
|
||||||
|
match path.rfind('/') {
|
||||||
|
Some(i) => &path[i + 1..],
|
||||||
|
None => path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a `StaticAssetStore` from paths, embedding each file's bytes at
|
||||||
|
/// compile time via `include_bytes!`. Each asset is keyed by its file name
|
||||||
|
/// (derived from the path), so there's no need to repeat the name.
|
||||||
macro_rules! static_assets {
|
macro_rules! static_assets {
|
||||||
($($name:literal => $path:literal),* $(,)?) => {
|
($($path:literal),* $(,)?) => {
|
||||||
StaticAssetStore::new(&[
|
StaticAssetStore::new(&[
|
||||||
$(($name, include_bytes!($path))),*
|
$((file_name($path), include_bytes!($path))),*
|
||||||
])
|
])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -61,18 +72,18 @@ macro_rules! static_assets {
|
|||||||
/// The app's static assets, loaded once on first use.
|
/// The app's static assets, loaded once on first use.
|
||||||
pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
|
pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
|
||||||
static_assets! {
|
static_assets! {
|
||||||
"style.css" => "../static/style.css",
|
"../static/style.css",
|
||||||
"passkey-login.js" => "../static/passkey-login.js",
|
"../static/passkey-login.js",
|
||||||
"passkey-register.js" => "../static/passkey-register.js",
|
"../static/passkey-register.js",
|
||||||
"password-toggle.js" => "../static/password-toggle.js",
|
"../static/password-toggle.js",
|
||||||
"rewards.js" => "../static/rewards.js",
|
"../static/rewards.js",
|
||||||
"htmx.min.js" => "../static/htmx.min.js",
|
"../static/htmx.min.js",
|
||||||
"idiomorph-ext.min.js" => "../static/idiomorph-ext.min.js",
|
"../static/idiomorph-ext.min.js",
|
||||||
"htmx-ws.min.js" => "../static/htmx-ws.min.js",
|
"../static/htmx-ws.min.js",
|
||||||
"favicon.ico" => "../static/favicon.ico",
|
"../static/favicon.ico",
|
||||||
"favicon-32x32.png" => "../static/favicon-32x32.png",
|
"../static/favicon-32x32.png",
|
||||||
"apple-touch-icon.png" => "../static/apple-touch-icon.png",
|
"../static/apple-touch-icon.png",
|
||||||
"logo.svg" => "../static/logo.svg",
|
"../static/logo.svg",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ pub struct ListMeal {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub meal_id: Option<i64>,
|
pub meal_id: Option<i64>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Whether the meal has been eaten (checked off) on this list.
|
||||||
|
pub checked: bool,
|
||||||
/// When the meal was added to the list.
|
/// When the meal was added to the list.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
|||||||
+25
@@ -133,6 +133,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/lists/{list_id}/meals/{list_meal_id}/remove",
|
"/lists/{list_id}/meals/{list_meal_id}/remove",
|
||||||
post(remove_meal_from_list),
|
post(remove_meal_from_list),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/lists/{list_id}/meals/{list_meal_id}/check",
|
||||||
|
post(check_list_meal),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/lists/{list_id}/carry",
|
"/lists/{list_id}/carry",
|
||||||
get(carry_over_modal).post(carry_meals),
|
get(carry_over_modal).post(carry_meals),
|
||||||
@@ -1177,6 +1181,27 @@ async fn remove_meal_from_list(
|
|||||||
list_fragment_response(&state, &user, list_id).await
|
list_fragment_response(&state, &user, list_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks a meal instance on a list as eaten (or not) without removing it.
|
||||||
|
async fn check_list_meal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path((list_id, list_meal_id)): Path<(i64, i64)>,
|
||||||
|
LoggedForm(form): LoggedForm<CheckForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
verify_csrf(&user, &form.csrf)?;
|
||||||
|
require_mutable_list(&state, list_id).await?;
|
||||||
|
let checked = match form.checked.as_str() {
|
||||||
|
"1" | "true" => true,
|
||||||
|
"0" | "false" => false,
|
||||||
|
_ => return Err(AppError::BadRequest("Invalid checked value.".into())),
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.meals
|
||||||
|
.set_list_meal_checked(list_id, list_meal_id, checked)
|
||||||
|
.await?;
|
||||||
|
list_fragment_response(&state, &user, list_id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the carry-over modal: a picker of active source lists (newest first,
|
/// Renders the carry-over modal: a picker of active source lists (newest first,
|
||||||
/// excluding the current list) that the user can import meals from.
|
/// excluding the current list) that the user can import meals from.
|
||||||
async fn carry_over_modal(
|
async fn carry_over_modal(
|
||||||
|
|||||||
+2
-3
@@ -135,7 +135,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// WebAuthn config from env vars. RP_ID must match the host users access the site from.
|
// WebAuthn config from env vars. RP_ID must match the host users access the site from.
|
||||||
let rp_id = env::var("RP_ID").unwrap_or_else(|_| {
|
let rp_id = env::var("RP_ID").unwrap_or_else(|_| {
|
||||||
let host = public_base_url
|
public_base_url
|
||||||
.trim_start_matches("http://")
|
.trim_start_matches("http://")
|
||||||
.trim_start_matches("https://")
|
.trim_start_matches("https://")
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -144,8 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.split(':')
|
.split(':')
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or("localhost")
|
.unwrap_or("localhost")
|
||||||
.to_owned();
|
.to_owned()
|
||||||
host
|
|
||||||
});
|
});
|
||||||
let rp_name = env::var("RP_NAME").unwrap_or_else(|_| "Sustenance".into());
|
let rp_name = env::var("RP_NAME").unwrap_or_else(|_| "Sustenance".into());
|
||||||
let origin =
|
let origin =
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ pub trait ItemRepository: Send + Sync {
|
|||||||
item_id: i64,
|
item_id: i64,
|
||||||
checked: bool,
|
checked: bool,
|
||||||
) -> DomainResult<i64>;
|
) -> DomainResult<i64>;
|
||||||
|
/// Update the mutable fields of an item. The bare field list mirrors the
|
||||||
|
/// edit form's inputs; clippy flags the argument count, which is justified
|
||||||
|
/// here by the flat domain signature.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn update_item(
|
async fn update_item(
|
||||||
&self,
|
&self,
|
||||||
txn: &mut SqliteConnection,
|
txn: &mut SqliteConnection,
|
||||||
@@ -191,6 +195,16 @@ pub trait ListMealRepository: Send + Sync {
|
|||||||
list_id: i64,
|
list_id: i64,
|
||||||
list_meal_id: i64,
|
list_meal_id: i64,
|
||||||
) -> DomainResult<i64>;
|
) -> DomainResult<i64>;
|
||||||
|
/// Marks a meal instance as eaten (or not) on a list, bumping the list's
|
||||||
|
/// revision exactly once. Like items, the meal stays in the list so it can
|
||||||
|
/// be toggled back.
|
||||||
|
async fn set_list_meal_checked(
|
||||||
|
&self,
|
||||||
|
txn: &mut SqliteConnection,
|
||||||
|
list_id: i64,
|
||||||
|
list_meal_id: i64,
|
||||||
|
checked: bool,
|
||||||
|
) -> DomainResult<i64>;
|
||||||
/// Copies the given meal instances from one list to another without
|
/// Copies the given meal instances from one list to another without
|
||||||
/// expanding their ingredients into items (they were already purchased).
|
/// expanding their ingredients into items (they were already purchased).
|
||||||
/// Returns the new rows and the destination list's bumped revision.
|
/// Returns the new rows and the destination list's bumped revision.
|
||||||
@@ -276,6 +290,9 @@ pub trait MealIngredientRepository: Send + Sync {
|
|||||||
note: String,
|
note: String,
|
||||||
category_id: Option<i64>,
|
category_id: Option<i64>,
|
||||||
) -> DomainResult<i64>;
|
) -> DomainResult<i64>;
|
||||||
|
/// Update the mutable fields of a meal ingredient. Same flat signature
|
||||||
|
/// rationale as `ItemRepository::update_item`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn update_ingredient(
|
async fn update_ingredient(
|
||||||
&self,
|
&self,
|
||||||
txn: &mut SqliteConnection,
|
txn: &mut SqliteConnection,
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ pub struct MealService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MealService {
|
impl MealService {
|
||||||
|
/// Builds the meal service from its dependencies. The argument count is
|
||||||
|
/// intentional: it assembles the service's repository/notifier graph.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
db: SqliteDatabase,
|
db: SqliteDatabase,
|
||||||
meals: Arc<dyn MealRepository>,
|
meals: Arc<dyn MealRepository>,
|
||||||
@@ -579,6 +582,29 @@ impl MealService {
|
|||||||
Ok(revision)
|
Ok(revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks a meal instance on a list as eaten (or not), keeping it in the list
|
||||||
|
/// and bumping the list revision exactly once.
|
||||||
|
pub async fn set_list_meal_checked(
|
||||||
|
&self,
|
||||||
|
list_id: i64,
|
||||||
|
list_meal_id: i64,
|
||||||
|
checked: bool,
|
||||||
|
) -> DomainResult<i64> {
|
||||||
|
let list_meals = Arc::clone(&self.list_meals);
|
||||||
|
let revision = self
|
||||||
|
.db
|
||||||
|
.run(move |txn| {
|
||||||
|
Box::pin(async move {
|
||||||
|
list_meals
|
||||||
|
.set_list_meal_checked(txn, list_id, list_meal_id, checked)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
self.realtime.publish_list_changed(list_id, revision).await;
|
||||||
|
Ok(revision)
|
||||||
|
}
|
||||||
|
|
||||||
/// Copies the given meal instances from a source list into the destination
|
/// Copies the given meal instances from a source list into the destination
|
||||||
/// list without re-expanding their ingredients into items (they were already
|
/// list without re-expanding their ingredients into items (they were already
|
||||||
/// purchased). The source list is left untouched. Publishes a realtime update
|
/// purchased). The source list is left untouched. Publishes a realtime update
|
||||||
|
|||||||
+210
-117
File diff suppressed because it is too large
Load Diff
+132
-113
@@ -802,116 +802,6 @@ fn render_markdown(source: &str) -> Markup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Extracts the rendered SVG width from `viewBox="0 0 <width> <height>"`.
|
|
||||||
fn svg_width(svg: &str) -> u32 {
|
|
||||||
let viewbox = svg
|
|
||||||
.split("viewBox=\"")
|
|
||||||
.nth(1)
|
|
||||||
.expect("expected viewBox")
|
|
||||||
.split('"')
|
|
||||||
.next()
|
|
||||||
.unwrap();
|
|
||||||
let width = viewbox.split_whitespace().nth(2).expect("expected width");
|
|
||||||
width.parse().expect("expected numeric width")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_markdown_turns_bullets_into_list_html() {
|
|
||||||
let html = render_markdown("- one\n- two\n").into_string();
|
|
||||||
assert!(html.contains("<ul>"), "expected <ul>, got: {html}");
|
|
||||||
assert!(html.contains("<li>"), "expected <li>, got: {html}");
|
|
||||||
assert!(!html.contains("* one"), "raw bullet leaked through: {html}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_markdown_renders_emphasis() {
|
|
||||||
let html = render_markdown("**bold** and *italic*").into_string();
|
|
||||||
assert!(html.contains("<strong>"), "expected <strong>, got: {html}");
|
|
||||||
assert!(html.contains("<em>"), "expected <em>, got: {html}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn format_date_renders_human_readable_date() {
|
|
||||||
// 2026-08-07T00:00:00Z in Unix seconds.
|
|
||||||
let ts = 1_786_060_800;
|
|
||||||
assert_eq!(format_date(ts), "7 Aug 2026");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn barcode_svg_renders_code128_svg() {
|
|
||||||
let html = barcode_svg("code128", "601123456789").into_string();
|
|
||||||
assert!(html.contains("<svg"), "expected svg, got: {html}");
|
|
||||||
assert!(html.contains("</svg>"), "expected closing svg, got: {html}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code128_uses_set_c_for_even_digit_numbers() {
|
|
||||||
// Set C (Ć) packs two digits per symbol, so an even-length digit-only
|
|
||||||
// number should be encoded with set C rather than set B.
|
|
||||||
assert_eq!(code128_input("601123456789"), "Ć601123456789");
|
|
||||||
// Non-numeric data falls back to set B.
|
|
||||||
assert_eq!(code128_input("ABC123"), "ƁABC123");
|
|
||||||
assert_eq!(code128_input(""), "Ɓ");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code128_odd_digit_numbers_lead_with_set_b_then_switch_to_set_c() {
|
|
||||||
// 21-digit number: first digit in set B, then set C for the rest.
|
|
||||||
assert_eq!(
|
|
||||||
code128_input("606171584511340224537"),
|
|
||||||
"Ɓ6Ć06171584511340224537"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code128_set_c_renders_shorter_than_set_b() {
|
|
||||||
let number = "601123456789";
|
|
||||||
let set_c = barcode_svg("code128", number).into_string();
|
|
||||||
// Force set B by using a non-numeric character so the digit-only
|
|
||||||
// fast path doesn't kick in.
|
|
||||||
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
|
|
||||||
let width_c = svg_width(&set_c);
|
|
||||||
let width_b = svg_width(&set_b);
|
|
||||||
assert!(
|
|
||||||
width_c < width_b,
|
|
||||||
"expected set C ({width_c}) narrower than set B ({width_b})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code128_odd_digit_number_renders_shorter_than_pure_set_b() {
|
|
||||||
let number = "606171584511340224537";
|
|
||||||
let mixed = barcode_svg("code128", number).into_string();
|
|
||||||
// Force pure set B by appending a non-numeric character.
|
|
||||||
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
|
|
||||||
let width_mixed = svg_width(&mixed);
|
|
||||||
let width_b = svg_width(&set_b);
|
|
||||||
assert!(
|
|
||||||
width_mixed < width_b,
|
|
||||||
"expected mixed ({width_mixed}) narrower than set B ({width_b})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn barcode_svg_renders_code39_svg() {
|
|
||||||
let html = barcode_svg("code39", "ABC123").into_string();
|
|
||||||
assert!(html.contains("<svg"), "expected svg, got: {html}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn barcode_svg_falls_back_to_text_for_invalid_number() {
|
|
||||||
// Code 39 only supports uppercase letters and digits, so lowercase
|
|
||||||
// input cannot be encoded and falls back to plain text.
|
|
||||||
let html = barcode_svg("code39", "abc").into_string();
|
|
||||||
assert!(!html.contains("<svg"), "expected no svg, got: {html}");
|
|
||||||
assert!(html.contains("abc"), "expected fallback text, got: {html}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_page(
|
pub fn list_page(
|
||||||
user: &User,
|
user: &User,
|
||||||
list: &GroceryList,
|
list: &GroceryList,
|
||||||
@@ -1288,7 +1178,26 @@ pub fn list_meals_panel(
|
|||||||
} @else {
|
} @else {
|
||||||
div class="list-meals" {
|
div class="list-meals" {
|
||||||
@for meal in list_meals {
|
@for meal in list_meals {
|
||||||
div class="list-meal-row" {
|
div
|
||||||
|
class=(if meal.checked { "list-meal-row is-checked" } else { "list-meal-row" })
|
||||||
|
id=(format!("list-meal-{}", meal.id))
|
||||||
|
{
|
||||||
|
@if editable {
|
||||||
|
form
|
||||||
|
class="list-meal-check"
|
||||||
|
hx-post=(format!("/lists/{}/meals/{}/check", list_id, meal.id))
|
||||||
|
hx-target="#list-items"
|
||||||
|
hx-swap="morph:outerHTML"
|
||||||
|
{
|
||||||
|
input type="hidden" name="csrf" value=(csrf_token);
|
||||||
|
input type="hidden" name="checked" value=(if meal.checked { "0" } else { "1" });
|
||||||
|
button type="submit" class="check-button list-meal-check-button" aria-label=(if meal.checked { format!("Mark {} as not eaten", meal.name) } else { format!("Mark {} as eaten", meal.name) }) {
|
||||||
|
@if meal.checked { "✓" } @else { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else if meal.checked {
|
||||||
|
span class="check-button check-button-static list-meal-check-button" { "✓" }
|
||||||
|
}
|
||||||
span class="list-meal-icon" { "🍽" }
|
span class="list-meal-icon" { "🍽" }
|
||||||
span class="list-meal-name" { (meal.name) }
|
span class="list-meal-name" { (meal.name) }
|
||||||
@if editable {
|
@if editable {
|
||||||
@@ -1596,7 +1505,7 @@ fn barcode_svg(symbology: &str, number: &str) -> Markup {
|
|||||||
.generate(&encoded)
|
.generate(&encoded)
|
||||||
.ok()
|
.ok()
|
||||||
}),
|
}),
|
||||||
_ => barcoders::sym::code128::Code128::new(&code128_input(number))
|
_ => barcoders::sym::code128::Code128::new(code128_input(number))
|
||||||
.ok()
|
.ok()
|
||||||
.map(|code| code.encode())
|
.map(|code| code.encode())
|
||||||
.and_then(|encoded| {
|
.and_then(|encoded| {
|
||||||
@@ -1628,7 +1537,7 @@ fn code128_input(number: &str) -> String {
|
|||||||
return format!("Ɓ{number}");
|
return format!("Ɓ{number}");
|
||||||
}
|
}
|
||||||
let len = number.chars().count();
|
let len = number.chars().count();
|
||||||
if len % 2 == 0 {
|
if len.is_multiple_of(2) {
|
||||||
format!("Ć{number}")
|
format!("Ć{number}")
|
||||||
} else {
|
} else {
|
||||||
let (first, rest) = number.split_at(1);
|
let (first, rest) = number.split_at(1);
|
||||||
@@ -1723,3 +1632,113 @@ fn initials(name: &str) -> String {
|
|||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.to_uppercase()
|
.to_uppercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Extracts the rendered SVG width from `viewBox="0 0 <width> <height>"`.
|
||||||
|
fn svg_width(svg: &str) -> u32 {
|
||||||
|
let viewbox = svg
|
||||||
|
.split("viewBox=\"")
|
||||||
|
.nth(1)
|
||||||
|
.expect("expected viewBox")
|
||||||
|
.split('"')
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
let width = viewbox.split_whitespace().nth(2).expect("expected width");
|
||||||
|
width.parse().expect("expected numeric width")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_turns_bullets_into_list_html() {
|
||||||
|
let html = render_markdown("- one\n- two\n").into_string();
|
||||||
|
assert!(html.contains("<ul>"), "expected <ul>, got: {html}");
|
||||||
|
assert!(html.contains("<li>"), "expected <li>, got: {html}");
|
||||||
|
assert!(!html.contains("* one"), "raw bullet leaked through: {html}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_renders_emphasis() {
|
||||||
|
let html = render_markdown("**bold** and *italic*").into_string();
|
||||||
|
assert!(html.contains("<strong>"), "expected <strong>, got: {html}");
|
||||||
|
assert!(html.contains("<em>"), "expected <em>, got: {html}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_date_renders_human_readable_date() {
|
||||||
|
// 2026-08-07T00:00:00Z in Unix seconds.
|
||||||
|
let ts = 1_786_060_800;
|
||||||
|
assert_eq!(format_date(ts), "7 Aug 2026");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn barcode_svg_renders_code128_svg() {
|
||||||
|
let html = barcode_svg("code128", "601123456789").into_string();
|
||||||
|
assert!(html.contains("<svg"), "expected svg, got: {html}");
|
||||||
|
assert!(html.contains("</svg>"), "expected closing svg, got: {html}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code128_uses_set_c_for_even_digit_numbers() {
|
||||||
|
// Set C (Ć) packs two digits per symbol, so an even-length digit-only
|
||||||
|
// number should be encoded with set C rather than set B.
|
||||||
|
assert_eq!(code128_input("601123456789"), "Ć601123456789");
|
||||||
|
// Non-numeric data falls back to set B.
|
||||||
|
assert_eq!(code128_input("ABC123"), "ƁABC123");
|
||||||
|
assert_eq!(code128_input(""), "Ɓ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code128_odd_digit_numbers_lead_with_set_b_then_switch_to_set_c() {
|
||||||
|
// 21-digit number: first digit in set B, then set C for the rest.
|
||||||
|
assert_eq!(
|
||||||
|
code128_input("606171584511340224537"),
|
||||||
|
"Ɓ6Ć06171584511340224537"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code128_set_c_renders_shorter_than_set_b() {
|
||||||
|
let number = "601123456789";
|
||||||
|
let set_c = barcode_svg("code128", number).into_string();
|
||||||
|
// Force set B by using a non-numeric character so the digit-only
|
||||||
|
// fast path doesn't kick in.
|
||||||
|
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
|
||||||
|
let width_c = svg_width(&set_c);
|
||||||
|
let width_b = svg_width(&set_b);
|
||||||
|
assert!(
|
||||||
|
width_c < width_b,
|
||||||
|
"expected set C ({width_c}) narrower than set B ({width_b})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code128_odd_digit_number_renders_shorter_than_pure_set_b() {
|
||||||
|
let number = "606171584511340224537";
|
||||||
|
let mixed = barcode_svg("code128", number).into_string();
|
||||||
|
// Force pure set B by appending a non-numeric character.
|
||||||
|
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
|
||||||
|
let width_mixed = svg_width(&mixed);
|
||||||
|
let width_b = svg_width(&set_b);
|
||||||
|
assert!(
|
||||||
|
width_mixed < width_b,
|
||||||
|
"expected mixed ({width_mixed}) narrower than set B ({width_b})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn barcode_svg_renders_code39_svg() {
|
||||||
|
let html = barcode_svg("code39", "ABC123").into_string();
|
||||||
|
assert!(html.contains("<svg"), "expected svg, got: {html}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn barcode_svg_falls_back_to_text_for_invalid_number() {
|
||||||
|
// Code 39 only supports uppercase letters and digits, so lowercase
|
||||||
|
// input cannot be encoded and falls back to plain text.
|
||||||
|
let html = barcode_svg("code39", "abc").into_string();
|
||||||
|
assert!(!html.contains("<svg"), "expected no svg, got: {html}");
|
||||||
|
assert!(html.contains("abc"), "expected fallback text, got: {html}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -259,7 +259,11 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
|
|||||||
.list-meals { display: grid; gap: 8px; }
|
.list-meals { display: grid; gap: 8px; }
|
||||||
.list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; }
|
.list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; }
|
||||||
.list-meal-row:last-child { border-bottom: 0; }
|
.list-meal-row:last-child { border-bottom: 0; }
|
||||||
|
.list-meal-check { margin: 0; flex: 0 0 auto; }
|
||||||
|
.list-meal-check .check-button { width: 26px; height: 26px; }
|
||||||
|
.is-checked .list-meal-name { color: var(--muted); text-decoration: line-through; }
|
||||||
.list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
|
.list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
|
||||||
|
.is-checked .list-meal-icon { filter: grayscale(.4); opacity: .7; }
|
||||||
.list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
|
.list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
|
||||||
.list-meal-remove { margin: 0; flex: 0 0 auto; }
|
.list-meal-remove { margin: 0; flex: 0 0 auto; }
|
||||||
.list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; }
|
.list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user