Compare commits
5
Commits
0.7.0
..
a1076a0731
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1076a0731 | ||
|
|
20c2afb5ad | ||
|
|
de46f150f8 | ||
|
|
77aac1e6ba | ||
|
|
950add40a3 |
Generated
+1
-1
@@ -1778,7 +1778,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "sustenance"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sustenance"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../fixtures";
|
||||
import { registerAndLogin, createList, createMealWithIngredients } from "../helpers";
|
||||
|
||||
/**
|
||||
* Opens the carry-over modal and waits for htmx to finish swapping it in, so
|
||||
* the source `<select>`'s `change` trigger is bound before interacting with it.
|
||||
*/
|
||||
async function openCarryModal(page: import("@playwright/test").Page) {
|
||||
await page.click(".carry-over-button");
|
||||
const carry = page.locator(".meal-picker-backdrop");
|
||||
await expect(carry).toBeVisible();
|
||||
// Let htmx finish processing the freshly-swapped modal before selecting a
|
||||
// source; otherwise the change event can be missed and no hx-get fires.
|
||||
await page.waitForTimeout(300);
|
||||
return carry;
|
||||
}
|
||||
|
||||
test("a user can carry meals over from a previous list without re-adding ingredients", async ({
|
||||
page,
|
||||
}) => {
|
||||
await registerAndLogin(page, "alice@example.com");
|
||||
await createMealWithIngredients(page, "Spaghetti Bolognese", [
|
||||
{ name: "Penne", quantity: "500g" },
|
||||
{ name: "Tomato", quantity: "2" },
|
||||
]);
|
||||
|
||||
// Last week's list has the meal added (with its ingredients as items).
|
||||
await createList(page, "Last week");
|
||||
await page.click(".add-meal-button");
|
||||
const picker = page.locator(".meal-picker-backdrop");
|
||||
await picker
|
||||
.locator(".meal-picker-button")
|
||||
.filter({ hasText: "Spaghetti Bolognese" })
|
||||
.click();
|
||||
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
|
||||
|
||||
// Create this week's (new) list.
|
||||
await createList(page, "This week");
|
||||
await expect(page.locator(".item-row")).toHaveCount(0);
|
||||
|
||||
// Open the carry-over modal and pick the source list.
|
||||
const carry = await openCarryModal(page);
|
||||
await expect(carry.locator(".carry-intro")).toContainText("already purchased");
|
||||
await carry.locator("#carry-source").selectOption({ label: "Last week" });
|
||||
|
||||
// The source list's meals appear as selectable rows.
|
||||
const row = carry.locator(".carry-row").filter({ hasText: "Spaghetti Bolognese" });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Select the meal and finish.
|
||||
await row.locator('input[type="checkbox"]').check();
|
||||
await carry.locator(".carry-submit").click();
|
||||
|
||||
// The modal closes, the meal appears in the meals panel, but no items are added.
|
||||
await expect(carry).toHaveCount(0);
|
||||
const panel = page.locator("#list-meals-panel");
|
||||
await expect(panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible();
|
||||
await expect(page.locator(".item-row")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the carry-over modal lists active lists newest first and excludes the current list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await registerAndLogin(page, "alice@example.com");
|
||||
await createList(page, "First list");
|
||||
await createList(page, "Second list");
|
||||
|
||||
// Open carry-over on the second (current) list.
|
||||
await page.click(".carry-over-button");
|
||||
const carry = page.locator(".meal-picker-backdrop");
|
||||
await expect(carry).toBeVisible();
|
||||
|
||||
const options = carry.locator("#carry-source option");
|
||||
// The current list is excluded; only the other list remains.
|
||||
await expect(options).toHaveCount(2); // placeholder + one source
|
||||
await expect(carry.locator("#carry-source")).toContainText("First list");
|
||||
await expect(carry.locator("#carry-source")).not.toContainText("Second list");
|
||||
});
|
||||
|
||||
test("carrying a meal does not remove it from the source list", async ({ page }) => {
|
||||
await registerAndLogin(page, "alice@example.com");
|
||||
await createMealWithIngredients(page, "Pasta", [{ name: "Penne" }]);
|
||||
await createList(page, "Last week");
|
||||
await page.click(".add-meal-button");
|
||||
await page
|
||||
.locator(".meal-picker-backdrop .meal-picker-button")
|
||||
.filter({ hasText: "Pasta" })
|
||||
.click();
|
||||
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
|
||||
|
||||
await createList(page, "This week");
|
||||
const carry = await openCarryModal(page);
|
||||
|
||||
// Select the source list; the meal rows load via hx-get.
|
||||
await carry.locator("#carry-source").selectOption({ label: "Last week" });
|
||||
const row = carry.locator(".carry-row").filter({ hasText: "Pasta" });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await row.locator('input[type="checkbox"]').check();
|
||||
await carry.locator(".carry-submit").click();
|
||||
await expect(carry).toHaveCount(0);
|
||||
|
||||
// The source list still has its meal and items.
|
||||
await page.goto("/lists");
|
||||
await page.locator(".list-card").filter({ hasText: "Last week" }).click();
|
||||
await expect(page.locator("#list-meals-panel .list-meal-row").filter({ hasText: "Pasta" })).toBeVisible();
|
||||
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("submitting carry-over with no selection does not add a meal", async ({ page }) => {
|
||||
await registerAndLogin(page, "alice@example.com");
|
||||
await createMealWithIngredients(page, "Pasta", [{ name: "Penne" }]);
|
||||
await createList(page, "Last week");
|
||||
await page.click(".add-meal-button");
|
||||
await page
|
||||
.locator(".meal-picker-backdrop .meal-picker-button")
|
||||
.filter({ hasText: "Pasta" })
|
||||
.click();
|
||||
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
|
||||
|
||||
await createList(page, "This week");
|
||||
const carry = await openCarryModal(page);
|
||||
|
||||
// Select the source list; the meal rows load via hx-get.
|
||||
await carry.locator("#carry-source").selectOption({ label: "Last week" });
|
||||
await expect(carry.locator(".carry-row").filter({ hasText: "Pasta" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Submit with nothing selected; the modal stays open and no meal is carried.
|
||||
await carry.locator(".carry-submit").click();
|
||||
await expect(carry).toBeVisible();
|
||||
await expect(page.locator("#list-meals-panel .list-meal-row")).toHaveCount(0);
|
||||
});
|
||||
@@ -20,6 +20,8 @@ tag ver:
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -m "version {{ver}} [skip ci]"
|
||||
git tag "{{ver}}"
|
||||
git push
|
||||
git push --tags
|
||||
|
||||
# Run the Rust unit tests.
|
||||
# Usage: just test
|
||||
@@ -34,4 +36,4 @@ fmt:
|
||||
# Run the end-to-end Playwright tests.
|
||||
# Usage: just e2e
|
||||
e2e:
|
||||
cd e2e && npm test
|
||||
cd e2e && npm test
|
||||
|
||||
+27
-16
@@ -48,12 +48,23 @@ fn content_hash(data: &[u8]) -> String {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Constructs a `StaticAssetStore` from `name => path` pairs, embedding each
|
||||
/// file's bytes at compile time via `include_bytes!`.
|
||||
/// Returns the file name portion of a path, e.g. `"../static/style.css"`
|
||||
/// 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 {
|
||||
($($name:literal => $path:literal),* $(,)?) => {
|
||||
($($path:literal),* $(,)?) => {
|
||||
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.
|
||||
pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
|
||||
static_assets! {
|
||||
"style.css" => "../static/style.css",
|
||||
"passkey-login.js" => "../static/passkey-login.js",
|
||||
"passkey-register.js" => "../static/passkey-register.js",
|
||||
"password-toggle.js" => "../static/password-toggle.js",
|
||||
"rewards.js" => "../static/rewards.js",
|
||||
"htmx.min.js" => "../static/htmx.min.js",
|
||||
"idiomorph-ext.min.js" => "../static/idiomorph-ext.min.js",
|
||||
"htmx-ws.min.js" => "../static/htmx-ws.min.js",
|
||||
"favicon.ico" => "../static/favicon.ico",
|
||||
"favicon-32x32.png" => "../static/favicon-32x32.png",
|
||||
"apple-touch-icon.png" => "../static/apple-touch-icon.png",
|
||||
"logo.svg" => "../static/logo.svg",
|
||||
"../static/style.css",
|
||||
"../static/passkey-login.js",
|
||||
"../static/passkey-register.js",
|
||||
"../static/password-toggle.js",
|
||||
"../static/rewards.js",
|
||||
"../static/htmx.min.js",
|
||||
"../static/idiomorph-ext.min.js",
|
||||
"../static/htmx-ws.min.js",
|
||||
"../static/favicon.ico",
|
||||
"../static/favicon-32x32.png",
|
||||
"../static/apple-touch-icon.png",
|
||||
"../static/logo.svg",
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+101
@@ -133,6 +133,11 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/lists/{list_id}/meals/{list_meal_id}/remove",
|
||||
post(remove_meal_from_list),
|
||||
)
|
||||
.route(
|
||||
"/lists/{list_id}/carry",
|
||||
get(carry_over_modal).post(carry_meals),
|
||||
)
|
||||
.route("/lists/{list_id}/carry/meals", get(carry_source_meals))
|
||||
.route("/rewards", get(rewards_page).post(create_rewards_card))
|
||||
.route("/rewards/{card_id}", get(rewards_scan_page))
|
||||
.route("/rewards/{card_id}/delete", post(delete_rewards_card))
|
||||
@@ -338,6 +343,21 @@ struct AddMealForm {
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CarrySourceQuery {
|
||||
source: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CarryMealsForm {
|
||||
source_list_id: i64,
|
||||
/// Selected list_meal ids as a comma-separated string, since the form
|
||||
/// extractor does not coalesce repeated keys into a Vec.
|
||||
#[serde(default)]
|
||||
list_meal_ids: String,
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MealPickerQuery {
|
||||
picker: Option<i64>,
|
||||
@@ -1157,6 +1177,87 @@ async fn remove_meal_from_list(
|
||||
list_fragment_response(&state, &user, list_id).await
|
||||
}
|
||||
|
||||
/// Renders the carry-over modal: a picker of active source lists (newest first,
|
||||
/// excluding the current list) that the user can import meals from.
|
||||
async fn carry_over_modal(
|
||||
State(state): State<AppState>,
|
||||
_user: CurrentUser,
|
||||
Path(dest_list_id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
require_mutable_list(&state, dest_list_id).await?;
|
||||
let lists = state.lists.list_summaries().await?;
|
||||
let sources: Vec<_> = lists
|
||||
.into_iter()
|
||||
.filter(|list| list.id != dest_list_id)
|
||||
.collect();
|
||||
Ok(html_response(views::carry_over_modal(
|
||||
&sources,
|
||||
dest_list_id,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Renders the selectable meal rows for a chosen source list inside the carry
|
||||
/// modal.
|
||||
async fn carry_source_meals(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(dest_list_id): Path<i64>,
|
||||
Query(query): Query<CarrySourceQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
require_mutable_list(&state, dest_list_id).await?;
|
||||
let source = require_list(&state, query.source).await?;
|
||||
if source.id == dest_list_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"Cannot carry meals from a list into itself.".into(),
|
||||
));
|
||||
}
|
||||
let list_meals = state.meals.list_meals_on_list(source.id).await?;
|
||||
Ok(html_response(views::carry_source_meals(
|
||||
&list_meals,
|
||||
source.id,
|
||||
dest_list_id,
|
||||
&user.session.csrf_token,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Copies the selected meals from a source list into the current list without
|
||||
/// re-adding their ingredients.
|
||||
async fn carry_meals(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(dest_list_id): Path<i64>,
|
||||
LoggedForm(form): LoggedForm<CarryMealsForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
require_mutable_list(&state, dest_list_id).await?;
|
||||
let source = require_list(&state, form.source_list_id).await?;
|
||||
if source.id == dest_list_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"Cannot carry meals from a list into itself.".into(),
|
||||
));
|
||||
}
|
||||
if form.list_meal_ids.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Select at least one meal to carry over.".into(),
|
||||
));
|
||||
}
|
||||
let list_meal_ids: Vec<i64> = form
|
||||
.list_meal_ids
|
||||
.split(',')
|
||||
.filter_map(|id| id.trim().parse().ok())
|
||||
.collect();
|
||||
if list_meal_ids.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Select at least one meal to carry over.".into(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.meals
|
||||
.carry_meals_to_list(source.id, dest_list_id, &list_meal_ids)
|
||||
.await?;
|
||||
list_fragment_response(&state, &user, dest_list_id).await
|
||||
}
|
||||
|
||||
async fn create_invitation(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
|
||||
+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.
|
||||
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("https://")
|
||||
.split('/')
|
||||
@@ -144,8 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("localhost")
|
||||
.to_owned();
|
||||
host
|
||||
.to_owned()
|
||||
});
|
||||
let rp_name = env::var("RP_NAME").unwrap_or_else(|_| "Sustenance".into());
|
||||
let origin =
|
||||
|
||||
+18
-1
@@ -153,6 +153,10 @@ pub trait ItemRepository: Send + Sync {
|
||||
item_id: i64,
|
||||
checked: bool,
|
||||
) -> 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(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
@@ -182,7 +186,7 @@ pub trait ListMealRepository: Send + Sync {
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
list_id: i64,
|
||||
meal_id: i64,
|
||||
meal_id: Option<i64>,
|
||||
name: String,
|
||||
) -> DomainResult<i64>;
|
||||
async fn remove_meal(
|
||||
@@ -191,6 +195,16 @@ pub trait ListMealRepository: Send + Sync {
|
||||
list_id: i64,
|
||||
list_meal_id: i64,
|
||||
) -> DomainResult<i64>;
|
||||
/// Copies the given meal instances from one list to another without
|
||||
/// expanding their ingredients into items (they were already purchased).
|
||||
/// Returns the new rows and the destination list's bumped revision.
|
||||
async fn copy_meals_to_list(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
source_list_id: i64,
|
||||
dest_list_id: i64,
|
||||
list_meal_ids: &[i64],
|
||||
) -> DomainResult<(Vec<ListMeal>, i64)>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -266,6 +280,9 @@ pub trait MealIngredientRepository: Send + Sync {
|
||||
note: String,
|
||||
category_id: Option<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(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
|
||||
+32
-1
@@ -345,6 +345,9 @@ pub struct 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(
|
||||
db: SqliteDatabase,
|
||||
meals: Arc<dyn MealRepository>,
|
||||
@@ -532,7 +535,7 @@ impl MealService {
|
||||
return Err(DomainError::NotFound);
|
||||
}
|
||||
let list_meal_id = list_meals
|
||||
.add_meal(txn, list_id, meal.id, meal.name.clone())
|
||||
.add_meal(txn, list_id, Some(meal.id), meal.name.clone())
|
||||
.await?;
|
||||
let new_items = meal
|
||||
.ingredients
|
||||
@@ -578,6 +581,34 @@ impl MealService {
|
||||
self.realtime.publish_list_changed(list_id, revision).await;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// Copies the given meal instances from a source list into the destination
|
||||
/// list without re-expanding their ingredients into items (they were already
|
||||
/// purchased). The source list is left untouched. Publishes a realtime update
|
||||
/// for the destination only.
|
||||
pub async fn carry_meals_to_list(
|
||||
&self,
|
||||
source_list_id: i64,
|
||||
dest_list_id: i64,
|
||||
list_meal_ids: &[i64],
|
||||
) -> DomainResult<Vec<ListMeal>> {
|
||||
let list_meals = Arc::clone(&self.list_meals);
|
||||
let ids = list_meal_ids.to_vec();
|
||||
let (copied, revision) = self
|
||||
.db
|
||||
.run(move |txn| {
|
||||
Box::pin(async move {
|
||||
list_meals
|
||||
.copy_meals_to_list(txn, source_list_id, dest_list_id, &ids)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
self.realtime
|
||||
.publish_list_changed(dest_list_id, revision)
|
||||
.await;
|
||||
Ok(copied)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InvitationService {
|
||||
|
||||
+235
-112
@@ -846,7 +846,7 @@ impl ListMealRepository for SqliteListMealRepository {
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
list_id: i64,
|
||||
meal_id: i64,
|
||||
meal_id: Option<i64>,
|
||||
name: String,
|
||||
) -> DomainResult<i64> {
|
||||
sqlx::query(
|
||||
@@ -886,6 +886,41 @@ impl ListMealRepository for SqliteListMealRepository {
|
||||
}
|
||||
bump_revision(txn, list_id).await
|
||||
}
|
||||
|
||||
async fn copy_meals_to_list(
|
||||
&self,
|
||||
txn: &mut SqliteConnection,
|
||||
source_list_id: i64,
|
||||
dest_list_id: i64,
|
||||
list_meal_ids: &[i64],
|
||||
) -> DomainResult<(Vec<ListMeal>, i64)> {
|
||||
let mut copied = Vec::with_capacity(list_meal_ids.len());
|
||||
for list_meal_id in list_meal_ids {
|
||||
let row =
|
||||
sqlx::query("SELECT meal_id, name FROM list_meals WHERE id = ?1 AND list_id = ?2")
|
||||
.bind(list_meal_id)
|
||||
.bind(source_list_id)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let Some(row) = row else {
|
||||
return Err(DomainError::NotFound);
|
||||
};
|
||||
let meal_id: Option<i64> = row.get(0);
|
||||
let name: String = row.get(1);
|
||||
let id = self
|
||||
.add_meal(txn, dest_list_id, meal_id, name.clone())
|
||||
.await?;
|
||||
copied.push(ListMeal {
|
||||
id,
|
||||
meal_id,
|
||||
name,
|
||||
created_at: now(),
|
||||
});
|
||||
}
|
||||
let revision = bump_revision(txn, dest_list_id).await?;
|
||||
Ok((copied, revision))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1504,7 +1539,7 @@ mod tests {
|
||||
let users = SqliteUserRepository;
|
||||
let email = email.to_owned();
|
||||
db.run(move |txn| {
|
||||
let users = users.clone();
|
||||
let users = users;
|
||||
Box::pin(async move {
|
||||
users
|
||||
.create_user(txn, email, "Test User".into(), "hash".into())
|
||||
@@ -1519,7 +1554,7 @@ mod tests {
|
||||
let lists = SqliteListRepository;
|
||||
let name = name.to_owned();
|
||||
db.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.create_list(txn, name).await })
|
||||
})
|
||||
.await
|
||||
@@ -1530,7 +1565,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let name_for_insert = name.to_owned();
|
||||
db.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move {
|
||||
items
|
||||
.add_item(
|
||||
@@ -1548,7 +1583,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let items = SqliteItemRepository;
|
||||
db.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.items(txn, list_id).await })
|
||||
})
|
||||
.await
|
||||
@@ -1561,7 +1596,7 @@ mod tests {
|
||||
async fn get_items(db: &SqliteDatabase, list_id: i64) -> Vec<Item> {
|
||||
let items = SqliteItemRepository;
|
||||
db.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.items(txn, list_id).await })
|
||||
})
|
||||
.await
|
||||
@@ -1571,7 +1606,7 @@ mod tests {
|
||||
async fn get_categories(db: &SqliteDatabase) -> Vec<Category> {
|
||||
let categories = SqliteCategoryRepository;
|
||||
db.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move { categories.categories(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1596,7 +1631,7 @@ mod tests {
|
||||
let users = SqliteUserRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let users = users.clone();
|
||||
let users = users;
|
||||
Box::pin(async move {
|
||||
users
|
||||
.create_user(
|
||||
@@ -1619,7 +1654,7 @@ mod tests {
|
||||
let users = SqliteUserRepository;
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let users = users.clone();
|
||||
let users = users;
|
||||
Box::pin(async move {
|
||||
users
|
||||
.find_user_by_email(txn, "alice@example.com".into())
|
||||
@@ -1640,7 +1675,7 @@ mod tests {
|
||||
let users = SqliteUserRepository;
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let users = users.clone();
|
||||
let users = users;
|
||||
Box::pin(async move {
|
||||
users
|
||||
.find_user_by_email(txn, "ALICE@EXAMPLE.COM".into())
|
||||
@@ -1658,7 +1693,7 @@ mod tests {
|
||||
let users = SqliteUserRepository;
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let users = users.clone();
|
||||
let users = users;
|
||||
Box::pin(async move {
|
||||
users
|
||||
.find_user_by_email(txn, "nobody@example.com".into())
|
||||
@@ -1676,7 +1711,7 @@ mod tests {
|
||||
let users = SqliteUserRepository;
|
||||
let empty = db
|
||||
.run(move |txn| {
|
||||
let users = users.clone();
|
||||
let users = users;
|
||||
Box::pin(async move { users.has_users(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1686,7 +1721,7 @@ mod tests {
|
||||
create_user(&db, "alice@example.com").await;
|
||||
let has = db
|
||||
.run(move |txn| {
|
||||
let users = users.clone();
|
||||
let users = users;
|
||||
Box::pin(async move { users.has_users(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1703,7 +1738,7 @@ mod tests {
|
||||
let sessions = SqliteSessionRepository;
|
||||
let (token, csrf) = db
|
||||
.run(move |txn| {
|
||||
let sessions = sessions.clone();
|
||||
let sessions = sessions;
|
||||
Box::pin(async move { sessions.create_session(txn, user.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -1713,7 +1748,7 @@ mod tests {
|
||||
|
||||
let session = db
|
||||
.run(move |txn| {
|
||||
let sessions = sessions.clone();
|
||||
let sessions = sessions;
|
||||
Box::pin(async move { sessions.session_user(txn, token.clone()).await })
|
||||
})
|
||||
.await
|
||||
@@ -1729,7 +1764,7 @@ mod tests {
|
||||
let sessions = SqliteSessionRepository;
|
||||
let session = db
|
||||
.run(move |txn| {
|
||||
let sessions = sessions.clone();
|
||||
let sessions = sessions;
|
||||
Box::pin(async move { sessions.session_user(txn, "bogus".into()).await })
|
||||
})
|
||||
.await
|
||||
@@ -1744,7 +1779,7 @@ mod tests {
|
||||
let sessions = SqliteSessionRepository;
|
||||
let (token, _) = db
|
||||
.run(move |txn| {
|
||||
let sessions = sessions.clone();
|
||||
let sessions = sessions;
|
||||
Box::pin(async move { sessions.create_session(txn, user.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -1752,7 +1787,7 @@ mod tests {
|
||||
|
||||
let token_for_delete = token.clone();
|
||||
db.run(move |txn| {
|
||||
let sessions = sessions.clone();
|
||||
let sessions = sessions;
|
||||
Box::pin(async move { sessions.delete_session(txn, token_for_delete).await })
|
||||
})
|
||||
.await
|
||||
@@ -1760,7 +1795,7 @@ mod tests {
|
||||
|
||||
let session = db
|
||||
.run(move |txn| {
|
||||
let sessions = sessions.clone();
|
||||
let sessions = sessions;
|
||||
Box::pin(async move { sessions.session_user(txn, token).await })
|
||||
})
|
||||
.await
|
||||
@@ -1790,7 +1825,7 @@ mod tests {
|
||||
let lists = SqliteListRepository;
|
||||
let summaries = db
|
||||
.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.list_summaries(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1808,7 +1843,7 @@ mod tests {
|
||||
|
||||
let list_id = list.id;
|
||||
db.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.set_archived(txn, list_id, true).await })
|
||||
})
|
||||
.await
|
||||
@@ -1816,7 +1851,7 @@ mod tests {
|
||||
|
||||
let summaries = db
|
||||
.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.list_summaries(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1825,7 +1860,7 @@ mod tests {
|
||||
|
||||
let archived = db
|
||||
.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.list_archived_summaries(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1843,13 +1878,13 @@ mod tests {
|
||||
|
||||
let list_id = list.id;
|
||||
db.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.set_archived(txn, list_id, true).await })
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.set_archived(txn, list_id, false).await })
|
||||
})
|
||||
.await
|
||||
@@ -1857,7 +1892,7 @@ mod tests {
|
||||
|
||||
let summaries = db
|
||||
.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.list_summaries(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1872,7 +1907,7 @@ mod tests {
|
||||
let lists = SqliteListRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.set_archived(txn, 9999, true).await })
|
||||
})
|
||||
.await;
|
||||
@@ -1886,7 +1921,7 @@ mod tests {
|
||||
let lists = SqliteListRepository;
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.get_list(txn, list.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -1895,7 +1930,7 @@ mod tests {
|
||||
|
||||
let missing = db
|
||||
.run(move |txn| {
|
||||
let lists = lists.clone();
|
||||
let lists = lists;
|
||||
Box::pin(async move { lists.get_list(txn, 9999).await })
|
||||
})
|
||||
.await
|
||||
@@ -1911,7 +1946,7 @@ mod tests {
|
||||
let categories = SqliteCategoryRepository;
|
||||
let id = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move { categories.create_category(txn, "Bakery".into()).await })
|
||||
})
|
||||
.await
|
||||
@@ -1928,7 +1963,7 @@ mod tests {
|
||||
let categories = SqliteCategoryRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move { categories.create_category(txn, "Produce".into()).await })
|
||||
})
|
||||
.await;
|
||||
@@ -1940,7 +1975,7 @@ mod tests {
|
||||
async fn get_meal_categories(db: &SqliteDatabase) -> Vec<MealCategory> {
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
db.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move { categories.meal_categories(txn).await })
|
||||
})
|
||||
.await
|
||||
@@ -1969,7 +2004,7 @@ mod tests {
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
let id = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move {
|
||||
categories
|
||||
.create_meal_category(txn, "Breakfast".into())
|
||||
@@ -1989,7 +2024,7 @@ mod tests {
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move { categories.create_meal_category(txn, "Beef".into()).await })
|
||||
})
|
||||
.await;
|
||||
@@ -2002,7 +2037,7 @@ mod tests {
|
||||
let categories = SqliteMealCategoryRepository;
|
||||
let category_id = db
|
||||
.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move {
|
||||
categories
|
||||
.create_meal_category(txn, "Breakfast".into())
|
||||
@@ -2014,7 +2049,7 @@ mod tests {
|
||||
let meal = create_meal(&db, "Pancakes").await;
|
||||
let meals = SqliteMealRepository;
|
||||
db.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move {
|
||||
meals
|
||||
.update_meal(
|
||||
@@ -2031,7 +2066,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
db.run(move |txn| {
|
||||
let categories = categories.clone();
|
||||
let categories = categories;
|
||||
Box::pin(async move { categories.delete_meal_category(txn, category_id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2039,7 +2074,7 @@ mod tests {
|
||||
|
||||
let fetched = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2057,7 +2092,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let revision = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move {
|
||||
items
|
||||
.add_item(
|
||||
@@ -2091,7 +2126,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move {
|
||||
items
|
||||
.add_item(
|
||||
@@ -2132,7 +2167,7 @@ mod tests {
|
||||
];
|
||||
let revision = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await })
|
||||
})
|
||||
.await
|
||||
@@ -2153,7 +2188,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let revision = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move {
|
||||
items
|
||||
.update_item(
|
||||
@@ -2186,7 +2221,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move {
|
||||
items
|
||||
.update_item(
|
||||
@@ -2213,7 +2248,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let revision = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.delete_item(txn, list.id, item.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2229,7 +2264,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.delete_item(txn, list.id, 9999).await })
|
||||
})
|
||||
.await;
|
||||
@@ -2243,7 +2278,7 @@ mod tests {
|
||||
let items = SqliteItemRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.set_item_checked(txn, list.id, 9999, true).await })
|
||||
})
|
||||
.await;
|
||||
@@ -2259,7 +2294,7 @@ mod tests {
|
||||
let invitations = SqliteInvitationRepository;
|
||||
let expires = db
|
||||
.run(move |txn| {
|
||||
let invitations = invitations.clone();
|
||||
let invitations = invitations;
|
||||
Box::pin(async move {
|
||||
invitations
|
||||
.create_invitation(txn, user.id, "token-1".into())
|
||||
@@ -2272,7 +2307,7 @@ mod tests {
|
||||
|
||||
let valid = db
|
||||
.run(move |txn| {
|
||||
let invitations = invitations.clone();
|
||||
let invitations = invitations;
|
||||
Box::pin(async move { invitations.invitation(txn, "token-1".into()).await })
|
||||
})
|
||||
.await
|
||||
@@ -2286,7 +2321,7 @@ mod tests {
|
||||
let invitations = SqliteInvitationRepository;
|
||||
let valid = db
|
||||
.run(move |txn| {
|
||||
let invitations = invitations.clone();
|
||||
let invitations = invitations;
|
||||
Box::pin(async move { invitations.invitation(txn, "bogus".into()).await })
|
||||
})
|
||||
.await
|
||||
@@ -2300,7 +2335,7 @@ mod tests {
|
||||
let user = create_user(&db, "alice@example.com").await;
|
||||
let invitations = SqliteInvitationRepository;
|
||||
db.run(move |txn| {
|
||||
let invitations = invitations.clone();
|
||||
let invitations = invitations;
|
||||
Box::pin(async move {
|
||||
invitations
|
||||
.create_invitation(txn, user.id, "token-1".into())
|
||||
@@ -2311,7 +2346,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
db.run(move |txn| {
|
||||
let invitations = invitations.clone();
|
||||
let invitations = invitations;
|
||||
Box::pin(async move { invitations.accept_invitation(txn, "token-1".into()).await })
|
||||
})
|
||||
.await
|
||||
@@ -2319,7 +2354,7 @@ mod tests {
|
||||
|
||||
let valid = db
|
||||
.run(move |txn| {
|
||||
let invitations = invitations.clone();
|
||||
let invitations = invitations;
|
||||
Box::pin(async move { invitations.invitation(txn, "token-1".into()).await })
|
||||
})
|
||||
.await
|
||||
@@ -2333,7 +2368,7 @@ mod tests {
|
||||
let invitations = SqliteInvitationRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let invitations = invitations.clone();
|
||||
let invitations = invitations;
|
||||
Box::pin(async move { invitations.accept_invitation(txn, "bogus".into()).await })
|
||||
})
|
||||
.await;
|
||||
@@ -2349,13 +2384,13 @@ mod tests {
|
||||
let item = add_item(&db, list.id, "Coffee").await;
|
||||
let items = SqliteItemRepository;
|
||||
db.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.set_item_checked(txn, list.id, item.id, true).await })
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.set_item_checked(txn, list.id, item.id, true).await })
|
||||
})
|
||||
.await
|
||||
@@ -2376,7 +2411,7 @@ mod tests {
|
||||
let first_item = get_items(&db, list.id).await.remove(0);
|
||||
|
||||
db.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move {
|
||||
items
|
||||
.set_item_checked(txn, list.id, first_item.id, true)
|
||||
@@ -2398,7 +2433,7 @@ mod tests {
|
||||
let meals = SqliteMealRepository;
|
||||
let name = name.to_owned();
|
||||
db.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.create_meal(txn, name, String::new(), None).await })
|
||||
})
|
||||
.await
|
||||
@@ -2414,7 +2449,7 @@ mod tests {
|
||||
let ingredients = SqliteMealIngredientRepository;
|
||||
let name_for_insert = name.to_owned();
|
||||
db.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move {
|
||||
ingredients
|
||||
.add_ingredient(
|
||||
@@ -2432,7 +2467,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let ingredients = SqliteMealIngredientRepository;
|
||||
db.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal_id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2459,7 +2494,7 @@ mod tests {
|
||||
let meals = SqliteMealRepository;
|
||||
let meals = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.list_meals(txn, "").await })
|
||||
})
|
||||
.await
|
||||
@@ -2476,12 +2511,8 @@ mod tests {
|
||||
create_meal(&db, "Chicken Curry").await;
|
||||
let meals = SqliteMealRepository;
|
||||
let names = |posted_query: &str| {
|
||||
let meals = meals.clone();
|
||||
let posted_query = posted_query.to_owned();
|
||||
db.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
Box::pin(async move { meals.list_meals(txn, &posted_query).await })
|
||||
})
|
||||
db.run(move |txn| Box::pin(async move { meals.list_meals(txn, &posted_query).await }))
|
||||
};
|
||||
let matched = names("pasta").await.unwrap();
|
||||
let matched_names = matched.iter().map(|m| m.name.as_str()).collect::<Vec<_>>();
|
||||
@@ -2496,12 +2527,8 @@ mod tests {
|
||||
create_meal(&db, "Salad").await;
|
||||
let meals = SqliteMealRepository;
|
||||
let names = |posted_query: &str| {
|
||||
let meals = meals.clone();
|
||||
let posted_query = posted_query.to_owned();
|
||||
db.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
Box::pin(async move { meals.list_meals(txn, &posted_query).await })
|
||||
})
|
||||
db.run(move |txn| Box::pin(async move { meals.list_meals(txn, &posted_query).await }))
|
||||
};
|
||||
// A bare `%` must not act as a wildcard matching every meal: it only
|
||||
// matches meals that contain a literal `%` character.
|
||||
@@ -2523,7 +2550,7 @@ mod tests {
|
||||
let meals = SqliteMealRepository;
|
||||
let fetched = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2545,7 +2572,7 @@ mod tests {
|
||||
let meals = SqliteMealRepository;
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.get_meal(txn, 9999).await })
|
||||
})
|
||||
.await
|
||||
@@ -2559,7 +2586,7 @@ mod tests {
|
||||
let meal = create_meal(&db, "Pasta").await;
|
||||
let meals = SqliteMealRepository;
|
||||
db.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move {
|
||||
meals
|
||||
.update_meal(
|
||||
@@ -2576,7 +2603,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let fetched = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2592,7 +2619,7 @@ mod tests {
|
||||
let meals = SqliteMealRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move {
|
||||
meals
|
||||
.update_meal(txn, 9999, "X".into(), String::new(), None)
|
||||
@@ -2610,14 +2637,14 @@ mod tests {
|
||||
add_ingredient(&db, meal.id, "Penne", None).await;
|
||||
let meals = SqliteMealRepository;
|
||||
db.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.delete_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2631,7 +2658,7 @@ mod tests {
|
||||
let meals = SqliteMealRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let meals = meals.clone();
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.delete_meal(txn, 9999).await })
|
||||
})
|
||||
.await;
|
||||
@@ -2645,7 +2672,7 @@ mod tests {
|
||||
let ingredients = SqliteMealIngredientRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move {
|
||||
ingredients
|
||||
.add_ingredient(
|
||||
@@ -2670,7 +2697,7 @@ mod tests {
|
||||
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
|
||||
let ingredients = SqliteMealIngredientRepository;
|
||||
db.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move {
|
||||
ingredients
|
||||
.update_ingredient(
|
||||
@@ -2689,7 +2716,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let fetched = db
|
||||
.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2705,7 +2732,7 @@ mod tests {
|
||||
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
|
||||
let ingredients = SqliteMealIngredientRepository;
|
||||
db.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move {
|
||||
ingredients
|
||||
.delete_ingredient(txn, meal.id, ingredient.id)
|
||||
@@ -2716,7 +2743,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let remaining = db
|
||||
.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2731,7 +2758,7 @@ mod tests {
|
||||
let ingredients = SqliteMealIngredientRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move { ingredients.delete_ingredient(txn, meal.id, 9999).await })
|
||||
})
|
||||
.await;
|
||||
@@ -2746,8 +2773,10 @@ mod tests {
|
||||
let meal_id = meal.id;
|
||||
let id = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals.clone();
|
||||
Box::pin(async move { list_meals.add_meal(txn, list_id, meal_id, name).await })
|
||||
let list_meals = list_meals;
|
||||
Box::pin(
|
||||
async move { list_meals.add_meal(txn, list_id, Some(meal_id), name).await },
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2769,7 +2798,7 @@ mod tests {
|
||||
let list_meals = SqliteListMealRepository;
|
||||
let meals = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals.clone();
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2792,7 +2821,7 @@ mod tests {
|
||||
let ingredients = SqliteMealIngredientRepository;
|
||||
let ingredient_rows = db
|
||||
.run(move |txn| {
|
||||
let ingredients = ingredients.clone();
|
||||
let ingredients = ingredients;
|
||||
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2808,7 +2837,7 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
db.run(move |txn| {
|
||||
let items = items.clone();
|
||||
let items = items;
|
||||
Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await })
|
||||
})
|
||||
.await
|
||||
@@ -2818,7 +2847,7 @@ mod tests {
|
||||
let list_meals = SqliteListMealRepository;
|
||||
let revision = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals.clone();
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move { list_meals.remove_meal(txn, list.id, list_meal.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2827,7 +2856,7 @@ mod tests {
|
||||
|
||||
let meals = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals.clone();
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2843,13 +2872,107 @@ mod tests {
|
||||
let list_meals = SqliteListMealRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals.clone();
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move { list_meals.remove_meal(txn, list.id, 9999).await })
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(DomainError::NotFound)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copying_meals_to_another_list_does_not_add_items() {
|
||||
let db = setup().await;
|
||||
let source = create_list(&db, "Last week").await;
|
||||
let dest = create_list(&db, "This week").await;
|
||||
let meal = create_meal(&db, "Pasta").await;
|
||||
add_ingredient(&db, meal.id, "Penne", None).await;
|
||||
let list_meal = add_meal_to_list(&db, source.id, &meal).await;
|
||||
|
||||
let list_meals = SqliteListMealRepository;
|
||||
let (copied, revision) = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move {
|
||||
list_meals
|
||||
.copy_meals_to_list(txn, source.id, dest.id, &[list_meal.id])
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The meal is copied to the destination, but no items are created.
|
||||
assert_eq!(copied.len(), 1);
|
||||
assert_eq!(copied[0].name, "Pasta");
|
||||
assert_eq!(copied[0].meal_id, Some(meal.id));
|
||||
assert!(get_items(&db, dest.id).await.is_empty());
|
||||
// The destination revision is bumped exactly once.
|
||||
assert_eq!(revision, 1);
|
||||
// The source meal is untouched.
|
||||
let source_meals = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move { list_meals.list_meals(txn, source.id).await })
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(source_meals.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copying_a_meal_with_a_deleted_catalog_meal_preserves_its_name() {
|
||||
let db = setup().await;
|
||||
let source = create_list(&db, "Last week").await;
|
||||
let dest = create_list(&db, "This week").await;
|
||||
let meal = create_meal(&db, "Pasta").await;
|
||||
let list_meal = add_meal_to_list(&db, source.id, &meal).await;
|
||||
|
||||
// Delete the catalog meal; the list_meals row keeps its name but meal_id
|
||||
// becomes NULL.
|
||||
let meals = SqliteMealRepository;
|
||||
db.run(move |txn| {
|
||||
let meals = meals;
|
||||
Box::pin(async move { meals.delete_meal(txn, meal.id).await })
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list_meals = SqliteListMealRepository;
|
||||
let (copied, _) = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move {
|
||||
list_meals
|
||||
.copy_meals_to_list(txn, source.id, dest.id, &[list_meal.id])
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(copied.len(), 1);
|
||||
assert_eq!(copied[0].name, "Pasta");
|
||||
assert_eq!(copied[0].meal_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copying_an_unknown_meal_fails() {
|
||||
let db = setup().await;
|
||||
let source = create_list(&db, "Last week").await;
|
||||
let dest = create_list(&db, "This week").await;
|
||||
let list_meals = SqliteListMealRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let list_meals = list_meals;
|
||||
Box::pin(async move {
|
||||
list_meals
|
||||
.copy_meals_to_list(txn, source.id, dest.id, &[9999])
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(DomainError::NotFound)));
|
||||
}
|
||||
|
||||
// ---- PasskeyRepository ----
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2860,7 +2983,7 @@ mod tests {
|
||||
|
||||
let created = db
|
||||
.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move {
|
||||
passkeys
|
||||
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
|
||||
@@ -2875,7 +2998,7 @@ mod tests {
|
||||
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move { passkeys.find_by_credential_id(txn, "cred-1".into()).await })
|
||||
})
|
||||
.await
|
||||
@@ -2885,7 +3008,7 @@ mod tests {
|
||||
|
||||
let listed = db
|
||||
.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move { passkeys.list_for_user(txn, user.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2893,7 +3016,7 @@ mod tests {
|
||||
assert_eq!(listed.len(), 1);
|
||||
|
||||
db.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move { passkeys.delete_passkey(txn, user.id, created.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2901,7 +3024,7 @@ mod tests {
|
||||
|
||||
let after = db
|
||||
.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move { passkeys.list_for_user(txn, user.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2915,7 +3038,7 @@ mod tests {
|
||||
let user = create_user(&db, "alice@example.com").await;
|
||||
let passkeys = SqlitePasskeyRepository;
|
||||
db.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move {
|
||||
passkeys
|
||||
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
|
||||
@@ -2927,7 +3050,7 @@ mod tests {
|
||||
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move {
|
||||
passkeys
|
||||
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
|
||||
@@ -2945,7 +3068,7 @@ mod tests {
|
||||
let passkeys = SqlitePasskeyRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let passkeys = passkeys.clone();
|
||||
let passkeys = passkeys;
|
||||
Box::pin(async move { passkeys.delete_passkey(txn, user.id, 9999).await })
|
||||
})
|
||||
.await;
|
||||
@@ -2962,7 +3085,7 @@ mod tests {
|
||||
|
||||
let created = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move {
|
||||
cards
|
||||
.create_card(
|
||||
@@ -2985,7 +3108,7 @@ mod tests {
|
||||
|
||||
let listed = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.list_cards(txn, user.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -2994,7 +3117,7 @@ mod tests {
|
||||
assert_eq!(listed[0].id, created.id);
|
||||
|
||||
db.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.delete_card(txn, user.id, created.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -3002,7 +3125,7 @@ mod tests {
|
||||
|
||||
let after = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.list_cards(txn, user.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -3018,7 +3141,7 @@ mod tests {
|
||||
let cards = SqliteRewardsCardRepository;
|
||||
|
||||
db.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move {
|
||||
cards
|
||||
.create_card(
|
||||
@@ -3036,7 +3159,7 @@ mod tests {
|
||||
|
||||
let bobs = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.list_cards(txn, bob.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -3046,7 +3169,7 @@ mod tests {
|
||||
// Bob cannot delete Alice's card.
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.delete_card(txn, bob.id, 1).await })
|
||||
})
|
||||
.await;
|
||||
@@ -3060,7 +3183,7 @@ mod tests {
|
||||
let cards = SqliteRewardsCardRepository;
|
||||
let result = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.delete_card(txn, user.id, 9999).await })
|
||||
})
|
||||
.await;
|
||||
@@ -3076,7 +3199,7 @@ mod tests {
|
||||
|
||||
let created = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move {
|
||||
cards
|
||||
.create_card(
|
||||
@@ -3095,7 +3218,7 @@ mod tests {
|
||||
// The owner can fetch their card.
|
||||
let found = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.get_card(txn, alice.id, created.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -3106,7 +3229,7 @@ mod tests {
|
||||
// Another user cannot fetch it.
|
||||
let not_found = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.get_card(txn, bob.id, created.id).await })
|
||||
})
|
||||
.await
|
||||
@@ -3116,7 +3239,7 @@ mod tests {
|
||||
// A missing id returns None.
|
||||
let missing = db
|
||||
.run(move |txn| {
|
||||
let cards = cards.clone();
|
||||
let cards = cards;
|
||||
Box::pin(async move { cards.get_card(txn, alice.id, 9999).await })
|
||||
})
|
||||
.await
|
||||
|
||||
+201
-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(
|
||||
user: &User,
|
||||
list: &GroceryList,
|
||||
@@ -1307,7 +1197,10 @@ pub fn list_meals_panel(
|
||||
}
|
||||
}
|
||||
@if editable {
|
||||
button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" }
|
||||
div class="list-meals-actions" {
|
||||
button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" }
|
||||
button class="button button-small carry-over-button" hx-get=(format!("/lists/{}/carry", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "Carry over" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1322,6 +1215,91 @@ pub fn list_meals_panel(
|
||||
}
|
||||
}
|
||||
|
||||
/// The carry-over modal: a picker of active source lists (newest first,
|
||||
/// excluding the current list) that the user can import meals from.
|
||||
pub fn carry_over_modal(sources: &[GroceryList], dest_list_id: i64) -> Markup {
|
||||
html! {
|
||||
div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" {
|
||||
div class="meal-picker-modal" role="dialog" aria-modal="true" aria-label="Carry over meals" {
|
||||
div class="meal-picker-header" {
|
||||
div {
|
||||
p class="eyebrow" { "CARRY OVER" }
|
||||
h2 { "Carry over meals" }
|
||||
}
|
||||
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "✕" }
|
||||
}
|
||||
p class="muted carry-intro" { "Import meals from another list. Their ingredients are not re-added — they were already purchased." }
|
||||
@if sources.is_empty() {
|
||||
div class="meal-picker-empty" {
|
||||
p { "No other lists to carry from." }
|
||||
}
|
||||
} @else {
|
||||
div class="carry-source-picker" {
|
||||
label for="carry-source" { "From list" }
|
||||
select
|
||||
id="carry-source"
|
||||
name="source"
|
||||
hx-get=(format!("/lists/{}/carry/meals", dest_list_id))
|
||||
hx-trigger="change"
|
||||
hx-target="#carry-results"
|
||||
hx-swap="innerHTML"
|
||||
{
|
||||
option value="" selected disabled { "Choose a list…" }
|
||||
@for source in sources {
|
||||
option value=(source.id) { (source.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
div id="carry-results" class="carry-results" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The selectable meal rows for a chosen source list inside the carry modal.
|
||||
pub fn carry_source_meals(
|
||||
list_meals: &[ListMeal],
|
||||
source_id: i64,
|
||||
dest_list_id: i64,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
html! {
|
||||
@if list_meals.is_empty() {
|
||||
div class="meal-picker-empty" {
|
||||
p { "This list has no meals to carry over." }
|
||||
}
|
||||
} @else {
|
||||
form
|
||||
hx-post=(format!("/lists/{}/carry", dest_list_id))
|
||||
hx-target="#list-items"
|
||||
hx-swap="morph:outerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
|
||||
class="carry-form"
|
||||
{
|
||||
input type="hidden" name="csrf" value=(csrf_token);
|
||||
input type="hidden" name="source_list_id" value=(source_id);
|
||||
// Collected as a comma-separated list by the checkboxes below;
|
||||
// the form extractor does not coalesce repeated keys into a Vec.
|
||||
input id="carry-selected" type="hidden" name="list_meal_ids" value="";
|
||||
div class="carry-list" {
|
||||
@for meal in list_meals {
|
||||
label class="carry-row" {
|
||||
input
|
||||
type="checkbox"
|
||||
value=(meal.id)
|
||||
onchange="var form=this.closest('form'); var box=form.querySelector('#carry-selected'); box.value=Array.from(form.querySelectorAll('input[type=checkbox]:checked')).map(function(c){return c.value}).join(',');";
|
||||
span class="carry-row-icon" { "🍽" }
|
||||
span class="carry-row-name" { (meal.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
button class="button button-primary carry-submit" type="submit" { "Carry over" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn presence_panel(presence: &[PresenceUser], out_of_band: bool) -> Markup {
|
||||
if out_of_band {
|
||||
html! {
|
||||
@@ -1508,7 +1486,7 @@ fn barcode_svg(symbology: &str, number: &str) -> Markup {
|
||||
.generate(&encoded)
|
||||
.ok()
|
||||
}),
|
||||
_ => barcoders::sym::code128::Code128::new(&code128_input(number))
|
||||
_ => barcoders::sym::code128::Code128::new(code128_input(number))
|
||||
.ok()
|
||||
.map(|code| code.encode())
|
||||
.and_then(|encoded| {
|
||||
@@ -1540,7 +1518,7 @@ fn code128_input(number: &str) -> String {
|
||||
return format!("Ɓ{number}");
|
||||
}
|
||||
let len = number.chars().count();
|
||||
if len % 2 == 0 {
|
||||
if len.is_multiple_of(2) {
|
||||
format!("Ć{number}")
|
||||
} else {
|
||||
let (first, rest) = number.split_at(1);
|
||||
@@ -1635,3 +1613,113 @@ fn initials(name: &str) -> String {
|
||||
.collect::<String>()
|
||||
.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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,23 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
|
||||
.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:hover { color: var(--coral); background: #fbeae4; }
|
||||
.list-meals-panel .add-meal-button { margin-top: 12px; width: 100%; }
|
||||
.list-meals-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
|
||||
.list-meals-actions .button { width: 100%; margin: 0; }
|
||||
.carry-over-button { color: var(--deep-sage); background: #eef4e9; }
|
||||
.carry-over-button:hover { background: #e3eddc; }
|
||||
.carry-intro { margin: 14px 24px 4px; font-size: .85rem; }
|
||||
.carry-source-picker { padding: 14px 24px 4px; }
|
||||
.carry-source-picker label { display: block; margin-bottom: 6px; font-size: .78rem; font-weight: 800; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.carry-source-picker select { width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 12px; color: var(--ink); background: #fff; font-size: .9rem; }
|
||||
.carry-results { padding: 14px 24px 22px; overflow-y: auto; }
|
||||
.carry-form { margin: 0; }
|
||||
.carry-list { display: grid; gap: 8px; }
|
||||
.carry-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 14px; background: #fff; cursor: pointer; }
|
||||
.carry-row:hover { border-color: var(--sage); }
|
||||
.carry-row input { accent-color: var(--deep-sage); width: 17px; height: 17px; }
|
||||
.carry-row-icon { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; border-radius: 11px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
|
||||
.carry-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
|
||||
.carry-submit { margin-top: 14px; width: 100%; }
|
||||
.sharing-panel p, .tip-panel p { color: var(--muted); font-size: .86rem; }
|
||||
.invite-result { margin-top: 15px; }
|
||||
.invite-link-result { padding: 11px; border-radius: 12px; background: #f1f5ec; }
|
||||
|
||||
Reference in New Issue
Block a user