feat: first take at M2

This commit is contained in:
2026-04-04 18:04:39 +02:00
parent b532104032
commit eedd0a9118
33 changed files with 2899 additions and 78 deletions

View File

@@ -1,6 +1,6 @@
//! App launch smoke tests.
//!
//! M0 validation: verifies that the core UI types can be constructed
//! Validates that the core UI types can be constructed
//! and that the message routing works at the type level.
//!
//! NOTE: muda::Menu on macOS requires the actual main thread (not just
@@ -8,7 +8,11 @@
//! be tested via `cargo test`. The full smoke test is launching the binary:
//! cargo run -p bds-ui
use std::path::PathBuf;
use bds_core::i18n::UiLocale;
use bds_ui::app::Message;
use bds_ui::state::navigation::{PanelTab, SidebarView};
use bds_ui::state::tabs::{Tab, TabType};
// ── Smoke: Message enum is well-formed ──
@@ -16,7 +20,6 @@ use bds_ui::app::Message;
fn message_variants_constructable() {
let _noop = Message::Noop;
let _menu = Message::MenuEvent(muda::MenuId::new("test"));
// Verify Debug trait works
assert!(format!("{:?}", Message::Noop).contains("Noop"));
}
@@ -27,12 +30,62 @@ fn message_clone_works() {
assert!(format!("{cloned:?}").contains("MenuEvent"));
}
#[test]
fn new_message_variants_constructable() {
// Navigation
let _view = Message::SetActiveView(SidebarView::Posts);
let _toggle_sb = Message::ToggleSidebar;
let _toggle_p = Message::TogglePanel;
// Tabs
let tab = Tab {
id: "test".to_string(),
tab_type: TabType::Post,
title: "Test".to_string(),
is_transient: false,
};
let _open = Message::OpenTab(tab);
let _close = Message::CloseTab("test".into());
let _select = Message::SelectTab("test".into());
let _pin = Message::PinTab("test".into());
// Project
let _switch = Message::SwitchProject("id".into());
let _create = Message::CreateProject { name: "X".into(), data_path: None };
let _delete = Message::DeleteProject("id".into());
// Dialogs
let _folder = Message::FolderPicked(Some(PathBuf::from("/tmp")));
let _media = Message::MediaFilesPicked(None);
// Tasks
let _tick = Message::TaskTick;
// macOS lifecycle
let _file = Message::FileOpenRequested(PathBuf::from("/test"));
let _url = Message::UrlOpenRequested("bds://open".into());
// Panel
let _panel = Message::SetPanelTab(PanelTab::Output);
// Settings
let _offline = Message::SetOfflineMode(true);
let _locale = Message::SetUiLocale(UiLocale::De);
// Blog actions
let _rebuild = Message::RebuildDatabase;
let _reindex = Message::ReindexText;
let _diff = Message::RunMetadataDiff;
let _finished = Message::BlogTaskFinished {
label: "test".into(),
result: Ok(()),
};
}
// ── Smoke: BdsApp type is accessible from integration tests ──
#[test]
fn bds_app_type_is_public() {
// This test verifies the public API surface exists.
// BdsApp, Message, platform::menu are all reachable.
fn _assert_types() {
let _: fn() -> (bds_ui::BdsApp, iced::Task<Message>) = bds_ui::BdsApp::new;
}

View File

@@ -0,0 +1,44 @@
//! Menu routing integration tests.
//!
//! Validates: all MenuAction variants registered, no collisions,
//! bidirectional lookup, and i18n keys resolve in every locale.
use bds_core::i18n::{translate, UiLocale};
use bds_ui::platform::menu::MenuAction;
#[test]
fn all_menu_actions_have_i18n_keys() {
for &action in MenuAction::ALL {
let key = action.i18n_key();
assert!(key.starts_with("menu.item."), "bad key prefix: {key}");
}
}
#[test]
fn all_menu_actions_translate_in_all_locales() {
for locale in UiLocale::all() {
for &action in MenuAction::ALL {
let key = action.i18n_key();
let label = translate(*locale, key);
assert_ne!(
label, key,
"missing translation for {key} in locale {locale}"
);
}
}
}
#[test]
fn menu_action_count_matches_spec() {
// M2 spec: 28 custom menu actions
assert_eq!(MenuAction::ALL.len(), 28);
}
#[test]
fn no_duplicate_i18n_keys() {
let mut keys = std::collections::HashSet::new();
for &action in MenuAction::ALL {
let key = action.i18n_key();
assert!(keys.insert(key), "duplicate i18n key: {key}");
}
}

View File

@@ -0,0 +1,124 @@
//! Project flow integration tests.
//!
//! Tests the project lifecycle: create, list, set active, switch, delete.
//! Uses in-memory DB — no Iced window required.
use bds_core::db::Database;
use bds_core::engine::project;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
#[test]
fn create_and_activate_default_project() {
let (db, dir) = setup();
let data_path = dir.path().join("my-blog");
let p = project::create_project(
db.conn(),
"My Blog",
Some(data_path.to_str().unwrap()),
)
.unwrap();
assert_eq!(p.name, "My Blog");
assert_eq!(p.slug, "my-blog");
assert!(!p.is_active);
// Activate it
project::set_active_project(db.conn(), &p.id).unwrap();
let active = project::get_active_project(db.conn()).unwrap().unwrap();
assert_eq!(active.id, p.id);
}
#[test]
fn switch_between_projects() {
let (db, dir) = setup();
let p1_path = dir.path().join("blog-a");
let p2_path = dir.path().join("blog-b");
let p1 = project::create_project(db.conn(), "Blog A", Some(p1_path.to_str().unwrap())).unwrap();
let p2 = project::create_project(db.conn(), "Blog B", Some(p2_path.to_str().unwrap())).unwrap();
project::set_active_project(db.conn(), &p1.id).unwrap();
let active = project::get_active_project(db.conn()).unwrap().unwrap();
assert_eq!(active.id, p1.id);
// Switch
project::set_active_project(db.conn(), &p2.id).unwrap();
let active = project::get_active_project(db.conn()).unwrap().unwrap();
assert_eq!(active.id, p2.id);
}
#[test]
fn delete_inactive_project() {
let (db, dir) = setup();
let p1_path = dir.path().join("keep");
let p2_path = dir.path().join("remove");
let p1 = project::create_project(db.conn(), "Keep", Some(p1_path.to_str().unwrap())).unwrap();
let p2 = project::create_project(db.conn(), "Remove", Some(p2_path.to_str().unwrap())).unwrap();
project::set_active_project(db.conn(), &p1.id).unwrap();
project::delete_project(db.conn(), &p2.id, Some(&p2_path)).unwrap();
let list = project::list_projects(db.conn()).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "Keep");
}
#[test]
fn cannot_delete_active_project() {
let (db, dir) = setup();
let p_path = dir.path().join("active");
let p = project::create_project(db.conn(), "Active", Some(p_path.to_str().unwrap())).unwrap();
project::set_active_project(db.conn(), &p.id).unwrap();
let result = project::delete_project(db.conn(), &p.id, None);
assert!(result.is_err());
}
#[test]
fn project_directory_structure_created() {
let (db, dir) = setup();
let p_path = dir.path().join("structured");
project::create_project(db.conn(), "Structured", Some(p_path.to_str().unwrap())).unwrap();
assert!(p_path.join("posts").is_dir());
assert!(p_path.join("media").is_dir());
assert!(p_path.join("meta").is_dir());
assert!(p_path.join("thumbnails").is_dir());
assert!(p_path.join("templates").is_dir());
assert!(p_path.join("scripts").is_dir());
}
#[test]
fn default_meta_files_written() {
let (db, dir) = setup();
let p_path = dir.path().join("meta-test");
project::create_project(db.conn(), "Meta Test", Some(p_path.to_str().unwrap())).unwrap();
let project_json: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(p_path.join("meta/project.json")).unwrap(),
)
.unwrap();
assert_eq!(project_json["name"], "Meta Test");
assert_eq!(project_json["maxPostsPerPage"], 50);
let categories: Vec<String> = serde_json::from_str(
&std::fs::read_to_string(p_path.join("meta/categories.json")).unwrap(),
)
.unwrap();
assert_eq!(categories, vec!["article", "aside", "page", "picture"]);
let tags: Vec<String> = serde_json::from_str(
&std::fs::read_to_string(p_path.join("meta/tags.json")).unwrap(),
)
.unwrap();
assert!(tags.is_empty());
}