- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区) - catalog 所有类型统一输出 groups + items,条目查询按节点取 - 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组 - 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组 - 修复首页卡片配置为「整个分区」时预览为空 - 测试 59 passed
758 lines
24 KiB
Rust
758 lines
24 KiB
Rust
//! 分区(首页卡片 / 侧栏项)的新增与删除。
|
||
//!
|
||
//! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。
|
||
//! 新增时顺手建一条首页展示块,保证它立刻出现在首页。
|
||
|
||
use rusqlite::{Connection, OptionalExtension, params};
|
||
use serde::Deserialize;
|
||
|
||
use crate::{db::map_error, error::StashError, db::entries};
|
||
|
||
const KINDS: [&str; 3] = ["apps", "posters", "articles"];
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct NewCategory {
|
||
/// 业务键,只允许小写字母、数字与连字符
|
||
pub id: String,
|
||
pub title: String,
|
||
/// apps / posters / articles
|
||
pub kind: String,
|
||
}
|
||
|
||
/// 修改分区:字段都可选,只改传进来的。
|
||
#[derive(Debug, Clone, Default, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct UpdateCategory {
|
||
pub title: Option<String>,
|
||
pub icon: Option<String>,
|
||
pub bar_color: Option<String>,
|
||
/// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属
|
||
pub kind: Option<String>,
|
||
pub preview_limit: Option<i64>,
|
||
pub empty_text: Option<String>,
|
||
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
|
||
pub block_node_key: Option<String>,
|
||
}
|
||
|
||
/// 新建分组(子分类)
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct NewGroup {
|
||
pub id: String,
|
||
pub title: String,
|
||
}
|
||
|
||
/// 修改分组:改名 / 侧栏显隐
|
||
#[derive(Debug, Clone, Default, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct UpdateGroup {
|
||
pub title: Option<String>,
|
||
pub show_in_sidebar: Option<bool>,
|
||
}
|
||
|
||
fn valid_id(id: &str) -> bool {
|
||
!id.is_empty()
|
||
&& id.len() <= 32
|
||
&& id
|
||
.chars()
|
||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||
}
|
||
|
||
pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> {
|
||
let id = new.id.trim();
|
||
let title = new.title.trim();
|
||
|
||
if !valid_id(id) {
|
||
return Err(StashError::InvalidDirectory(
|
||
"标识只能用小写字母、数字与连字符(≤32)".into(),
|
||
));
|
||
}
|
||
if title.is_empty() || title.chars().count() > 40 {
|
||
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
|
||
}
|
||
if !KINDS.contains(&new.kind.as_str()) {
|
||
return Err(StashError::InvalidDirectory("类型不合法".into()));
|
||
}
|
||
|
||
let exists: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| row.get(0))
|
||
.map_err(map_error)?;
|
||
if exists > 0 {
|
||
return Err(StashError::Conflict(format!("分区已存在: {id}")));
|
||
}
|
||
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
let sort: i64 = tx
|
||
.query_row(
|
||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE kind = 'section'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
tx.execute(
|
||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
|
||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, 1)",
|
||
params![id, title, new.kind, sort],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
let block_sort: i64 = tx
|
||
.query_row(
|
||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.map_err(map_error)?;
|
||
tx.execute(
|
||
"INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)",
|
||
params![id, block_sort],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
entries::audit(&tx, "category.create", id, title)?;
|
||
tx.commit().map_err(map_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
struct SectionRow {
|
||
title: String,
|
||
icon: Option<String>,
|
||
bar_color: Option<String>,
|
||
view_mode: Option<String>,
|
||
preview_limit: Option<i64>,
|
||
empty_text: Option<String>,
|
||
}
|
||
|
||
fn valid_color(color: &str) -> bool {
|
||
if color.is_empty() {
|
||
return true;
|
||
}
|
||
let hex = color.strip_prefix('#').unwrap_or("");
|
||
(hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
|
||
}
|
||
|
||
/// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。
|
||
///
|
||
/// 改类型时迁移数据,保证切换后条目不丢:
|
||
/// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去,
|
||
/// 首页卡片也改指向该分组;
|
||
/// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。
|
||
pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> {
|
||
if id == "local" {
|
||
return Err(StashError::InvalidDirectory(
|
||
"「本地服务」是运行时分类,不能编辑".into(),
|
||
));
|
||
}
|
||
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
|
||
let current = tx
|
||
.query_row(
|
||
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text
|
||
FROM node WHERE key = ?1 AND kind = 'section'",
|
||
[id],
|
||
|row| {
|
||
Ok(SectionRow {
|
||
title: row.get(0)?,
|
||
icon: row.get(1)?,
|
||
bar_color: row.get(2)?,
|
||
view_mode: row.get(3)?,
|
||
preview_limit: row.get(4)?,
|
||
empty_text: row.get(5)?,
|
||
})
|
||
},
|
||
)
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
|
||
let Some(current) = current else {
|
||
return Err(StashError::NotFound(format!("分区不存在: {id}")));
|
||
};
|
||
|
||
let title = match patch.title {
|
||
Some(title) => {
|
||
let title = title.trim().to_string();
|
||
if title.is_empty() || title.chars().count() > 40 {
|
||
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
|
||
}
|
||
title
|
||
}
|
||
None => current.title,
|
||
};
|
||
|
||
let icon = match patch.icon {
|
||
Some(icon) => {
|
||
let icon = icon.trim().to_string();
|
||
if icon.chars().count() > 8 {
|
||
return Err(StashError::InvalidDirectory("图标最多 8 个字符".into()));
|
||
}
|
||
(!icon.is_empty()).then_some(icon)
|
||
}
|
||
None => current.icon,
|
||
};
|
||
|
||
let bar_color = match patch.bar_color {
|
||
Some(color) => {
|
||
let color = color.trim().to_ascii_lowercase();
|
||
if !valid_color(&color) {
|
||
return Err(StashError::InvalidDirectory(
|
||
"色条需为 #rgb 或 #rrggbb".into(),
|
||
));
|
||
}
|
||
(!color.is_empty()).then_some(color)
|
||
}
|
||
None => current.bar_color,
|
||
};
|
||
|
||
let empty_text = match patch.empty_text {
|
||
Some(text) => {
|
||
let text = text.trim().to_string();
|
||
(!text.is_empty()).then_some(text)
|
||
}
|
||
None => current.empty_text,
|
||
};
|
||
|
||
let preview_limit = match patch.preview_limit {
|
||
Some(limit) => {
|
||
if !(1..=60).contains(&limit) {
|
||
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
|
||
}
|
||
Some(limit)
|
||
}
|
||
None => current.preview_limit,
|
||
};
|
||
|
||
let kind = match patch.kind {
|
||
Some(kind) => {
|
||
if !KINDS.contains(&kind.as_str()) {
|
||
return Err(StashError::InvalidDirectory("类型不合法".into()));
|
||
}
|
||
kind
|
||
}
|
||
None => current
|
||
.view_mode
|
||
.clone()
|
||
.ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?,
|
||
};
|
||
|
||
let was_apps = current.view_mode.as_deref() == Some("apps");
|
||
let is_apps = kind == "apps";
|
||
|
||
if is_apps && !was_apps {
|
||
// 切到应用:条目必须有分组可挂;没有就建一个「全部」
|
||
let group_count: i64 = tx
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
|
||
[id],
|
||
|row| row.get(0),
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
let group_key = if group_count == 0 {
|
||
let group_key = format!("{id}-all");
|
||
tx.execute(
|
||
"INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar)
|
||
VALUES (?1, ?2, '全部', 'group', 1, 1)",
|
||
params![group_key, id],
|
||
)
|
||
.map_err(map_error)?;
|
||
group_key
|
||
} else {
|
||
tx.query_row(
|
||
"SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
|
||
ORDER BY sort, key LIMIT 1",
|
||
[id],
|
||
|row| row.get(0),
|
||
)
|
||
.map_err(map_error)?
|
||
};
|
||
|
||
tx.execute(
|
||
"UPDATE entry SET node_key = ?1 WHERE node_key = ?2",
|
||
params![group_key, id],
|
||
)
|
||
.map_err(map_error)?;
|
||
tx.execute(
|
||
"UPDATE section_block SET node_key = ?1 WHERE node_key = ?2",
|
||
params![group_key, id],
|
||
)
|
||
.map_err(map_error)?;
|
||
} else if !is_apps && was_apps {
|
||
// 离开应用:首页卡片从「某个分组」挪回分区,标题交还给分区
|
||
tx.execute(
|
||
"UPDATE section_block SET node_key = ?1, title = NULL
|
||
WHERE node_key IN (
|
||
SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
|
||
)",
|
||
[id],
|
||
)
|
||
.map_err(map_error)?;
|
||
}
|
||
|
||
tx.execute(
|
||
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
|
||
preview_limit = ?5, empty_text = ?6
|
||
WHERE key = ?7",
|
||
params![title, icon, bar_color, kind, preview_limit, empty_text, id],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
// 首页卡片预览哪个节点:分区自己或它下面的分组
|
||
if let Some(block_node_key) = patch.block_node_key {
|
||
let block_node_key = block_node_key.trim().to_string();
|
||
let valid: i64 = tx
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key
|
||
WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)",
|
||
params![block_node_key, id],
|
||
|row| row.get(0),
|
||
)
|
||
.map_err(map_error)?;
|
||
if valid == 0 {
|
||
return Err(StashError::InvalidDirectory(
|
||
"首页卡片只能预览本分区或它的分组".into(),
|
||
));
|
||
}
|
||
|
||
let updated = tx
|
||
.execute(
|
||
"UPDATE section_block SET node_key = ?1, title = NULL
|
||
WHERE id IN (
|
||
SELECT b.id FROM section_block b
|
||
JOIN node n ON n.key = b.node_key
|
||
LEFT JOIN node p ON p.key = n.parent_key
|
||
WHERE COALESCE(p.key, n.key) = ?2
|
||
)",
|
||
params![block_node_key, id],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
if updated == 0 {
|
||
let sort: i64 = tx
|
||
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| {
|
||
row.get(0)
|
||
})
|
||
.map_err(map_error)?;
|
||
tx.execute(
|
||
"INSERT INTO section_block (node_key, title, preview_limit, sort)
|
||
VALUES (?1, NULL, NULL, ?2)",
|
||
params![block_node_key, sort],
|
||
)
|
||
.map_err(map_error)?;
|
||
}
|
||
}
|
||
|
||
entries::audit(&tx, "category.update", id, &title)?;
|
||
tx.commit().map_err(map_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 在应用类分区下新建分组(子分类)。
|
||
pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> {
|
||
let id = new.id.trim();
|
||
let title = new.title.trim();
|
||
|
||
if !valid_id(id) {
|
||
return Err(StashError::InvalidDirectory(
|
||
"分组标识只能用小写字母、数字与连字符(≤32)".into(),
|
||
));
|
||
}
|
||
if title.is_empty() || title.chars().count() > 40 {
|
||
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
|
||
}
|
||
|
||
let section_exists: Option<String> = conn
|
||
.query_row(
|
||
"SELECT key FROM node WHERE key = ?1 AND kind = 'section'",
|
||
[section_id],
|
||
|row| row.get(0),
|
||
)
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
|
||
if section_exists.is_none() {
|
||
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
||
}
|
||
|
||
let exists: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| {
|
||
row.get(0)
|
||
})
|
||
.map_err(map_error)?;
|
||
if exists > 0 {
|
||
return Err(StashError::Conflict(format!("分组已存在: {id}")));
|
||
}
|
||
|
||
let sort: i64 = conn
|
||
.query_row(
|
||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
|
||
[section_id],
|
||
|row| row.get(0),
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
conn.execute(
|
||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
|
||
VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)",
|
||
params![id, section_id, title, sort],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
entries::audit(conn, "group.create", id, title)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 修改分组:改名 / 侧栏显隐。
|
||
pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> {
|
||
let current: Option<String> = conn
|
||
.query_row(
|
||
"SELECT title FROM node WHERE key = ?1 AND kind = 'group'",
|
||
[id],
|
||
|row| row.get(0),
|
||
)
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
|
||
let Some(current) = current else {
|
||
return Err(StashError::NotFound(format!("分组不存在: {id}")));
|
||
};
|
||
|
||
let title = match patch.title {
|
||
Some(title) => {
|
||
let title = title.trim().to_string();
|
||
if title.is_empty() || title.chars().count() > 40 {
|
||
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
|
||
}
|
||
title
|
||
}
|
||
None => current,
|
||
};
|
||
|
||
match patch.show_in_sidebar {
|
||
Some(show) => conn.execute(
|
||
"UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3",
|
||
params![title, show, id],
|
||
),
|
||
None => conn.execute(
|
||
"UPDATE node SET title = ?1 WHERE key = ?2",
|
||
params![title, id],
|
||
),
|
||
}
|
||
.map_err(map_error)?;
|
||
|
||
entries::audit(conn, "group.update", id, &title)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 删除分组:组内条目会一起删除(前端需二次确认)。
|
||
pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> {
|
||
let removed = conn
|
||
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id])
|
||
.map_err(map_error)?;
|
||
|
||
if removed == 0 {
|
||
return Err(StashError::NotFound(format!("分组不存在: {id}")));
|
||
}
|
||
|
||
entries::audit(conn, "group.delete", id, "")?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
|
||
if id == "local" {
|
||
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
|
||
}
|
||
|
||
let removed = conn
|
||
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'section'", [id])
|
||
.map_err(map_error)?;
|
||
|
||
if removed == 0 {
|
||
return Err(StashError::NotFound(format!("分区不存在: {id}")));
|
||
}
|
||
|
||
entries::audit(conn, "category.delete", id, "")?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn db() -> Connection {
|
||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||
conn.execute_batch(
|
||
"PRAGMA foreign_keys = ON;
|
||
CREATE TABLE node (
|
||
id INTEGER PRIMARY KEY,
|
||
key TEXT NOT NULL UNIQUE,
|
||
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||
title TEXT NOT NULL,
|
||
kind TEXT NOT NULL,
|
||
view_mode TEXT,
|
||
icon TEXT,
|
||
bar_color TEXT,
|
||
preview_limit INTEGER,
|
||
empty_text TEXT,
|
||
sort INTEGER NOT NULL DEFAULT 0,
|
||
show_in_sidebar INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
CREATE TABLE entry (
|
||
key TEXT PRIMARY KEY,
|
||
node_key TEXT NOT NULL
|
||
);
|
||
CREATE TABLE section_block (
|
||
id INTEGER PRIMARY KEY,
|
||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||
title TEXT,
|
||
preview_limit INTEGER,
|
||
sort INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE audit (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
at INTEGER NOT NULL,
|
||
action TEXT NOT NULL,
|
||
target TEXT,
|
||
detail TEXT
|
||
);",
|
||
)
|
||
.expect("应能建表");
|
||
conn
|
||
}
|
||
|
||
#[test]
|
||
fn create_then_delete() {
|
||
let mut conn = db();
|
||
create(
|
||
&mut conn,
|
||
NewCategory {
|
||
id: "books".into(),
|
||
title: "书单".into(),
|
||
kind: "posters".into(),
|
||
},
|
||
)
|
||
.expect("应能创建");
|
||
|
||
let blocks: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
||
.expect("应能查");
|
||
assert_eq!(blocks, 1);
|
||
|
||
remove(&mut conn, "books").expect("应能删除");
|
||
let nodes: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
|
||
.expect("应能查");
|
||
assert_eq!(nodes, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_bad_id_and_duplicate() {
|
||
let mut conn = db();
|
||
assert!(
|
||
create(
|
||
&mut conn,
|
||
NewCategory {
|
||
id: "Bad Id".into(),
|
||
title: "x".into(),
|
||
kind: "apps".into()
|
||
}
|
||
)
|
||
.is_err()
|
||
);
|
||
|
||
create(
|
||
&mut conn,
|
||
NewCategory {
|
||
id: "books".into(),
|
||
title: "书单".into(),
|
||
kind: "apps".into(),
|
||
},
|
||
)
|
||
.expect("应能创建");
|
||
|
||
let error = create(
|
||
&mut conn,
|
||
NewCategory {
|
||
id: "books".into(),
|
||
title: "书单".into(),
|
||
kind: "apps".into(),
|
||
},
|
||
)
|
||
.expect_err("重复应失败");
|
||
assert!(matches!(error, StashError::Conflict(_)));
|
||
}
|
||
|
||
fn section(id: &str, title: &str, kind: &str) -> NewCategory {
|
||
NewCategory {
|
||
id: id.into(),
|
||
title: title.into(),
|
||
kind: kind.into(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn updates_fields_and_rejects_bad_values() {
|
||
let mut conn = db();
|
||
create(&mut conn, section("books", "书单", "posters")).expect("应能创建");
|
||
|
||
update(
|
||
&mut conn,
|
||
"books",
|
||
UpdateCategory {
|
||
title: Some("读过的书".into()),
|
||
icon: Some("📚".into()),
|
||
bar_color: Some("#ff6034".into()),
|
||
preview_limit: Some(8),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.expect("应能更新");
|
||
|
||
let (title, icon, color, limit): (String, Option<String>, Option<String>, Option<i64>) =
|
||
conn.query_row(
|
||
"SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||
)
|
||
.expect("应能查");
|
||
assert_eq!(title, "读过的书");
|
||
assert_eq!(icon.as_deref(), Some("📚"));
|
||
assert_eq!(color.as_deref(), Some("#ff6034"));
|
||
assert_eq!(limit, Some(8));
|
||
|
||
assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err());
|
||
assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err());
|
||
assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err());
|
||
assert!(matches!(
|
||
update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"),
|
||
StashError::NotFound(_)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn switching_apps_to_posters_moves_card_back_to_section() {
|
||
let mut conn = db();
|
||
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
|
||
conn.execute(
|
||
"INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)",
|
||
[],
|
||
)
|
||
.expect("应能建分组");
|
||
conn.execute(
|
||
"INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')",
|
||
[],
|
||
)
|
||
.expect("应能建条目");
|
||
conn.execute(
|
||
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
|
||
[],
|
||
)
|
||
.expect("应能建卡片");
|
||
|
||
update(&mut conn, "books", UpdateCategory { kind: Some("posters".into()), ..Default::default() })
|
||
.expect("应能改类型");
|
||
|
||
let block: String = conn
|
||
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
||
.expect("应能查");
|
||
assert_eq!(block, "books", "卡片应挪回分区");
|
||
let mode: String = conn
|
||
.query_row("SELECT view_mode FROM node WHERE key='books'", [], |row| row.get(0))
|
||
.expect("应能查");
|
||
assert_eq!(mode, "posters");
|
||
}
|
||
|
||
#[test]
|
||
fn creates_updates_and_removes_group() {
|
||
let mut conn = db();
|
||
create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建");
|
||
|
||
create_group(
|
||
&mut conn,
|
||
"apps1",
|
||
NewGroup {
|
||
id: "g1".into(),
|
||
title: "常用".into(),
|
||
},
|
||
)
|
||
.expect("应能建分组");
|
||
|
||
update_group(
|
||
&conn,
|
||
"g1",
|
||
UpdateGroup {
|
||
title: Some("改名".into()),
|
||
show_in_sidebar: Some(false),
|
||
},
|
||
)
|
||
.expect("应能改分组");
|
||
|
||
let (title, show): (String, i64) = conn
|
||
.query_row(
|
||
"SELECT title, show_in_sidebar FROM node WHERE key='g1'",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)
|
||
.expect("应能查");
|
||
assert_eq!(title, "改名");
|
||
assert_eq!(show, 0);
|
||
|
||
remove_group(&conn, "g1").expect("应能删分组");
|
||
let left: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| {
|
||
row.get(0)
|
||
})
|
||
.expect("应能查");
|
||
assert_eq!(left, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn groups_under_any_kind_of_section() {
|
||
let mut conn = db();
|
||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
||
create_group(
|
||
&mut conn,
|
||
"films",
|
||
NewGroup {
|
||
id: "g1".into(),
|
||
title: "片单".into(),
|
||
},
|
||
)
|
||
.expect("海报分区也应能建分组");
|
||
}
|
||
|
||
#[test]
|
||
fn switching_posters_to_apps_creates_group_and_moves_entries() { let mut conn = db();
|
||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
||
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
|
||
.expect("应能建条目");
|
||
conn.execute(
|
||
"INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)",
|
||
[],
|
||
)
|
||
.expect("应能建卡片");
|
||
|
||
update(&mut conn, "films", UpdateCategory { kind: Some("apps".into()), ..Default::default() })
|
||
.expect("应能改类型");
|
||
|
||
let (entry_node, block_node): (String, String) = conn
|
||
.query_row(
|
||
"SELECT (SELECT node_key FROM entry WHERE key='f1'),
|
||
(SELECT node_key FROM section_block)",
|
||
[],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)
|
||
.expect("应能查");
|
||
assert_eq!(entry_node, "films-all", "条目应挂到默认分组");
|
||
assert_eq!(block_node, "films-all", "首页卡片应指向默认分组");
|
||
let groups: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("应能查");
|
||
assert_eq!(groups, 1);
|
||
}
|
||
}
|