chore: source formatting and spec allignment

This commit is contained in:
2026-07-18 14:20:23 +02:00
parent a594b99e90
commit 16a210c0ad
119 changed files with 8868 additions and 5250 deletions

View File

@@ -25,7 +25,15 @@ fn read_project() {
.query_row(
"SELECT id, name, slug, data_path, is_active FROM projects WHERE id = ?1",
[PROJECT_ID],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.unwrap();
@@ -52,7 +60,10 @@ fn read_published_post_has_null_content() {
assert_eq!(title, "Esmeralda");
assert_eq!(slug, "esmeralda");
assert_eq!(status, "published");
assert!(content.is_none(), "published posts must have NULL content in DB");
assert!(
content.is_none(),
"published posts must have NULL content in DB"
);
}
#[test]
@@ -96,8 +107,14 @@ fn published_posts_have_file_paths() {
assert_eq!(rows.len(), 3);
for (slug, path) in &rows {
assert!(!path.is_empty(), "published post '{slug}' must have a file_path");
assert!(path.ends_with(&format!("{slug}.md")), "file_path must end with {slug}.md");
assert!(
!path.is_empty(),
"published post '{slug}' must have a file_path"
);
assert!(
path.ends_with(&format!("{slug}.md")),
"file_path must end with {slug}.md"
);
}
}
@@ -131,8 +148,14 @@ fn post_timestamps_are_unix_integers() {
.unwrap();
// Sanity: timestamps should be in reasonable Unix range (year 2000+)
assert!(created_at > 946_684_800, "created_at should be after year 2000");
assert!(updated_at > 946_684_800, "updated_at should be after year 2000");
assert!(
created_at > 946_684_800,
"created_at should be after year 2000"
);
assert!(
updated_at > 946_684_800,
"updated_at should be after year 2000"
);
}
#[test]
@@ -147,7 +170,10 @@ fn post_unique_constraint_on_project_slug() {
.map(|r| r.unwrap())
.collect();
assert!(dupes.is_empty(), "found duplicate (project_id, slug) pairs: {dupes:?}");
assert!(
dupes.is_empty(),
"found duplicate (project_id, slug) pairs: {dupes:?}"
);
}
// ── Post Translations ───────────────────────────────────────────────
@@ -156,7 +182,9 @@ fn post_unique_constraint_on_project_slug() {
fn read_post_translations() {
let conn = fixture_db();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM post_translations", [], |row| row.get(0))
.query_row("SELECT COUNT(*) FROM post_translations", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(count, 4);
}
@@ -177,7 +205,10 @@ fn translation_references_valid_post() {
.map(|r| r.unwrap())
.collect();
assert!(orphans.is_empty(), "orphan translations referencing missing posts: {orphans:?}");
assert!(
orphans.is_empty(),
"orphan translations referencing missing posts: {orphans:?}"
);
}
#[test]
@@ -193,7 +224,10 @@ fn published_translations_have_null_content() {
.collect();
for (id, content) in &rows {
assert!(content.is_none(), "published translation {id} must have NULL content");
assert!(
content.is_none(),
"published translation {id} must have NULL content"
);
}
}
@@ -293,7 +327,16 @@ fn tag_names_are_expected() {
.map(|r| r.unwrap())
.collect();
assert_eq!(names, vec!["fotografie", "mac-os-x", "natur", "programmierung", "sysadmin"]);
assert_eq!(
names,
vec![
"fotografie",
"mac-os-x",
"natur",
"programmierung",
"sysadmin"
]
);
}
#[test]
@@ -308,7 +351,10 @@ fn tag_unique_constraint_on_project_name() {
.map(|r| r.unwrap())
.collect();
assert!(dupes.is_empty(), "found duplicate (project_id, name) tag pairs: {dupes:?}");
assert!(
dupes.is_empty(),
"found duplicate (project_id, name) tag pairs: {dupes:?}"
);
}
// ── Templates ───────────────────────────────────────────────────────
@@ -345,7 +391,10 @@ fn read_template() {
assert_eq!(kind, "post");
assert!(enabled);
assert_eq!(status, "published");
assert!(content.is_none(), "published template content should be NULL in DB");
assert!(
content.is_none(),
"published template content should be NULL in DB"
);
}
// ── Scripts ─────────────────────────────────────────────────────────
@@ -420,7 +469,10 @@ fn settings_are_key_value_pairs() {
assert!(!value.is_empty());
}
// All setting keys should contain the project ID (namespaced)
let project_keys: Vec<_> = pairs.iter().filter(|(k, _)| k.contains(PROJECT_ID)).collect();
let project_keys: Vec<_> = pairs
.iter()
.filter(|(k, _)| k.contains(PROJECT_ID))
.collect();
assert_eq!(project_keys.len(), 5);
}
@@ -479,7 +531,10 @@ fn all_posts_belong_to_existing_project() {
.map(|r| r.unwrap())
.collect();
assert!(orphans.is_empty(), "posts referencing missing projects: {orphans:?}");
assert!(
orphans.is_empty(),
"posts referencing missing projects: {orphans:?}"
);
}
#[test]
@@ -498,7 +553,10 @@ fn all_tags_belong_to_existing_project() {
.map(|r| r.unwrap())
.collect();
assert!(orphans.is_empty(), "tags referencing missing projects: {orphans:?}");
assert!(
orphans.is_empty(),
"tags referencing missing projects: {orphans:?}"
);
}
#[test]
@@ -517,7 +575,10 @@ fn all_media_belong_to_existing_project() {
.map(|r| r.unwrap())
.collect();
assert!(orphans.is_empty(), "media referencing missing projects: {orphans:?}");
assert!(
orphans.is_empty(),
"media referencing missing projects: {orphans:?}"
);
}
#[test]
@@ -537,7 +598,10 @@ fn post_links_reference_valid_posts() {
.map(|r| r.unwrap())
.collect();
assert!(orphans.is_empty(), "post_links with invalid references: {orphans:?}");
assert!(
orphans.is_empty(),
"post_links with invalid references: {orphans:?}"
);
}
#[test]
@@ -557,5 +621,8 @@ fn post_media_references_valid_entities() {
.map(|r| r.unwrap())
.collect();
assert!(orphans.is_empty(), "post_media with invalid references: {orphans:?}");
assert!(
orphans.is_empty(),
"post_media with invalid references: {orphans:?}"
);
}

View File

@@ -4,23 +4,22 @@
//! - Step 20: Golden-file comparisons against fixture files
//! - Step 21: Metadata diff coverage matrix (every diffable field covered)
use bds_core::db::Database;
use bds_core::db::fts::ensure_fts_tables;
use bds_core::db::queries::project::insert_project;
use bds_core::db::queries::script as qs;
use bds_core::db::queries::template as qtpl;
use bds_core::db::Database;
use bds_core::engine::media;
use bds_core::engine::meta;
use bds_core::engine::metadata_diff;
use bds_core::engine::post;
use bds_core::engine::tag;
use bds_core::model::{
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind,
TemplateStatus,
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus,
};
use bds_core::util::frontmatter::{
read_post_file, read_template_file, read_translation_file,
write_script_file, write_template_file, ScriptFrontmatter, TemplateFrontmatter,
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_template_file,
read_translation_file, write_script_file, write_template_file,
};
use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar};
use image::DynamicImage;
@@ -325,10 +324,7 @@ fn test_golden_post_esmeralda() {
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
// Compare byte-for-byte with fixture
assert_eq!(
actual, expected,
"golden output mismatch for esmeralda.md"
);
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
}
#[test]
@@ -353,8 +349,7 @@ fn test_golden_translation_esmeralda_en() {
#[test]
fn test_golden_sidecar() {
let path =
fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
let expected = fs::read_to_string(&path).unwrap();
let sc = read_sidecar(&expected).unwrap();
@@ -401,7 +396,14 @@ fn test_golden_meta_files() {
assert_eq!(
categories,
vec![
"article", "aside", "kochbuch", "metaowl", "page", "picture", "spielelog", "wiki"
"article",
"aside",
"kochbuch",
"metaowl",
"page",
"picture",
"spielelog",
"wiki"
]
);
// Verify they are sorted
@@ -416,7 +418,7 @@ fn test_golden_meta_files() {
// Verify sorted by name
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
let mut sorted_names = names.clone();
sorted_names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
sorted_names.sort_by_key(|a| a.to_lowercase());
assert_eq!(names, sorted_names, "tags should be sorted by name");
// Spot-check a known tag
assert!(tags.iter().any(|t| t.name == "rust"));
@@ -457,9 +459,7 @@ fn test_golden_template() {
/// Helper: create a post, publish it, then modify a specific field in the
/// file on disk. Run the diff and return all detected field names.
fn post_diff_for_field(
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
fn post_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
let (db, dir) = setup();
let created = post::create_post(
@@ -519,9 +519,8 @@ fn test_diff_detects_post_slug() {
#[test]
fn test_diff_detects_post_status() {
let fields = post_diff_for_field(|content| {
content.replace("status: published", "status: draft")
});
let fields =
post_diff_for_field(|content| content.replace("status: published", "status: draft"));
assert!(
fields.contains(&"status".to_string()),
"expected 'status' in diff fields, got: {fields:?}"
@@ -530,9 +529,7 @@ fn test_diff_detects_post_status() {
#[test]
fn test_diff_detects_post_tags() {
let fields = post_diff_for_field(|content| {
content.replace(" - tag1", " - changed-tag")
});
let fields = post_diff_for_field(|content| content.replace(" - tag1", " - changed-tag"));
assert!(
fields.contains(&"tags".to_string()),
"expected 'tags' in diff fields, got: {fields:?}"
@@ -541,9 +538,7 @@ fn test_diff_detects_post_tags() {
#[test]
fn test_diff_detects_post_categories() {
let fields = post_diff_for_field(|content| {
content.replace(" - cat1", " - changed-cat")
});
let fields = post_diff_for_field(|content| content.replace(" - cat1", " - changed-cat"));
assert!(
fields.contains(&"categories".to_string()),
"expected 'categories' in diff fields, got: {fields:?}"
@@ -578,9 +573,7 @@ fn test_diff_detects_post_author() {
#[test]
fn test_diff_detects_post_language() {
let fields = post_diff_for_field(|content| {
content.replace("language: en", "language: de")
});
let fields = post_diff_for_field(|content| content.replace("language: en", "language: de"));
assert!(
fields.contains(&"language".to_string()),
"expected 'language' in diff fields, got: {fields:?}"
@@ -687,9 +680,7 @@ fn test_diff_detects_post_published_at() {
/// Helper: import media, then modify a field in the sidecar file.
/// Returns the list of diff field names detected.
fn media_diff_for_field(
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
fn media_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
let (db, dir) = setup();
let source_path = create_test_image(dir.path());
@@ -764,10 +755,7 @@ fn test_diff_detects_media_caption() {
#[test]
fn test_diff_detects_media_author() {
let fields = media_diff_for_field(|content| {
content.replace(
"author: \"Original Author\"",
"author: \"Changed Author\"",
)
content.replace("author: \"Original Author\"", "author: \"Changed Author\"")
});
assert!(
fields.contains(&"author".to_string()),
@@ -778,10 +766,7 @@ fn test_diff_detects_media_author() {
#[test]
fn test_diff_detects_media_tags() {
let fields = media_diff_for_field(|content| {
content.replace(
"tags: [\"original-tag\"]",
"tags: [\"changed-tag\"]",
)
content.replace("tags: [\"original-tag\"]", "tags: [\"changed-tag\"]")
});
assert!(
fields.contains(&"tags".to_string()),
@@ -791,9 +776,7 @@ fn test_diff_detects_media_tags() {
#[test]
fn test_diff_detects_media_language() {
let fields = media_diff_for_field(|content| {
content.replace("language: en", "language: de")
});
let fields = media_diff_for_field(|content| content.replace("language: en", "language: de"));
assert!(
fields.contains(&"language".to_string()),
"expected 'language' in media diff fields, got: {fields:?}"
@@ -804,9 +787,7 @@ fn test_diff_detects_media_language() {
/// Helper: insert a template in DB + write file, then modify a field in
/// the file. Returns the list of diff field names.
fn template_diff_for_field(
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
let (db, dir) = setup();
let tpl = Template {
@@ -870,9 +851,8 @@ fn test_diff_detects_template_title() {
#[test]
fn test_diff_detects_template_kind() {
let fields = template_diff_for_field(|content| {
content.replace("kind: \"post\"", "kind: \"list\"")
});
let fields =
template_diff_for_field(|content| content.replace("kind: \"post\"", "kind: \"list\""));
assert!(
fields.contains(&"kind".to_string()),
"expected 'kind' in template diff fields, got: {fields:?}"
@@ -881,9 +861,8 @@ fn test_diff_detects_template_kind() {
#[test]
fn test_diff_detects_template_enabled() {
let fields = template_diff_for_field(|content| {
content.replace("enabled: true", "enabled: false")
});
let fields =
template_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
assert!(
fields.contains(&"enabled".to_string()),
"expected 'enabled' in template diff fields, got: {fields:?}"
@@ -892,9 +871,7 @@ fn test_diff_detects_template_enabled() {
#[test]
fn test_diff_detects_template_version() {
let fields = template_diff_for_field(|content| {
content.replace("version: 1", "version: 99")
});
let fields = template_diff_for_field(|content| content.replace("version: 1", "version: 99"));
assert!(
fields.contains(&"version".to_string()),
"expected 'version' in template diff fields, got: {fields:?}"
@@ -905,9 +882,7 @@ fn test_diff_detects_template_version() {
/// Helper: insert a script in DB + write file, then modify a field in the
/// file. Returns the list of diff field names.
fn script_diff_for_field(
modify_fn: impl FnOnce(&str) -> String,
) -> Vec<String> {
fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
let (db, dir) = setup();
let script = Script {
@@ -995,9 +970,8 @@ fn test_diff_detects_script_entrypoint() {
#[test]
fn test_diff_detects_script_enabled() {
let fields = script_diff_for_field(|content| {
content.replace("enabled: true", "enabled: false")
});
let fields =
script_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
assert!(
fields.contains(&"enabled".to_string()),
"expected 'enabled' in script diff fields, got: {fields:?}"
@@ -1006,9 +980,7 @@ fn test_diff_detects_script_enabled() {
#[test]
fn test_diff_detects_script_version() {
let fields = script_diff_for_field(|content| {
content.replace("version: 1", "version: 42")
});
let fields = script_diff_for_field(|content| content.replace("version: 1", "version: 42"));
assert!(
fields.contains(&"version".to_string()),
"expected 'version' in script diff fields, got: {fields:?}"

View File

@@ -1,15 +1,18 @@
use std::collections::HashMap;
use bds_core::db::Database;
use bds_core::db::queries::project::insert_project;
use bds_core::db::queries::template::insert_template;
use bds_core::db::Database;
use bds_core::engine::generation::{
PublishedPostSource, apply_validation_sections, generate_starter_site,
sections_from_validation_report,
load_published_post_source, sections_from_validation_report,
};
use bds_core::engine::meta::write_category_meta_json;
use bds_core::engine::validate_site::validate_site;
use bds_core::model::{CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind, TemplateStatus};
use bds_core::model::{
CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind,
TemplateStatus,
};
use tempfile::TempDir;
fn make_project() -> Project {
@@ -95,6 +98,24 @@ fn setup() -> (Database, TempDir) {
(db, dir)
}
#[test]
fn reopened_draft_generation_uses_last_published_file() {
let (_db, dir) = setup();
let mut post = make_post("reopened", 1_710_000_000_000);
post.status = PostStatus::Draft;
post.content = Some("Unpublished draft body".into());
post.file_path = "posts/2024/03/reopened.md".into();
let frontmatter = bds_core::util::frontmatter::PostFrontmatter::from_post(&post).to_yaml();
let file = bds_core::util::frontmatter::format_frontmatter(&frontmatter, "Published body");
std::fs::create_dir_all(dir.path().join("posts/2024/03")).unwrap();
std::fs::write(dir.path().join(&post.file_path), file).unwrap();
let source = load_published_post_source(dir.path(), post)
.unwrap()
.unwrap();
assert_eq!(source.body_markdown, "Published body");
}
#[test]
fn generation_engine_writes_core_and_single_post_artifacts() {
let (db, dir) = setup();
@@ -110,7 +131,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
},
];
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let report =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(report.written_paths.contains(&"index.html".to_string()));
assert!(report.written_paths.contains(&"calendar.json".to_string()));
@@ -118,10 +140,26 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
assert!(report.written_paths.contains(&"feed.xml".to_string()));
assert!(report.written_paths.contains(&"atom.xml".to_string()));
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
assert!(report.written_paths.contains(&"assets/pico.min.css".to_string()));
assert!(report.written_paths.contains(&"assets/tag-cloud.js".to_string()));
assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string()));
assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string()));
assert!(
report
.written_paths
.contains(&"assets/pico.min.css".to_string())
);
assert!(
report
.written_paths
.contains(&"assets/tag-cloud.js".to_string())
);
assert!(
report
.written_paths
.contains(&"2024/03/09/hello/index.html".to_string())
);
assert!(
report
.written_paths
.contains(&"2024/03/10/next/index.html".to_string())
);
assert!(dir.path().join("index.html").exists());
assert!(dir.path().join("rss.xml").exists());
@@ -152,13 +190,17 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
body_markdown: "Hallo Welt".into(),
}];
let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap();
let report =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap();
assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
assert!(atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />") || atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />"));
assert!(
atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />")
|| atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />")
);
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
@@ -223,7 +265,8 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
},
];
let report = generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap();
let report =
generate_starter_site(db.conn(), dir.path(), "p1", &make_metadata(), &posts, "en").unwrap();
for asset in [
"images/close.png",
@@ -232,17 +275,29 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
"images/prev.png",
] {
assert!(report.written_paths.contains(&asset.to_string()));
assert!(dir.path().join(asset).exists(), "missing bundled image {asset}");
assert!(
dir.path().join(asset).exists(),
"missing bundled image {asset}"
);
}
assert!(!report.written_paths.contains(&"category/hidden/index.html".to_string()));
assert!(report.written_paths.contains(&"category/featured/index.html".to_string()));
assert!(
!report
.written_paths
.contains(&"category/hidden/index.html".to_string())
);
assert!(
report
.written_paths
.contains(&"category/featured/index.html".to_string())
);
let index_html = std::fs::read_to_string(dir.path().join("index.html")).unwrap();
assert!(!index_html.contains("Hidden Post"));
assert!(index_html.contains("[Featured Post|false]"));
let featured_html = std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
let featured_html =
std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
@@ -263,15 +318,21 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
body_markdown: "Hello **world**".into(),
}];
let first = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let second = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let first =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let second =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(!first.written_paths.is_empty());
assert!(second.skipped_paths.contains(&"index.html".to_string()));
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
assert!(second.skipped_paths.contains(&"assets/pico.min.css".to_string()));
assert!(
second
.skipped_paths
.contains(&"assets/pico.min.css".to_string())
);
}
#[test]
@@ -313,7 +374,9 @@ fn apply_validation_repairs_core_section_outputs() {
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sections = sections_from_validation_report(&report);
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections).unwrap();
let apply_report =
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
@@ -339,12 +402,22 @@ fn apply_validation_removes_extra_section_outputs() {
std::fs::write(extra_dir.join("index.html"), "ghost").unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.extra_pages.contains(&"tag/ghost/index.html".to_string()));
assert!(
report
.extra_pages
.contains(&"tag/ghost/index.html".to_string())
);
let sections = sections_from_validation_report(&report);
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections).unwrap();
let apply_report =
apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections)
.unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(apply_report.deleted_paths.contains(&"tag/ghost/index.html".to_string()));
assert!(
apply_report
.deleted_paths
.contains(&"tag/ghost/index.html".to_string())
);
assert!(repaired.extra_pages.is_empty());
}
@@ -368,4 +441,4 @@ fn site_validation_uses_html_output_directory_when_present() {
assert!(report.missing_pages.is_empty());
assert!(report.extra_pages.is_empty());
assert!(report.stale_pages.is_empty());
}
}

View File

@@ -1,12 +1,11 @@
use std::fs;
use bds_core::db::Database;
use bds_core::db::queries::generated_file_hash::get_generated_file_hash;
use bds_core::db::queries::project::insert_project;
use bds_core::db::Database;
use bds_core::model::{Post, PostStatus, Project};
use bds_core::render::{
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
write_generated_file,
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file,
};
use tempfile::TempDir;
@@ -72,7 +71,11 @@ fn generated_write_skips_unchanged_content() {
let stored = get_generated_file_hash(db.conn(), "p1", "index.html").unwrap();
assert_eq!(stored.relative_path, "index.html");
assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed"));
assert!(
fs::read_to_string(dir.path().join("index.html"))
.unwrap()
.contains("changed")
);
}
#[test]
@@ -85,7 +88,10 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() {
assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"hello"
);
}
#[test]
@@ -98,7 +104,10 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"hello"
);
}
#[test]
@@ -131,4 +140,4 @@ fn calendar_json_groups_posts_by_year_month_day() {
assert_eq!(parsed["months"]["2024-04"], 1);
assert_eq!(parsed["days"]["2024-03-09"], 2);
assert_eq!(parsed["days"]["2024-04-09"], 1);
}
}

View File

@@ -92,7 +92,7 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() {
}
#[test]
fn markdown_filter_marks_unsupported_macros_visibly() {
fn markdown_filter_leaves_unknown_macros_verbatim() {
let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";
let partials = HashMap::new();
let context = serde_json::json!({
@@ -104,9 +104,8 @@ fn markdown_filter_marks_unsupported_macros_visibly() {
});
let rendered = render_liquid_template(template, &partials, &context).unwrap();
assert!(rendered.contains("macro-unsupported"));
assert!(rendered.contains("Unsupported macro"));
assert!(rendered.contains("custom_block"));
assert!(rendered.contains("[[custom_block foo=bar]]"));
assert!(!rendered.contains("macro-unsupported"));
}
#[test]
@@ -123,11 +122,13 @@ fn starter_single_post_template_renders_with_partials() {
),
(
"partials/language-switcher".to_string(),
include_str!("../../../assets/starter-templates/partials/language-switcher.liquid").to_string(),
include_str!("../../../assets/starter-templates/partials/language-switcher.liquid")
.to_string(),
),
(
"partials/menu-items".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}".to_string(),
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
.to_string(),
),
]);
@@ -167,4 +168,4 @@ fn starter_single_post_template_renders_with_partials() {
assert!(rendered.contains("<strong>world</strong>"));
assert!(rendered.contains("href=\"/2024/03/09/hello\""));
assert!(rendered.contains("data-pagefind-body"));
}
}

View File

@@ -2,8 +2,8 @@ use std::collections::HashMap;
use bds_core::model::{Post, PostStatus, Tag, Template, TemplateKind, TemplateStatus};
use bds_core::render::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError,
render_markdown_to_html, resolve_post_template,
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, render_markdown_to_html,
resolve_post_template,
};
fn make_post() -> Post {
@@ -70,7 +70,11 @@ fn template_lookup_prefers_post_specific_template() {
post.tags = vec!["rust".into()];
post.categories = vec!["article".into()];
let templates = vec![make_template("post"), make_template("tag-template"), make_template("custom")];
let templates = vec![
make_template("post"),
make_template("tag-template"),
make_template("custom"),
];
let tags = vec![make_tag("rust", Some("tag-template"))];
let mut categories = HashMap::new();
categories.insert(
@@ -163,7 +167,10 @@ fn template_lookup_errors_when_explicit_template_missing() {
})
.unwrap_err();
assert_eq!(err, TemplateLookupError::MissingExplicitTemplate("missing".into()));
assert_eq!(
err,
TemplateLookupError::MissingExplicitTemplate("missing".into())
);
}
#[test]
@@ -171,4 +178,4 @@ fn markdown_render_produces_html() {
let html = render_markdown_to_html("# Hello\n\nA paragraph with **bold** text.");
assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("<strong>bold</strong>"));
}
}

View File

@@ -25,9 +25,18 @@ fn post_status_serde_matches_db_values() {
// spec: status: draft | published | archived
use bds_core::model::PostStatus;
assert_eq!(serde_json::to_string(&PostStatus::Draft).unwrap(), "\"draft\"");
assert_eq!(serde_json::to_string(&PostStatus::Published).unwrap(), "\"published\"");
assert_eq!(serde_json::to_string(&PostStatus::Archived).unwrap(), "\"archived\"");
assert_eq!(
serde_json::to_string(&PostStatus::Draft).unwrap(),
"\"draft\""
);
assert_eq!(
serde_json::to_string(&PostStatus::Published).unwrap(),
"\"published\""
);
assert_eq!(
serde_json::to_string(&PostStatus::Archived).unwrap(),
"\"archived\""
);
// round-trip
let d: PostStatus = serde_json::from_str("\"draft\"").unwrap();
@@ -44,10 +53,22 @@ fn post_status_serde_matches_db_values() {
fn template_kind_serde_matches_db_values() {
use bds_core::model::TemplateKind;
assert_eq!(serde_json::to_string(&TemplateKind::Post).unwrap(), "\"post\"");
assert_eq!(serde_json::to_string(&TemplateKind::List).unwrap(), "\"list\"");
assert_eq!(serde_json::to_string(&TemplateKind::NotFound).unwrap(), "\"not_found\"");
assert_eq!(serde_json::to_string(&TemplateKind::Partial).unwrap(), "\"partial\"");
assert_eq!(
serde_json::to_string(&TemplateKind::Post).unwrap(),
"\"post\""
);
assert_eq!(
serde_json::to_string(&TemplateKind::List).unwrap(),
"\"list\""
);
assert_eq!(
serde_json::to_string(&TemplateKind::NotFound).unwrap(),
"\"not_found\""
);
assert_eq!(
serde_json::to_string(&TemplateKind::Partial).unwrap(),
"\"partial\""
);
let nf: TemplateKind = serde_json::from_str("\"not_found\"").unwrap();
assert_eq!(nf, TemplateKind::NotFound);
@@ -59,8 +80,14 @@ fn template_kind_serde_matches_db_values() {
fn template_status_serde_matches_db_values() {
use bds_core::model::TemplateStatus;
assert_eq!(serde_json::to_string(&TemplateStatus::Draft).unwrap(), "\"draft\"");
assert_eq!(serde_json::to_string(&TemplateStatus::Published).unwrap(), "\"published\"");
assert_eq!(
serde_json::to_string(&TemplateStatus::Draft).unwrap(),
"\"draft\""
);
assert_eq!(
serde_json::to_string(&TemplateStatus::Published).unwrap(),
"\"published\""
);
}
// ── spec: schema.allium — Script kind: macro | utility | transform ──
@@ -69,9 +96,18 @@ fn template_status_serde_matches_db_values() {
fn script_kind_serde_matches_db_values() {
use bds_core::model::ScriptKind;
assert_eq!(serde_json::to_string(&ScriptKind::Macro).unwrap(), "\"macro\"");
assert_eq!(serde_json::to_string(&ScriptKind::Utility).unwrap(), "\"utility\"");
assert_eq!(serde_json::to_string(&ScriptKind::Transform).unwrap(), "\"transform\"");
assert_eq!(
serde_json::to_string(&ScriptKind::Macro).unwrap(),
"\"macro\""
);
assert_eq!(
serde_json::to_string(&ScriptKind::Utility).unwrap(),
"\"utility\""
);
assert_eq!(
serde_json::to_string(&ScriptKind::Transform).unwrap(),
"\"transform\""
);
let t: ScriptKind = serde_json::from_str("\"transform\"").unwrap();
assert_eq!(t, ScriptKind::Transform);
@@ -83,8 +119,14 @@ fn script_kind_serde_matches_db_values() {
fn script_status_serde_matches_db_values() {
use bds_core::model::ScriptStatus;
assert_eq!(serde_json::to_string(&ScriptStatus::Draft).unwrap(), "\"draft\"");
assert_eq!(serde_json::to_string(&ScriptStatus::Published).unwrap(), "\"published\"");
assert_eq!(
serde_json::to_string(&ScriptStatus::Draft).unwrap(),
"\"draft\""
);
assert_eq!(
serde_json::to_string(&ScriptStatus::Published).unwrap(),
"\"published\""
);
}
// ── spec: post.allium — content_location invariant ──
@@ -105,7 +147,10 @@ fn content_location_published_posts_null_content_in_fixture() {
assert!(!rows.is_empty());
for (slug, content) in &rows {
assert!(content.is_none(), "spec: published post '{slug}' must have NULL content in DB");
assert!(
content.is_none(),
"spec: published post '{slug}' must have NULL content in DB"
);
}
}
@@ -123,7 +168,10 @@ fn content_location_draft_posts_have_content_in_fixture() {
assert!(!rows.is_empty());
for (slug, content) in &rows {
assert!(content.is_some(), "spec: draft post '{slug}' must have content in DB");
assert!(
content.is_some(),
"spec: draft post '{slug}' must have content in DB"
);
}
}
@@ -138,7 +186,8 @@ fn post_status_transitions_all_valid() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[],
).unwrap();
)
.unwrap();
conn.execute(
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at) \
VALUES ('post1', 'p1', 'Test', 'test', 'draft', '', 1000, 1000)",
@@ -157,11 +206,15 @@ fn post_status_transitions_all_valid() {
for (from, to) in transitions {
// Set to 'from' state first
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [from]).unwrap();
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [from])
.unwrap();
// Transition to 'to' state
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [to]).unwrap();
conn.execute("UPDATE posts SET status = ?1 WHERE id = 'post1'", [to])
.unwrap();
let status: String = conn
.query_row("SELECT status FROM posts WHERE id = 'post1'", [], |r| r.get(0))
.query_row("SELECT status FROM posts WHERE id = 'post1'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(status, to, "transition {from} -> {to} failed");
}
@@ -177,7 +230,8 @@ fn slug_frozen_after_publish_semantics() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[],
).unwrap();
)
.unwrap();
conn.execute(
"INSERT INTO posts (id, project_id, title, slug, status, file_path, created_at, updated_at, published_at) \
VALUES ('post1', 'p1', 'Test', 'test', 'published', 'posts/2024/01/test.md', 1000, 1000, 1000)",
@@ -186,9 +240,16 @@ fn slug_frozen_after_publish_semantics() {
// published_at is set — slug should be considered frozen
let published_at: Option<i64> = conn
.query_row("SELECT published_at FROM posts WHERE id = 'post1'", [], |r| r.get(0))
.query_row(
"SELECT published_at FROM posts WHERE id = 'post1'",
[],
|r| r.get(0),
)
.unwrap();
assert!(published_at.is_some(), "spec: is_slug_frozen = published_at != null");
assert!(
published_at.is_some(),
"spec: is_slug_frozen = published_at != null"
);
// A never-published draft has no published_at — slug is mutable
conn.execute(
@@ -197,9 +258,16 @@ fn slug_frozen_after_publish_semantics() {
[],
).unwrap();
let draft_published_at: Option<i64> = conn
.query_row("SELECT published_at FROM posts WHERE id = 'post2'", [], |r| r.get(0))
.query_row(
"SELECT published_at FROM posts WHERE id = 'post2'",
[],
|r| r.get(0),
)
.unwrap();
assert!(draft_published_at.is_none(), "spec: unpublished draft has no published_at");
assert!(
draft_published_at.is_none(),
"spec: unpublished draft has no published_at"
);
}
// ── spec: project.allium — SingleActiveProject invariant ──
@@ -209,7 +277,11 @@ fn slug_frozen_after_publish_semantics() {
fn single_active_project_in_fixture() {
let conn = fixture_db();
let active_count: i64 = conn
.query_row("SELECT COUNT(*) FROM projects WHERE is_active = 1", [], |r| r.get(0))
.query_row(
"SELECT COUNT(*) FROM projects WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(active_count, 1, "spec: exactly one project is active");
}
@@ -247,7 +319,10 @@ fn unique_translation_per_post_language_in_fixture() {
.unwrap()
.map(|r| r.unwrap())
.collect();
assert!(dupes.is_empty(), "spec: translation unique per (post, language)");
assert!(
dupes.is_empty(),
"spec: translation unique per (post, language)"
);
}
// ── spec: schema.allium — Post defaults ──
@@ -260,13 +335,15 @@ fn post_defaults_match_spec() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[],
).unwrap();
)
.unwrap();
// Insert with minimal columns to test defaults
conn.execute(
"INSERT INTO posts (id, project_id, title, slug, created_at, updated_at) \
VALUES ('min1', 'p1', 'Minimal', 'minimal', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let (status, do_not_translate, file_path): (String, bool, String) = conn
.query_row(
@@ -291,12 +368,14 @@ fn script_defaults_match_spec() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[],
).unwrap();
)
.unwrap();
conn.execute(
"INSERT INTO scripts (id, project_id, slug, title, file_path, created_at, updated_at) \
VALUES ('s1', 'p1', 'test', 'Test', 'scripts/test.lua', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let (kind, entrypoint, enabled, version, status): (String, String, bool, i32, String) = conn
.query_row(
@@ -322,12 +401,14 @@ fn template_defaults_match_spec() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[],
).unwrap();
)
.unwrap();
conn.execute(
"INSERT INTO templates (id, project_id, slug, title, file_path, created_at, updated_at) \
VALUES ('t1', 'p1', 'test', 'Test', 'templates/test.liquid', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
let (kind, enabled, version, status): (String, bool, i32, String) = conn
.query_row(
@@ -351,12 +432,14 @@ fn tag_unique_name_per_project_enforced() {
"INSERT INTO projects (id, name, slug, created_at, updated_at, is_active) \
VALUES ('p1', 'test', 'test', 1000, 1000, 1)",
[],
).unwrap();
)
.unwrap();
conn.execute(
"INSERT INTO tags (id, project_id, name, created_at, updated_at) \
VALUES ('t1', 'p1', 'rust', 1000, 1000)",
[],
).unwrap();
)
.unwrap();
// Same name, same project — must fail
let result = conn.execute(
@@ -364,7 +447,10 @@ fn tag_unique_name_per_project_enforced() {
VALUES ('t2', 'p1', 'rust', 1000, 1000)",
[],
);
assert!(result.is_err(), "spec: duplicate tag name in same project must fail");
assert!(
result.is_err(),
"spec: duplicate tag name in same project must fail"
);
}
// ── spec: post.allium — Slug generation algorithm ──
@@ -447,16 +533,37 @@ fn published_post_file_paths_follow_date_layout() {
#[test]
fn notification_entity_serde_matches_db_values() {
use bds_core::model::{NotificationEntity, NotificationAction};
use bds_core::model::{NotificationAction, NotificationEntity};
assert_eq!(serde_json::to_string(&NotificationEntity::Post).unwrap(), "\"post\"");
assert_eq!(serde_json::to_string(&NotificationEntity::Media).unwrap(), "\"media\"");
assert_eq!(serde_json::to_string(&NotificationEntity::Script).unwrap(), "\"script\"");
assert_eq!(serde_json::to_string(&NotificationEntity::Template).unwrap(), "\"template\"");
assert_eq!(
serde_json::to_string(&NotificationEntity::Post).unwrap(),
"\"post\""
);
assert_eq!(
serde_json::to_string(&NotificationEntity::Media).unwrap(),
"\"media\""
);
assert_eq!(
serde_json::to_string(&NotificationEntity::Script).unwrap(),
"\"script\""
);
assert_eq!(
serde_json::to_string(&NotificationEntity::Template).unwrap(),
"\"template\""
);
assert_eq!(serde_json::to_string(&NotificationAction::Created).unwrap(), "\"created\"");
assert_eq!(serde_json::to_string(&NotificationAction::Updated).unwrap(), "\"updated\"");
assert_eq!(serde_json::to_string(&NotificationAction::Deleted).unwrap(), "\"deleted\"");
assert_eq!(
serde_json::to_string(&NotificationAction::Created).unwrap(),
"\"created\""
);
assert_eq!(
serde_json::to_string(&NotificationAction::Updated).unwrap(),
"\"updated\""
);
assert_eq!(
serde_json::to_string(&NotificationAction::Deleted).unwrap(),
"\"deleted\""
);
}
// ── spec: generation.allium / publishing.allium — SshMode values ──
@@ -493,7 +600,11 @@ fn fts5_tables_exist_in_fixture() {
[],
|r| r.get::<_, i64>(0),
);
assert_eq!(result.unwrap(), 1, "spec: posts_fts virtual table must exist");
assert_eq!(
result.unwrap(),
1,
"spec: posts_fts virtual table must exist"
);
// media_fts
let result = conn.query_row(
@@ -501,5 +612,9 @@ fn fts5_tables_exist_in_fixture() {
[],
|r| r.get::<_, i64>(0),
);
assert_eq!(result.unwrap(), 1, "spec: media_fts virtual table must exist");
assert_eq!(
result.unwrap(),
1,
"spec: media_fts virtual table must exist"
);
}