Split collections_ui.rs into a module directory by area of behaviour
collections_ui.rs had grown to 4,591 lines covering the sidebar controller, the click/drag selection policy, tree refresh, the drag gesture, the trash worker, twelve wiring functions, and the row's rename/create/context menu, all in one file. Split into collections_ui/ with one module per area, the way develop/ and library/ were already split on this branch: - controller.rs: CollectionsController and the pure drop/delete/release decisions (decide_drop, decide_delete, decide_release, menu_detail, delete_warning) that a test can drive without a window. - press.rs: PressUndo and the click-and-release selection policy (apply_press, select_row, commit_press, cancel_press). - tree_sync.rs: rebuilding the sidebar from the catalog and pushing catalog-derived state into the grid (refresh_tree, offline_state, sync_lifted/sync_selection/sync_reorderable/sync_badges, refresh_membership, direct_holdings). - drag.rs: the cursor bitmap (compose_drag_image, blit_scaled) and the hold/spring timers (arm_hold, arm_spring, should_spring, collapse_spring_opened) plus their delay constants. - trash.rs: the soft delete (start_trash, start_restore, drain_trash, stop_trash, refresh_trash, format_bytes). - wiring_grid.rs / wiring_tree.rs: the wire() entry point and its twelve wire_* functions, split in two because together they were the largest single piece (grid-facing selection/drag/trash vs. sidebar-facing navigation/create/rename/row-drag/menu/membership). - rename_menu.rs: creating, naming and renaming collections, and the row's context menu (apply_rename, create_child, unique_name, open_row_menu, close_row_menu, close_rename). mod.rs carries the module's own top-level doc comment, the `pub use` re-exports for the eight items the rest of the crate reaches by `collections_ui::` path (CollectionsController, wire, refresh_tree, sync_badges, sync_selection, select_row, commit_press, cancel_press), and a shared `test_support` for the one fixture (`ids`) more than one file's tests needed. Every item that only crossed a boundary within this module, not out of it, was narrowed to `pub(super)` rather than kept at the crate-wide `pub` a single file gave it for free. Extracted with a brace-aware pass that kept each item's own leading doc comment and attributes attached to it, and tests moved with the code they exercise; every TRACES/GESTURE comment lands on the same code it did before. No file outside the new directory changed — lib.rs's `mod collections_ui;` resolves to the directory automatically, and every outside caller's `collections_ui::` path still resolves through mod.rs's re-exports.
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
//! Rebuilding the sidebar from the catalog, and pushing catalog-derived
|
||||
//! state into the grid: the selection flags, the lifted cells, the
|
||||
//! per-cell collection badges, and the membership sheet's own rows.
|
||||
//!
|
||||
//! `offline_state` and `direct_holdings` live here rather than beside the
|
||||
//! menu or the tray that reads them, because both are catalog reads over
|
||||
//! the tree — the same shape as `refresh_tree` itself, just narrower.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use dr_catalog::collections::{self as coll, CollectionKind};
|
||||
use dr_catalog::Catalog;
|
||||
use dr_types::{CollectionId, ImageId};
|
||||
use slint::Model as _;
|
||||
|
||||
use crate::{AppWindow, CollectionRow};
|
||||
|
||||
use super::controller::CollectionsController;
|
||||
|
||||
/// Rebuild the sidebar from the catalog.
|
||||
///
|
||||
/// Called after every edit. The whole tree rather than a patch: a rename can
|
||||
/// reorder siblings, a delete promotes children, and a drop changes counts on
|
||||
/// every ancestor — diffing that against the model would be more code than the
|
||||
/// query costs, and the tree is tens of rows, not thousands.
|
||||
pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog: &Catalog) {
|
||||
let rows = match coll::tree(catalog.connection()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
window.set_collection_error(format!("reading collections: {e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let collapsed = ctl.collapsed.borrow();
|
||||
// TRACES: FR-NC-6a | FR-NC-6c
|
||||
// How much of each collection is already on the device, rolled up the tree
|
||||
// — computed once for the whole sidebar rather than per row.
|
||||
let held = offline_state(catalog, &rows);
|
||||
|
||||
// A row is hidden when any ancestor is collapsed. The tree arrives
|
||||
// depth-first, so tracking the shallowest collapsed depth seen is enough —
|
||||
// no ancestor lookup per row.
|
||||
let mut hide_below: Option<usize> = None;
|
||||
let mut ids = Vec::new();
|
||||
let mut smart = Vec::new();
|
||||
let mut has_kids = Vec::new();
|
||||
let mut nested = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
if let Some(depth) = hide_below {
|
||||
if row.depth > depth {
|
||||
continue;
|
||||
}
|
||||
hide_below = None;
|
||||
}
|
||||
|
||||
let id = row.collection.id;
|
||||
let expanded = !collapsed.contains(&id);
|
||||
if row.has_children && !expanded {
|
||||
hide_below = Some(row.depth);
|
||||
}
|
||||
|
||||
// Deep counts are one query per row. That is fine at sidebar scale and
|
||||
// wrong at grid scale, which is why the grid does not do this.
|
||||
let deep =
|
||||
coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
|
||||
|
||||
let offline = held.get(&id).copied().unwrap_or_default();
|
||||
|
||||
ids.push(id);
|
||||
smart.push(row.collection.kind == CollectionKind::Smart);
|
||||
has_kids.push(row.has_children);
|
||||
nested.push(row.collection.parent.is_some());
|
||||
out.push(CollectionRow {
|
||||
id: id.0 as i32,
|
||||
name: row.collection.name.as_str().into(),
|
||||
depth: row.depth as i32,
|
||||
direct_count: row.collection.direct_count as i32,
|
||||
deep_count: deep as i32,
|
||||
has_children: row.has_children,
|
||||
expanded,
|
||||
smart: row.collection.kind == CollectionKind::Smart,
|
||||
// An empty collection is never "kept": every one of its zero
|
||||
// photographs being here is true and says nothing, and a full tray
|
||||
// beside an empty collection is a lie about what a trip would cost
|
||||
// to take.
|
||||
pinned: offline.total > 0 && offline.pinned == offline.total,
|
||||
partly_pinned: offline.pinned > 0 && offline.pinned < offline.total,
|
||||
});
|
||||
}
|
||||
|
||||
*ctl.row_ids.borrow_mut() = ids;
|
||||
*ctl.row_smart.borrow_mut() = smart;
|
||||
*ctl.row_has_children.borrow_mut() = has_kids;
|
||||
*ctl.row_nested.borrow_mut() = nested;
|
||||
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
||||
|
||||
// The rows are what `sync_reorderable` reads, so it has to be told they
|
||||
// changed: a collection that gains a child, or one that is deleted out from
|
||||
// under the scope, changes whether the grid can be reordered without the
|
||||
// scope itself moving.
|
||||
sync_reorderable(window);
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||
/// How many of a collection's photographs are pinned, and how many there are.
|
||||
///
|
||||
/// Counted over *members*, not distinct images: an image filed in both a parent
|
||||
/// and a child is counted in each, which is what makes the roll-up below a sum
|
||||
/// rather than a set union. That inflates the totals of a tree that files the
|
||||
/// same photograph twice, and it does not matter here — the only questions
|
||||
/// asked of these numbers are "all of them?" and "none of them?", and both
|
||||
/// survive the double count.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct OfflineCount {
|
||||
total: usize,
|
||||
pinned: usize,
|
||||
}
|
||||
|
||||
/// The offline state of every collection in the tree, descendants included.
|
||||
///
|
||||
/// One query for the direct membership, then a single reverse pass to fold each
|
||||
/// row into its parent. The alternative — `descendants` plus a count per row —
|
||||
/// is two queries per collection, and the sidebar rebuilds after every drop,
|
||||
/// rename and rating change.
|
||||
///
|
||||
/// The reverse pass is correct because `tree` emits parents before children:
|
||||
/// walking it backwards means every child has already been folded in by the
|
||||
/// time its parent is reached, however deep the nesting goes.
|
||||
fn offline_state(
|
||||
catalog: &Catalog,
|
||||
rows: &[coll::TreeRow],
|
||||
) -> std::collections::HashMap<CollectionId, OfflineCount> {
|
||||
let mut counts: std::collections::HashMap<CollectionId, OfflineCount> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
// `tier_actual`, not `pinned`: the question a user is asking of this icon is
|
||||
// "will these open on the aeroplane", and a pin whose download has not run
|
||||
// yet answers no (see the note on `tier_actual` in `dr_catalog::cache`).
|
||||
let sql = "SELECT cm.collection_id,
|
||||
count(*),
|
||||
coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0)
|
||||
FROM collection_members cm
|
||||
LEFT JOIN image_cache ic ON ic.image_id = cm.image_id
|
||||
GROUP BY cm.collection_id";
|
||||
|
||||
match catalog.connection().prepare(sql).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| {
|
||||
Ok((
|
||||
CollectionId(r.get::<_, i64>(0)? as u64),
|
||||
r.get::<_, i64>(1)? as usize,
|
||||
r.get::<_, i64>(2)? as usize,
|
||||
))
|
||||
})?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
}) {
|
||||
Ok(direct) => {
|
||||
for (id, total, pinned) in direct {
|
||||
counts.insert(id, OfflineCount { total, pinned });
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Not fatal: the tray then reads "nothing kept", which is the safe
|
||||
// direction — it offers a download rather than claiming a trip is
|
||||
// already on the device.
|
||||
log::debug!("reading offline state: {e}");
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
for row in rows.iter().rev() {
|
||||
let Some(parent) = row.collection.parent else {
|
||||
continue;
|
||||
};
|
||||
let child = counts.get(&row.collection.id).copied().unwrap_or_default();
|
||||
if child.total == 0 {
|
||||
continue;
|
||||
}
|
||||
let entry = counts.entry(parent).or_default();
|
||||
entry.total += child.total;
|
||||
entry.pinned += child.pinned;
|
||||
}
|
||||
|
||||
counts
|
||||
}
|
||||
|
||||
/// Mark which cells are currently lifted out by a drag.
|
||||
///
|
||||
/// Separate from [`sync_selection`] because the two differ: the selection
|
||||
/// persists after the drag, the lift lasts only while it is in flight.
|
||||
pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) {
|
||||
let model = window.get_library_cells();
|
||||
for (row, id) in ids.iter().enumerate() {
|
||||
let want = lifted.contains(id);
|
||||
if let Some(mut cell) = model.row_data(row) {
|
||||
if cell.lifted != want {
|
||||
cell.lifted = want;
|
||||
model.set_row_data(row, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current selection into the grid model's `selected` flags.
|
||||
///
|
||||
/// The model carries the flag per cell so Slint can style without a lookup;
|
||||
/// this is what keeps the two in step after a scrub, a drop, or a click.
|
||||
pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||
let selection = ctl.selection.borrow();
|
||||
let model = window.get_library_cells();
|
||||
|
||||
// The anchor is deliberately **not** pushed to the grid.
|
||||
//
|
||||
// It used to be, and it was drawn as a thin ring inside the cell. Two
|
||||
// things were wrong with that. It was the clearest mark on the cell, so it
|
||||
// read as *the* selection to anyone who had not written it; and an anchor
|
||||
// outlives a deselection, so the ring sat around the last photograph
|
||||
// touched while nothing was selected at all — indistinguishable from a cell
|
||||
// that had stayed behind.
|
||||
//
|
||||
// The ordinal still lives in the controller and still decides where a range
|
||||
// extends from. What is gone is the claim that the user needs to see it:
|
||||
// the selection bar says "Tap the last photograph" while a range is armed,
|
||||
// which answers the question the ring was there to answer.
|
||||
for (row, id) in ids.iter().enumerate() {
|
||||
let want = selection.contains(id);
|
||||
if let Some(mut cell) = model.row_data(row) {
|
||||
if cell.selected != want {
|
||||
cell.selected = want;
|
||||
model.set_row_data(row, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.set_library_selected_count(selection.len() as i32);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7
|
||||
/// Whether what the grid is showing has an order the user can change.
|
||||
///
|
||||
/// A single manual collection: not the whole library, not a saved filter whose
|
||||
/// membership is a rule, and not a set — a set draws its descendants' images
|
||||
/// too, and two children's `position` columns are unrelated integers that would
|
||||
/// interleave arbitrarily.
|
||||
///
|
||||
/// Read off the tree rows the sidebar already draws rather than asked of the
|
||||
/// catalog. `smart` and `has_children` are the only two facts it needs and both
|
||||
/// are in the model already, so this cannot disagree with what the user is
|
||||
/// looking at, and a scope change costs no extra query.
|
||||
pub(super) fn sync_reorderable(window: &AppWindow) {
|
||||
let id = window.get_collection_selected();
|
||||
let rows = window.get_collection_rows();
|
||||
let manual = id > 0
|
||||
&& (0..rows.row_count())
|
||||
.filter_map(|i| rows.row_data(i))
|
||||
.find(|r| r.id == id)
|
||||
.is_some_and(|r| !r.smart && !r.has_children);
|
||||
window.set_library_reorderable(manual);
|
||||
}
|
||||
|
||||
/// Refresh the per-cell "in this many collections" badges.
|
||||
///
|
||||
/// One query for the whole window rather than one per cell: 120 cells is 120
|
||||
/// round trips otherwise, on every drop.
|
||||
pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT m.image_id, count(*)
|
||||
FROM collection_members m
|
||||
JOIN collections c ON c.id = m.collection_id
|
||||
WHERE m.image_id IN ({placeholders}) AND c.deleted = 0
|
||||
GROUP BY m.image_id"
|
||||
);
|
||||
|
||||
let params: Vec<rusqlite::types::Value> = ids
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
if let Ok(mut stmt) = catalog.connection().prepare(&sql) {
|
||||
if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
|
||||
}) {
|
||||
for (id, n) in rows.flatten() {
|
||||
counts.insert(id, n as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let model = window.get_library_cells();
|
||||
for (row, id) in ids.iter().enumerate() {
|
||||
let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0);
|
||||
if let Some(mut cell) = model.row_data(row) {
|
||||
if cell.collection_count != want {
|
||||
cell.collection_count = want;
|
||||
model.set_row_data(row, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7
|
||||
/// What a collection directly holds: its own member rows, and its own children.
|
||||
///
|
||||
/// One query each rather than reusing `deep_count`, which counts descendants'
|
||||
/// photographs — see [`menu_detail`] for why the menu needs the direct numbers.
|
||||
pub(super) fn direct_holdings(
|
||||
conn: &rusqlite::Connection,
|
||||
id: CollectionId,
|
||||
) -> Result<(usize, usize), dr_catalog::CatalogError> {
|
||||
let holds: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM collection_members WHERE collection_id = ?1",
|
||||
[id.0 as i64],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let children: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0",
|
||||
[id.0 as i64],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((holds as usize, children as usize))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// Rebuild the membership sheet from the selection as it stands.
|
||||
///
|
||||
/// Called on open and after every removal rather than patched: a removal can
|
||||
/// take the last selected photograph out of a collection, and that row then has
|
||||
/// to go — a patched model would leave a "Remove" that removes nothing.
|
||||
pub(super) fn refresh_membership(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<CollectionsController>,
|
||||
catalog: &Catalog,
|
||||
) {
|
||||
let chosen = ctl.selected();
|
||||
let total = chosen.len();
|
||||
let rows = match coll::membership_of(catalog.connection(), &chosen) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
window.set_collection_error(format!("reading collections: {e}").into());
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let out: Vec<crate::MembershipRow> = rows
|
||||
.into_iter()
|
||||
.map(|m| crate::MembershipRow {
|
||||
id: m.id.0 as i32,
|
||||
name: m.name.as_str().into(),
|
||||
holding: m.holding as i32,
|
||||
// "1 of 1" is arithmetic nobody asked for; with one photograph
|
||||
// selected the name already says everything true about it.
|
||||
detail: if total <= 1 {
|
||||
slint::SharedString::new()
|
||||
} else {
|
||||
format!("{} of {total}", m.holding).into()
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
window.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// TRACES: FR-NC-6a | FR-NC-6c
|
||||
/// A catalog with `n` images, for the offline roll-up tests.
|
||||
fn catalog_with_images(n: usize) -> (Catalog, Vec<ImageId>) {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
cat.connection()
|
||||
.execute(
|
||||
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..n {
|
||||
cat.connection()
|
||||
.execute(
|
||||
"INSERT INTO images (root_id, source_ref, added_at)
|
||||
VALUES (1, ?1, 0)",
|
||||
rusqlite::params![format!("Photos/img{i:03}.CR2")],
|
||||
)
|
||||
.unwrap();
|
||||
ids.push(ImageId(cat.connection().last_insert_rowid() as u64));
|
||||
}
|
||||
(cat, ids)
|
||||
}
|
||||
|
||||
/// Record an image as held on this device, as a finished download would.
|
||||
fn mark_held(cat: &Catalog, image: ImageId) {
|
||||
cat.connection()
|
||||
.execute(
|
||||
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
|
||||
VALUES (?1, ?2, ?2, 100, 1)",
|
||||
rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parent_reads_as_kept_only_when_its_children_are_too() {
|
||||
// The tray on a parent answers for the subtree, because that is what
|
||||
// keeping a parent downloads. A parent that showed a full tray over a
|
||||
// child with nothing downloaded would be a promise the aeroplane
|
||||
// breaks.
|
||||
let (cat, images) = catalog_with_images(4);
|
||||
let c = cat.connection();
|
||||
let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap();
|
||||
let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap();
|
||||
coll::add_images(c, parent, &images[0..2]).unwrap();
|
||||
coll::add_images(c, child, &images[2..4]).unwrap();
|
||||
|
||||
// Everything the parent holds directly, and nothing of the child's.
|
||||
mark_held(&cat, images[0]);
|
||||
mark_held(&cat, images[1]);
|
||||
|
||||
let rows = coll::tree(c).unwrap();
|
||||
let state = offline_state(&cat, &rows);
|
||||
|
||||
assert_eq!(
|
||||
state[&parent],
|
||||
OfflineCount {
|
||||
total: 4,
|
||||
pinned: 2
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
state[&child],
|
||||
OfflineCount {
|
||||
total: 2,
|
||||
pinned: 0
|
||||
}
|
||||
);
|
||||
|
||||
mark_held(&cat, images[2]);
|
||||
mark_held(&cat, images[3]);
|
||||
let state = offline_state(&cat, &coll::tree(c).unwrap());
|
||||
assert_eq!(
|
||||
state[&parent],
|
||||
OfflineCount {
|
||||
total: 4,
|
||||
pinned: 4
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grandchild_rolls_all_the_way_up() {
|
||||
// The reverse pass has to survive depth: a photograph three levels down
|
||||
// is still part of what the top-level collection would download.
|
||||
let (cat, images) = catalog_with_images(1);
|
||||
let c = cat.connection();
|
||||
let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap();
|
||||
let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap();
|
||||
let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap();
|
||||
coll::add_images(c, leaf, &images).unwrap();
|
||||
|
||||
let state = offline_state(&cat, &coll::tree(c).unwrap());
|
||||
|
||||
assert_eq!(
|
||||
state[&top],
|
||||
OfflineCount {
|
||||
total: 1,
|
||||
pinned: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
state[&mid],
|
||||
OfflineCount {
|
||||
total: 1,
|
||||
pinned: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_collection_has_nothing_to_keep() {
|
||||
// Guards the reading that would put a full tray beside an empty
|
||||
// collection: every one of its zero photographs is here, which is true
|
||||
// and useless. `refresh_tree` requires a non-zero total before drawing
|
||||
// one, and this is the fact it relies on.
|
||||
let (cat, _) = catalog_with_images(0);
|
||||
let c = cat.connection();
|
||||
let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap();
|
||||
|
||||
let state = offline_state(&cat, &coll::tree(c).unwrap());
|
||||
assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user