Compare commits

...

16 Commits

Author SHA1 Message Date
2ba8be2fc6 Resolve D4-3: add WelcomeScreen/CSP/chart-surface tests for editor_chat.allium 2026-05-30 20:02:36 +02:00
4731bc0cd2 D4-2: add 56 UI tests for editor_settings (MCP agents, style/theme, search filter, categories CRUD) 2026-05-30 19:52:23 +02:00
8bc371eb3f Fix D4-1: add standalone delete_media_translation tests and MediaDetectLanguage rule integration test 2026-05-30 19:40:49 +02:00
b65c2be29b D3: close out partial test coverage gaps with new tests + execute_macro degrade-to-empty fix 2026-05-30 19:33:52 +02:00
ee4d0dd33f D2-10/D2-12/D2-15/D2-16: close out remaining D2 spec gaps with tests + validate_media implementation 2026-05-30 19:25:20 +02:00
8c71ece887 D2-9: add max_posts_per_page 1..500 constraint tests 2026-05-30 19:17:09 +02:00
b1438d5222 Fix D2-8: add width/height assertions to ConditionalMediaFields nil-fields-absent test 2026-05-30 19:15:50 +02:00
87f2f22241 fill D2-7: test nil excerpt/author/language absent from frontmatter 2026-05-30 19:14:06 +02:00
60acda3fee fix: add SidecarRoundtrip tests for D2-6 2026-05-30 19:10:40 +02:00
ab6a03dc54 D2-5: add FrontmatterRoundtrip test 2026-05-30 19:08:09 +02:00
0afb017e43 Implement create_and_publish_script/1 and add tests for D2-3/D2-4 spec gaps 2026-05-30 19:02:20 +02:00
cf553e2f78 Add create_and_publish_template/1 (D2-2 spec gap) 2026-05-30 14:51:44 +02:00
cb658aba1a clean up unused alias warning in CSM013 test 2026-05-30 14:47:33 +02:00
544ff65e3b D2-1: add RemoveCategory tests covering state/files/DB cleanup and no-op for non-existent 2026-05-30 14:44:20 +02:00
08eb9e4ea1 B2-1..B2-9: distill minor code behaviors into specs (post/project/template/media_processing/generation/dashboard) 2026-05-30 14:39:11 +02:00
723a7ec1f7 B1-5..B1-20: distill remaining code behaviors into specs (rendering.allium, post/media/task/generation/editor specs) 2026-05-30 14:33:19 +02:00
36 changed files with 2262 additions and 80 deletions

View File

@@ -64,36 +64,36 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| ~~B1-2~~ | ~~Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill)~~ | `lib/bds/posts/auto_translation.ex` | **Resolved:** distilled into translation.allium — added `AutoTranslationControlSurface` (PostSavedForAutoTranslation reactive + FillMissingTranslationsRequested batch triggers), three rules (`ScheduleAutoTranslation` draft-per-missing-language + media cascade, `AutoTranslatePost` upsert/auto-publish primitive, `AutoTranslateMediaCascade` linked-media per-language tasks, `FillMissingTranslations` published-only batch emitting `ProgressReported` + `FillMissingTranslationsCompleted`), three invariants (`AutoTranslationGatedByEndpoint`, `AutoTranslationSkipsDoNotTranslate`, `AutoTranslationOnlyMissingLanguages`), and `auto_translation_task_group_name` config; `allium check` passes |
| ~~B1-3~~ | ~~3 extra settings sections (Technology, MCP, Data Maintenance)~~ | `lib/bds/desktop/shell_live/settings_editor/` | **Resolved:** distilled into editor_settings.allium — added `SettingsTechnologySection` (semantic_similarity toggle + read-only scripting-runtime note, saved with project metadata), `SettingsDataSection` (rebuild_targets), and `technology_section`/`data_section` on `SettingsView`; reconciled `SettingsMCPSection`/`MCPAgentRow` to code (dropped non-existent status badge; added `is_supported`/`config_path`; only Claude Code + GitHub Copilot supported); updated TechnologySection/MCPSection/DataMaintenanceSection guarantees (7 rebuild buttons incl. Rebuild Embedding Index) and SettingsRebuild rule entity_type (+embedding); `allium check` passes |
| ~~B1-4~~ | ~~Style/Theme as separate tab (`:style`), not settings section~~ | `lib/bds/desktop/shell_live/settings_editor/style_editor.ex` | **Resolved:** editor_settings.allium Style view section now frames it as its own `style` singleton tab (cross-ref tabs.allium), explicitly NOT a SettingsView collapsible section; added `SeparateTab` guarantee (requires active project), documented 20 named Pico themes and the theme display-name transform ("-"→" ", capitalise); `allium check` passes |
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
| ~~B1-5~~ | ~~`published_*` snapshot fields on Post for diffing~~ | `lib/bds/posts/post.ex:61-65` | **Resolved:** added published_title/content/tags/categories/excerpt snapshot fields to the Post entity in post.allium, noting their role in changes_affect_published_content / ReopenPublishedPost (schema.allium already listed them) |
| ~~B1-6~~ | ~~Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering)~~ | `lib/bds/rendering/` | **Resolved:** new `rendering.allium` distills the shared render subsystem — 3 custom filters (i18n/markdown/slugify), markdown→HTML + URL rewriting, built-in `[[...]]` macros (youtube/vimeo/gallery/photo_archive/tag_cloud) with isolation invariant, links/languages (LinkContext, language prefix, backlinks), RenderLabels (content-language gettext + months), and post/list/not-found assign builders; added `SharedRenderPathForPreviewAndGeneration` invariant and wired into bds.allium |
| ~~B1-7~~ | ~~404.html generation~~ | `lib/bds/generation/outputs.ex:344-345` | **Resolved:** added `FileGenerated("404.html")` (+ `{lang}/404.html` per blog language) to GenerateCoreSectionPages in generation.allium |
| B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) |
| B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) |
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
| ~~B1-10~~ | ~~Media translation editing modal~~ | `media_editor.html.heex:275-303` | **Resolved:** editor_media.allium corrected — MediaTranslationEdit opens an "Edit Translation" modal (not inline); added `TranslationEditModal` guarantee (language hidden field + Title/Alt/Caption inputs, Cancel/Save footer) |
| ~~B1-11~~ | ~~Menu editor drag-drop, indent/unindent/move~~ | `lib/bds/desktop/menu_editor/tree_ops.ex` | **Resolved:** already covered in editor_misc.allium — MenuMoveItem rule (up/down/indent/unindent), DragDrop + MoveDirections guarantees, HomeItemProtection (D1-18); verified against tree_ops.ex |
| ~~B1-12~~ | ~~`:language_picker` overlay with flag emojis~~ | `shell_overlay.html.heex:116-139` | **Resolved:** already in modals.allium — LanguagePickerModal/LanguageTarget (flag emoji, name, existing-translation status badge); verified matches overlay code |
| ~~B1-13~~ | ~~`:confirm_dialog` generic confirmation~~ | `shell_overlay.html.heex:171-187` | **Resolved:** already in modals.allium — ConfirmDialog (title/message, Cancel/Confirm); verified matches overlay code |
| ~~B1-14~~ | ~~Publish actions for scripts and templates~~ | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | **Resolved:** added `can_publish` field, Publish button in HeaderLayout (shown only when draft), ScriptPublishRequested/TemplatePublishRequested surface events and ScriptPublish/TemplatePublish rules (validate-then-publish) to editor_script.allium + editor_template.allium |
| ~~B1-15~~ | ~~`:import` as full editor tab~~ | `lib/bds/ui/import_editor.ex` | **Resolved:** already in tabs.allium (import singleton/pinned tab + editor route) and fully detailed as ImportAnalysisView in editor_misc.allium |
| ~~B1-16~~ | ~~`:documentation`/`:api_documentation` tab types~~ | `lib/bds/desktop/misc_editor/` | **Resolved:** already in tabs.allium (both listed as singleton tabs + editor views: DOCUMENTATION.md / API.md) with DocumentationSurface in editor_misc.allium |
| ~~B1-17~~ | ~~Metadata diff covers embedding, media_translation, post_translation as entity types~~ | `lib/bds/maintenance/repair.ex` | **Resolved:** metadata_diff.allium DiffReport entity_type list expanded to all 7 types; RunMetadataDiff notes media/media_translation/script/template/embedding (content_hash) coverage; added `RepairMetadataDiffItem` rule + surface event documenting the file_to_db/db_to_file dispatch per entity type |
| ~~B1-18~~ | ~~Finished task TTL eviction (1h, keep last 10)~~ | `lib/bds/tasks.ex:365-386` | **Resolved:** added `finished_task_ttl` (1h) + `recent_finished_limit` (10) config, `FinishedTaskRetention` invariant, `EvictFinishedTasks` rule and `FinishedTaskEvictionDue` runtime event to task.allium |
| ~~B1-19~~ | ~~`discard_post_changes/1`~~ | `lib/bds/posts.ex:201-227` | **Resolved:** added `DiscardPostChangesRequested` surface event + `DiscardPostChanges` rule (requires file_path, restores published file, content=null/status=published, re-syncs links+FTS) to post.allium |
| ~~B1-20~~ | ~~`replace_media_file/2` with checksum/backup~~ | `lib/bds/media.ex:288-337` | **Resolved:** added `ReplaceMediaFileRequested` surface event + `ReplaceMediaFile` rule (md5 no-op skip, .bak backup/restore, updates checksum/size/dimensions, synchronous thumbnail regen, FTS re-sync) to media.allium |
### B2. Lower Priority (implementation detail or minor)
| ID | Behavior | Code Location |
|---|---|---|
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
| ID | Behavior | Code Location | Resolution |
|---|---|---|---|
| ~~B2-1~~ | ~~`editor_body/1` content resolver~~ | `lib/bds/posts.ex:234-256` | **Resolved:** added `editor_body` derived field to the Post entity in post.allium (prefer DB draft content, else read markdown body from file, else empty; same for translations) |
| ~~B2-2~~ | ~~`sync_post_from_file/1` single-post reimport~~ | `lib/bds/posts.ex:259` | **Resolved:** added `SyncPostFromFileRequested` surface event + `SyncPostFromFile` rule (re-read own .md file, upsert DB, re-sync links) to post.allium |
| ~~B2-3~~ | ~~`import_orphan_post_file/1`~~ | `lib/bds/posts.ex:293` | **Resolved:** added `ImportOrphanPostFileRequested` surface event + `ImportOrphanPostFile` rule (import a disk .md with no DB row, reject non-markdown) to post.allium |
| ~~B2-4~~ | ~~`dashboard_stats/1`, `post_counts_by_year_month/1`~~ | `lib/bds/posts.ex:416-450` | **Resolved:** added `ComputeDashboardData` rule to editor_misc.allium (status-grouped counts, (year,month) timeline newest-first limited to recent months, plus clouds/recent) |
| ~~B2-5~~ | ~~`regenerate_missing_thumbnails/2`~~ | `lib/bds/media/thumbnails.ex:51` | **Resolved:** added `RegenerateMissingThumbnailsRequested` surface event + `RegenerateMissingThumbnails` rule (raster images excl. SVG, regenerate only missing files, background task w/ counts) to media_processing.allium |
| ~~B2-6~~ | ~~Cache dir computation~~ | `lib/bds/projects.ex:142-147` | **Resolved:** added `cache_dir` derived field (`private_dir/projects/{id}`, :project_cache_root override) to the Project entity in project.allium |
| ~~B2-7~~ | ~~`remove_stale_published_templates`~~ | `lib/bds/templates.ex:561` | **Resolved:** extended RebuildTemplatesFromFiles in template.allium to prune published templates whose file is neither scanned nor on disk (clearing references first) |
| ~~B2-8~~ | ~~Rendering Labels module (30+ i18n strings)~~ | `lib/bds/rendering/labels.ex` | **Resolved:** captured as `RenderLabels` value + `LabelsUseContentLanguage` invariant in rendering.allium (with B1-6) — content-language gettext strings + month names |
| ~~B2-9~~ | ~~Progress reporting during reindex~~ | `lib/bds/generation/progress.ex` | **Resolved:** added `GenerationProgressReported` runtime event + `ProgressReporting` guarantee to generation.allium (count-based + phased fractions, via task progress channel) |
---
@@ -138,47 +138,47 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Claim | Spec | Path |
|---|---|---|---|
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post published |
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
| ~~D2-1~~ | ~~RemoveCategory rule~~ | ~~metadata.allium:100~~ | **Resolved:** 2 tests added in metadata_test.exs — remove_category removes category+settings from state/files/DB (6 assertions); remove_category is a no-op for non-existent category |
| ~~D2-2~~ | ~~CreateAndPublishTemplate rule~~ | template.allium:105 | **Resolved:** added `create_and_publish_template/1` (validates Liquid, creates published template + writs file), 3 tests added (happy path file assertions, invalid liquid rejection, slug dedup on title conflict) |
| ~~D2-3~~ | ~~CreateAndPublishScript rule~~ | ~~script.allium:160~~ | **Resolved:** `create_and_publish_script/1` implemented in scripts.ex, 4 tests added (happy path with file assertions + macro entrypoint default + invalid Lua rejection + slug dedup on title conflict) |
| ~~D2-4~~ | ~~UniqueScriptSlug dedup~~ | ~~script.allium:115~~ | **Resolved:** test added asserting two scripts with same title produce unique slugs (`dup-slug``dup-slug-2`) |
| D2-5 | ~~FrontmatterRoundtrip invariant~~ | post.allium:223 | **Resolved:** test added — creates post with all fields, publishes, parses file back via `Frontmatter.parse_document`, asserts every field (id, title, slug, excerpt, status, author, language, doNotTranslate, templateSlug, tags, categories, createdAt, updatedAt, publishedAt, body) matches the published DB record |
| ~~D2-6~~ | ~~SidecarRoundtrip invariant~~ | ~~media.allium:198~~ | **Resolved:** 2 tests added — full roundtrip (write sidecar, parse via `Sidecar.parse_document/1`, assert all fields match DB) + nil conditional fields absent from parsed sidecar |
| ~~D2-7~~ | ~~ConditionalPostFields: nil fields absent from frontmatter~~ | frontmatter.allium:398 | **Resolved:** test added — post with nil excerpt/author/language → those fields absent from published file, 3 refute assertions |
| ~~D2-8~~ | ~~ConditionalMediaFields: nil fields absent from sidecar~~ | frontmatter.allium:417 | **Resolved:** added width/height assertions to nil-fields-absent test |
| D2-9 | ~~max_posts_per_page 1..500 constraint~~ | metadata.allium:75-77 | **Resolved:** 5 tests added (0→1, -5→1, 1000→500, nil→50, non-numeric→50) |
| D2-10 | ~~SandboxedExecution: restricted capabilities blocked~~ | script.allium:84-88 | **Resolved:** 6 tests added (os.execute, os.rename, io.open for write, require, dofile, loadlib all blocked) |
| D2-11 | ~~TransformToastBudget enforcement~~ | script.allium:251-258 | **Resolved:** 2 tests already existed (per-script cap + total budget) |
| D2-12 | ~~ProgressThrottled: 250ms throttle~~ | task.allium:110-113 | **Resolved:** 2 tests added (rapid reports within 250ms dropped; value 1.0 bypasses throttle) |
| D2-13 | ~~archived→draft transition~~ | post.allium:121 | **Resolved:** 4 tests already existed (unarchive→draft, restores content from disk, rejects non-archived, not_found) |
| D2-14 | ~~archived→published transition~~ | post.allium:122 | **Resolved:** test already existed (publish_post republishes archived posts without losing body or published_at) |
| D2-15 | ~~AppNoopNotifier: app writes don't produce notification rows~~ | cli_sync.allium:64-68 | **Resolved:** test added (post create, media import, metadata update do not produce notification rows) |
| D2-16 | ~~ValidateMedia rule~~ | media_processing.allium:318-343 | **Resolved:** `validate_media/1` implemented in media.ex, 5 tests added (healthy, missing_binary, missing_sidecar, orphan, linked_not_orphan) |
| D2-17 | ~~ContentHashSkipsUnchanged during reindex~~ | embedding.allium:199-202 | **Resolved:** 2 tests already existed (sync_post skips when hash matches; index_unindexed skips unchanged) |
### D3. Partial Test Coverage (needs expansion)
| ID | Claim | Spec | Gap | Path |
|---|---|---|---|---|
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
| D3-1 | ~~PublishPost: content=null after publish~~ | post.allium:186 | **Resolved:** `refute published.content` added to frontmatter roundtrip test |
| D3-2 | ~~PublishPost: old file deleted on path change~~ | engine_side_effects.allium:73-74 | **Resolved:** test already existed (`publish_post deletes old file when file path changes` at posts_test.exs:284) |
| D3-3 | ~~UpsertPostTranslation: do_not_translate guard~~ | translation.allium:113 | **Resolved:** direct test added (do_not_translate guard rejects translation upsert with changeset error) |
| D3-4 | ~~PublishTemplate: Liquid validation prerequisite~~ | template.allium:139 | **Resolved:** tests already existed (publish_template rejects invalid Liquid syntax, create_and_publish rejects invalid Liquid) |
| D3-5 | ~~PublishScript: validation prerequisite~~ | script.allium:181 | **Resolved:** tests already existed (publish_script rejects invalid Lua syntax, create_and_publish rejects invalid Lua) |
| D3-6 | ~~ExecuteMacro failure degrades to empty~~ | script.allium:199 | **Resolved:** `execute_macro` now returns `{:ok, ""}` on failure (was `{:error, reason}`), test updated, spec already correct |
| D3-7 | ~~TemplateFrontmatter roundtrip~~ | template.allium:53 | **Resolved:** roundtrip test added (publish then parse back, compare all fields against DB record) |
| D3-8 | ~~DefaultCategories for fresh project~~ | metadata.allium:60 | **Resolved:** test added (fresh project has article/aside/page/picture categories before any operations) |
| D3-9 | ~~FtsIncludesTranslations~~ | translation.allium:178 | **Resolved:** test expanded (de/fr/es/it search terms all find the canonical post after reindex) |
| D3-10 | ~~PostCanonicalUrl format~~ | post.allium:33-40 | **Resolved:** format test added (`/YYYY/MM/DD/slug/` via `LinksAndLanguages.post_path`) |
| D3-11 | ~~Slug generation: German transliteration~~ | post.allium:14-22 | **Resolved:** 5 tests added (ß→ss, ö→o, ä→a, ü→u, mixed ÄÖÜäöüß→aouaouss) |
### D4. UI Test Coverage Gaps (whole-editor specs)
| ID | Spec | Covered | Not Covered |
|---|---|---|---|
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
| ~~D4-1~~ | ~~editor_media.allium~~ | ~~AI analysis, delete~~ | ~~Translate, replace file, link-to-post, translation CRUD, detect language~~ | **Resolved:** backend tests cover replace_file, link-to-post, translation CRUD (upsert + unique constraint); added standalone `delete_media_translation/2` test (row + sidecar deletion, no-op for non-existent, not-found for unknown media); added `MediaDetectLanguage` rule integration test (AI mock, language persisted, sidecar rewritten) |
| ~~D4-2~~ | ~~editor_settings.allium~~ | ~~AI endpoints, airplane toggle, rebuild~~ | ~~Protected categories~~ (resolved D1-17), ~~MCP agents~~ (6 `mcp_rows` + 3 `toggle_mcp_agent` tests), ~~style/theme~~ (19 `build_style` + 4 select/change/apply/display tests), ~~search filter~~ (9 `build_settings` visibility tests), ~~categories CRUD~~ (7 `category_rows` + 2 update + 3 add + 2 save + 4 reset tests) | **Resolved:** 3 new test files (mcp_config_test.exs, style_editor_test.exs, settings_search_test.exs) + expanded managed_categories_test.exs cover all untested areas. Total 56 tests added across MCP agents, style/theme, search filter, and categories CRUD. |
| D4-3 | ~~editor_chat.allium~~ | ~~Chat creation, pinned tab~~ | ~~API key screen, message rendering, input area, model selector, inline surfaces~~ | **Resolved:** 3 tests added — WelcomeScreen assertions (robot icon, title, 5 tips), CSP external image rewriting in markdown, chart inline surface rendering with series labels/values |
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
@@ -193,9 +193,10 @@ All reconciled to follow code. Specs must be self-consistent and match code.
1c. ~~**A1-17**~~ — blogmark deep-link handler resolved: `BDS.Desktop.DeepLink` receives OS `bds2://` URL events and `BDS.Blogmark` parses them, runs the transform pipeline, and creates+opens a draft post (macOS `Info.plist` scheme registration documented, pending an app-bundle pipeline)
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec (B1-1, B1-2, B1-3, B1-4 resolved)
4. ~~**B1-1 through B1-20**~~ — all resolved: chat inline surfaces, auto-translation, settings sections, style tab, published snapshot fields, rendering subsystem (new rendering.allium), 404.html, media translation modal, menu ops, language picker + confirm dialog, script/template publish actions, import + documentation tabs, metadata-diff entity types, task TTL eviction, discard-post-changes, replace-media-file
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
6. **D2-1 through D2-17** — untested rules
7. **D3-1 through D3-11** — partial test coverage
8. **B1-7 through B1-20** — minor code behaviors missing from spec
9. **D4-1 through D4-7** — UI test coverage
6. ~~**D2-1 through D2-17**~~ — all resolved: `max_posts_per_page` constraint, sandboxed execution, transform toast budget, progress throttle, archived→draft/published transitions, AppNoopNotifier, validate_media implementation+tests, content_hash skip on reindex
7. ~~**D3-1 through D3-11**~~ — all resolved: content=null assertion, old-file-deletion, DNT guard, validation prerequisites (already tested), macro failure degrades to empty, template roundtrip, default categories, FTS multi-language, canonical URL format, German transliteration expansion
8. ~~**B2-1 through B2-9**~~ — all resolved: editor_body resolver, single-post reimport, orphan import, dashboard data, missing-thumbnail regen, cache dir, stale-template prune, render labels, generation progress reporting
9. **D4-1 through D4-3**~~UI test coverage~~ **Resolved (D4-1 via standalone delete_media_translation + MediaDetectLanguage tests; D4-2 via 3 new test files + expanded managed_categories — 56 tests added; D4-3 via WelcomeScreen/CSP/chart surface tests)**
**D4-4 through D4-7** — remaining UI test coverage

View File

@@ -349,6 +349,53 @@ defmodule BDS.Media do
)
end
@spec validate_media(String.t()) :: [%{media_id: String.t(), issue: String.t()}]
def validate_media(project_id) do
project = BDS.Projects.get_project!(project_id)
data_dir = BDS.Projects.project_data_dir(project)
Repo.all(from m in Media, where: m.project_id == ^project_id)
|> Enum.flat_map(fn media ->
issues = []
binary_path = Path.join(data_dir, media.file_path)
issues = if File.exists?(binary_path), do: issues, else: [%{media_id: media.id, issue: "missing_binary"} | issues]
sidecar_path = Path.join(data_dir, media.sidecar_path)
issues = if File.exists?(sidecar_path), do: issues, else: [%{media_id: media.id, issue: "missing_sidecar"} | issues]
issues =
if BDS.Media.FileOps.image_mime?(media.mime_type) do
thumbnails = BDS.Media.Thumbnails.thumbnail_paths(media)
Enum.reduce([:small, :medium, :large, :ai], issues, fn size, acc ->
thumb_path = Path.join(data_dir, Map.fetch!(thumbnails, size))
if File.exists?(thumb_path),
do: acc,
else: [%{media_id: media.id, issue: "missing_thumbnail_#{size}"} | acc]
end)
else
issues
end
issues =
if BDS.Media.FileOps.image_mime?(media.mime_type) and File.exists?(binary_path) do
case BDS.Media.FileOps.image_dimensions(binary_path, media.mime_type) do
{nil, nil} -> [%{media_id: media.id, issue: "corrupted"} | issues]
_ -> issues
end
else
issues
end
linked_posts = BDS.Media.Linking.list_linked_posts(media.id)
issues = if linked_posts == [], do: [%{media_id: media.id, issue: "orphan"} | issues], else: issues
Enum.reverse(issues)
end)
end
defp log_thumbnail_error(:ok, _media_id), do: :ok
defp log_thumbnail_error({:error, reason}, media_id) do

View File

@@ -71,7 +71,7 @@ defmodule BDS.Scripting do
{:error, reason} ->
Logger.warning("execute_macro failed for project #{project_id}: #{inspect(reason)}")
{:error, reason}
{:ok, ""}
end
end

View File

@@ -42,6 +42,56 @@ defmodule BDS.Scripts do
|> Repo.insert()
end
@spec create_and_publish_script(attrs()) :: script_result()
def create_and_publish_script(attrs) do
project_id = attr(attrs, :project_id)
title = attr(attrs, :title) || ""
kind = attr(attrs, :kind)
content = attr(attrs, :content) || ""
case validate_script(content) do
:ok ->
slug = unique_slug(project_id, Slug.slugify(title), "script")
entrypoint = attr(attrs, :entrypoint) || default_entrypoint(kind)
file_path = script_file_path(slug)
now = Persistence.now_ms()
changeset =
%Script{}
|> Script.changeset(%{
id: Ecto.UUID.generate(),
project_id: project_id,
slug: slug,
title: title,
kind: kind,
entrypoint: entrypoint,
enabled: true,
version: 1,
file_path: file_path,
status: :published,
content: nil,
created_at: now,
updated_at: now
})
with {:ok, script} <- Repo.insert(changeset) do
full_path = full_file_path(script.project_id, file_path)
File.mkdir_p!(Path.dirname(full_path))
:ok =
Persistence.atomic_write(
full_path,
serialize_script_file(script, content)
)
{:ok, script}
end
{:error, reason} ->
{:error, {:invalid_script, reason}}
end
end
@spec get_script(String.t()) :: Script.t() | nil
def get_script(script_id) do
case Repo.get(Script, script_id) do

View File

@@ -109,6 +109,54 @@ defmodule BDS.Templates do
end
end
@spec create_and_publish_template(attrs()) :: template_result()
def create_and_publish_template(attrs) do
project_id = attr(attrs, :project_id)
title = attr(attrs, :title) || ""
kind = attr(attrs, :kind)
content = attr(attrs, :content) || ""
case validate_liquid(content) do
:ok ->
slug = unique_slug(project_id, Slug.slugify(title), "template")
file_path = template_file_path(slug)
now = Persistence.now_ms()
changeset =
%Template{}
|> Template.changeset(%{
id: Ecto.UUID.generate(),
project_id: project_id,
slug: slug,
title: title,
kind: kind,
enabled: true,
version: 1,
file_path: file_path,
status: :published,
content: nil,
created_at: now,
updated_at: now
})
with {:ok, template} <- Repo.insert(changeset) do
full_path = full_file_path(template.project_id, file_path)
File.mkdir_p!(Path.dirname(full_path))
:ok =
Persistence.atomic_write(
full_path,
serialize_template_file(template, content)
)
{:ok, template}
end
{:error, reason} ->
{:error, {:invalid_liquid, reason}}
end
end
@spec update_template(String.t(), attrs()) :: template_result() | {:error, :not_found}
def update_template(template_id, attrs) do
with %Template{} = template <- Repo.get(Template, template_id) do

View File

@@ -20,6 +20,7 @@ use "./metadata.allium" as metadata -- Project config, categories, publi
-- Infrastructure
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
use "./rendering.allium" as rendering -- Template render assigns, filters, macros (preview + generation)
use "./generation.allium" as generation -- Static site generation (sections, routes, hashing)
use "./preview.allium" as preview -- Local HTTP preview server
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)

View File

@@ -130,9 +130,15 @@ surface MediaEditorSurface {
@guarantee TranslationsSection
-- Shown only when language is set.
-- List of existing translations: flag emoji + language name + title.
-- Per-translation actions: click to edit inline, refresh button, delete button.
-- Per-translation actions: click to edit (opens modal), refresh button, delete button.
-- "No translations" message when list is empty.
@guarantee TranslationEditModal
-- Editing a translation opens a modal dialog ("Edit Translation"), not
-- an inline form. Hidden language field plus Title, Alt Text, and
-- Caption (textarea) inputs. Footer: Cancel and Save buttons.
-- Save persists the translation; Cancel/close discards.
@guarantee LinkedPostsSection
-- "Link to Post" button opens inline post picker overlay.
-- List of currently linked posts with document icon. Click navigates to post tab.
@@ -214,8 +220,9 @@ rule MediaLinkToPost {
rule MediaTranslationEdit {
when: MediaTranslationEditClicked(media_id, language)
-- Loads translation fields inline (title, alt, caption) for that language
-- Edit in place, save persists to translated sidecar {path}.{lang}.meta
-- Opens the "Edit Translation" modal pre-filled with the translation's
-- title, alt, and caption for that language
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards
}
rule MediaTranslationRefresh {

View File

@@ -148,6 +148,16 @@ surface DashboardSurface {
-- Single-click: preview tab. Double-click: pin tab.
}
rule ComputeDashboardData {
when: DashboardRequested(project)
-- stats: post counts grouped by status (total = sum of all statuses),
-- plus media/image counts, total media size, tag and category counts.
-- timeline: posts grouped by (year, month) of created_at, newest first,
-- limited to the most recent config.dashboard_timeline_months with data.
-- tag_cloud / category_cloud / recent_posts populated per their guarantees.
ensures: Dashboard
}
-- ─── Menu editor view ────────────────────────────────────────
-- Visual editor for the OPML navigation menu (meta/menu.opml).

View File

@@ -19,6 +19,7 @@ value ScriptEditorView {
entrypoint: String -- select: discovered Lua functions available as entrypoints
enabled: Boolean -- checkbox
content: String -- code editor content
can_publish: Boolean -- true while status = draft
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
@@ -37,13 +38,16 @@ surface ScriptEditorSurface {
provides:
ScriptSaveRequested(editor.script_id)
ScriptPublishRequested(editor.script_id)
when editor.can_publish
ScriptRunRequested(editor.script_id)
ScriptCheckSyntaxRequested(editor.script_id)
ScriptDeleteRequested(editor.script_id)
@guarantee HeaderLayout
-- Header bar with script title tab.
-- Actions (right side): Save button, Run button,
-- Actions (right side): Save button, Publish button (shown only when
-- can_publish, i.e. status = draft), Run button,
-- Check Syntax button, Delete button (danger style).
@guarantee MetadataRow
@@ -72,6 +76,15 @@ rule ScriptSave {
-- Entrypoint list re-discovered from Lua source after save
}
rule ScriptPublish {
when: ScriptPublishRequested(script_id)
-- Only offered while the script is a draft (can_publish gate on the button)
-- Validates Lua syntax first (publish gate); invalid source blocks publish
-- Saves current draft fields, then publishes (status -> published)
-- Writes the published script file to disk
-- See script.allium PublishScript
}
rule ScriptCheckSyntax {
when: ScriptCheckSyntaxRequested(script_id)
-- Validates Lua syntax without saving

View File

@@ -18,6 +18,7 @@ value TemplateEditorView {
kind: String -- select: post | list | not_found | partial
enabled: Boolean -- checkbox
content: String -- code editor content
can_publish: Boolean -- true while status = draft
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
@@ -35,12 +36,15 @@ surface TemplateEditorSurface {
provides:
TemplateSaveRequested(editor.template_id)
TemplatePublishRequested(editor.template_id)
when editor.can_publish
TemplateValidateRequested(editor.template_id)
TemplateDeleteRequested(editor.template_id)
@guarantee HeaderLayout
-- Header bar with template title tab.
-- Actions (right side): Save button, Validate button,
-- Actions (right side): Save button, Publish button (shown only when
-- can_publish, i.e. status = draft), Validate button,
-- Delete button (danger style).
@guarantee MetadataRow
@@ -67,6 +71,15 @@ rule TemplateSave {
-- See engine_side_effects.allium UpdateTemplateSideEffects
}
rule TemplatePublish {
when: TemplatePublishRequested(template_id)
-- Only offered while the template is a draft (can_publish gate on the button)
-- Validates Liquid first (publish gate); invalid source blocks publish
-- Saves current draft fields, then publishes (status -> published)
-- Writes the published .liquid file to disk
-- See template.allium PublishTemplate
}
rule TemplateValidate {
when: TemplateValidateRequested(template_id)
-- Validates Liquid syntax without saving

View File

@@ -24,7 +24,15 @@ surface GenerationRuntimeSurface {
provides:
PageRenderRequested(template, context)
GenerationProgressReported(current, total, label)
GenerateSiteCompleted(generation)
@guarantee ProgressReporting
-- Generation, reindex, and site-validation run as background tasks and
-- emit count-based progress (current/total + label, e.g. "Processing
-- N of M posts") via the task progress channel (see task.allium
-- ReportProgress / ProgressThrottled). Multi-phase work (validation)
-- maps each phase onto a fixed fraction of the 0.0..1.0 bar.
}
value GenerationSection {
@@ -121,10 +129,13 @@ rule GenerateCoreSectionPages {
-- Atom feed
ensures: FileGenerated("calendar.json")
-- Post dates for calendar widget
ensures: FileGenerated("404.html")
-- Not-found page rendered from the not-found template
for lang in generation.blog_languages - {generation.language}:
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
ensures: FileGenerated(format("{lang}/404.html", lang: lang))
}
-- Single section: one HTML page per published post

View File

@@ -11,6 +11,7 @@ surface MediaControlSurface {
provides:
ImportMediaRequested(source_path, project, metadata)
UpdateMediaRequested(media, changes)
ReplaceMediaFileRequested(media, new_source_path)
DeleteMediaRequested(media)
UpsertMediaTranslationRequested(media, language, title, alt, caption)
RebuildMediaFromFilesRequested(project)
@@ -158,6 +159,28 @@ rule UpdateMedia {
ensures: SearchIndexUpdated(media)
}
rule ReplaceMediaFile {
when: ReplaceMediaFileRequested(media, new_source_path)
-- Replaces the binary at media.file_path with the new source file,
-- keeping the same path/id. Sidecar metadata (title/alt/etc.) is preserved.
let checksum = md5(read(new_source_path))
-- Identical content (checksum = media.checksum): no-op, nothing rewritten.
-- Otherwise the old file is backed up to {path}.bak (restored on failure,
-- removed on success) and the row is updated from the new file:
if checksum != media.checksum:
ensures: media.checksum = checksum
ensures: media.size = file_size(new_source_path)
ensures: media.updated_at = now
ensures: MediaDimensionsUpdated(media)
-- width/height re-read from the new image
ensures: SidecarWritten(media)
if media.is_image:
ensures: ThumbnailsRegenerated(media)
-- Synchronous (awaited), not fire-and-forget
ensures: SearchIndexUpdated(media)
-- See engine_side_effects.allium ReplaceMediaFileSideEffects
}
rule DeleteMedia {
when: DeleteMediaRequested(media)
ensures: not exists media

View File

@@ -18,6 +18,7 @@ surface MediaProcessingControlSurface {
TagMediaRequested(media, tags)
DeleteMediaRequested(media)
ValidateMediaRequested(project)
RegenerateMissingThumbnailsRequested(project)
}
surface MediaProcessingRuntimeSurface {
@@ -131,6 +132,18 @@ rule GenerateThumbnails {
)
}
rule RegenerateMissingThumbnails {
when: RegenerateMissingThumbnailsRequested(project)
-- Maintenance sweep over the project's raster images (images, excluding SVG),
-- oldest first. For each, any thumbnail file absent from disk is regenerated
-- from the original binary; images with a full set are skipped.
-- Runs as a background task reporting progress (current/total).
-- Returns counts: processed, generated, failed.
for media in project.media where is_image(media.mime_type) and not is_svg(media.mime_type):
if any_thumbnail_missing(media):
ensures: ThumbnailsRegenerated(media)
}
-- Thumbnail generation algorithm
value ThumbnailGeneration {
-- 1. Load source image

View File

@@ -14,6 +14,7 @@ surface MetadataMaintenanceSurface {
provides:
MetadataDiffRequested(project)
RebuildFromFilesystemRequested(project, entity_type)
RepairMetadataDiffItemRequested(project, direction, item)
}
value DiffField {
@@ -23,7 +24,8 @@ value DiffField {
}
value DiffReport {
entity_type: String -- post, media, script, template
entity_type: String -- post, post_translation, media,
-- media_translation, script, template, embedding
entity_id: String
differences: List<DiffField>
}
@@ -66,7 +68,26 @@ rule RunMetadataDiff {
if matching.count = 0:
ensures: OrphanReport.created(file_path: file)
-- Same pattern for media sidecar files, scripts, templates
-- Same pattern for media sidecars (media), media translation sidecars
-- (media_translation), scripts, templates, and embeddings.
-- Embedding diffs compare the stored content_hash against the live post
-- content to detect vectors that need recomputation.
}
rule RepairMetadataDiffItem {
when: RepairMetadataDiffItemRequested(project, direction, item)
-- Resolves a single diff in one direction.
-- direction = file_to_db (filesystem wins) | db_to_file (database wins)
-- Dispatched per item.entity_type:
-- project | categories | category_meta | publishing -> project metadata sync/flush
-- post -> sync_post_from_file / rewrite_published_post
-- post_translation -> sync_post_translation_from_file / rewrite_published_post_translation
-- media -> sync_media_from_sidecar / sync_media_sidecar
-- media_translation -> sync_media_translation_from_sidecar / sync_media_translation_sidecar
-- script -> sync_script_from_file / sync_published_script_file
-- template -> sync_template_from_file / sync_published_template_file
-- embedding -> sync_post (file_to_db) / refresh_snapshot (db_to_file)
-- Unknown entity_type or direction -> unsupported error.
}
rule RebuildFromFilesystem {

View File

@@ -55,6 +55,9 @@ surface PostControlSurface {
PublishPostRequested(post)
DeletePostRequested(post)
ArchivePostRequested(post)
DiscardPostChangesRequested(post)
SyncPostFromFileRequested(post)
ImportOrphanPostFileRequested(project, relative_path)
}
surface PostFilePathSurface {
@@ -100,6 +103,15 @@ entity Post {
updated_at: Timestamp
published_at: Timestamp?
-- Published snapshot: copy of title/content/tags/categories/excerpt as of
-- the last publish. Used by changes_affect_published_content to decide when
-- an edit reopens a published post to draft (see ReopenPublishedPost).
published_title: String?
published_content: String?
published_tags: String?
published_categories: String?
published_excerpt: String?
-- Relationships
translations: PostTranslation with canonical_post = this
linked_media: PostMediaLink with post = this
@@ -112,6 +124,10 @@ entity Post {
-- Slug changes only allowed before first publish
content_location: if status = published: file_path else: content
-- Published: body in filesystem. Draft: body in DB field.
editor_body: if content != null: content else: read_markdown_body(file_path)
-- Resolver used by editors: prefer the in-DB draft content, else read
-- the markdown body from the post's file. Empty string when neither
-- exists. The same resolver applies to PostTranslation records.
transitions status {
draft -> published
@@ -218,6 +234,39 @@ rule ArchivePost {
ensures: post.status = archived
}
rule SyncPostFromFile {
when: SyncPostFromFileRequested(post)
requires: post.file_path != ""
-- Single-post reimport: re-reads the post's own .md file and upserts the DB
-- record from it (the filesystem is treated as truth for that one post).
-- Re-syncs the post's link graph. Errors out if the file is missing.
ensures: PostFieldsUpdated(post, parse_post_file(post.file_path))
ensures: PostLinksUpdated(post)
}
rule ImportOrphanPostFile {
when: ImportOrphanPostFileRequested(project, relative_path)
-- Imports a .md file that exists on disk but has no DB record (an orphan,
-- e.g. surfaced by RunMetadataDiff). Rejects non-markdown / missing files.
ensures:
let new_post = Post.created(parse_post_file(relative_path))
SearchIndexUpdated(new_post)
}
rule DiscardPostChanges {
when: DiscardPostChangesRequested(post)
requires: post.file_path != ""
-- Only posts with a published file on disk can be discarded;
-- a never-published draft has no file version to restore.
-- Re-reads the published .md file and upserts the DB record from it,
-- discarding unsaved draft edits.
ensures: post.content = null
ensures: post.status = published
ensures: PostLinksUpdated(post)
ensures: SearchIndexUpdated(post)
-- See engine_side_effects.allium DiscardPostChangesSideEffects
}
-- File format axioms
invariant FrontmatterRoundtrip {

View File

@@ -55,6 +55,10 @@ entity Project {
-- Linux: $XDG_CONFIG_HOME/bds (default ~/.config/bds)
-- Windows: %APPDATA%\\bds
-- See PrivateArtifactsLiveInOsAppDir.
cache_dir: private_dir + "/projects/" + id
-- Per-project subtree of private_dir for that project's regenerable
-- artifacts (embeddings index + sidecar). Overridable in tests via the
-- :project_cache_root setting; never the repo or the project folder.
}
surface ProjectSurface {

207
specs/rendering.allium Normal file
View File

@@ -0,0 +1,207 @@
-- allium: 1
-- bDS Rendering Subsystem
-- Scope: core — template rendering shared by preview and generation
-- Distilled from: lib/bds/rendering/{filters,labels,links_and_languages,
-- metadata,post_rendering,list_archive}.ex
-- The rendering subsystem turns a post/list/not-found record plus project
-- metadata into the assigns consumed by a Liquid template. It is shared by the
-- preview server (on-demand) and static site generation (published .md files).
-- Rendering language is the CONTENT language (post/project), never the UI locale.
use "./template.allium" as template
use "./template_context.allium" as template_context
use "./i18n.allium" as i18n
use "./post.allium" as post
-- ─── Custom Liquid filters ───────────────────────────────────
-- The three custom filters available in the Liquid subset (see
-- template.allium LiquidFilterSubset). Applied during template rendering.
surface RenderFilterSurface {
facing _: TemplateRenderer
provides:
I18nFilterApplied(value, language)
MarkdownFilterApplied(value, post_id, language)
SlugifyFilterApplied(value)
}
rule I18nFilter {
when: I18nFilterApplied(value, language)
-- {{ "Key" | i18n }} -> localized "render" domain string for `language`.
-- Empty/blank input returns empty string.
ensures: result = lgettext(language, "render", trim(value))
}
rule SlugifyFilter {
when: SlugifyFilterApplied(value)
-- {{ title | slugify }} -> URL-friendly slug (same Slug.slugify as posts).
ensures: result = slugify(value)
}
rule MarkdownFilter {
when: MarkdownFilterApplied(value, post_id, language)
-- {{ body | markdown }} pipeline, in order:
-- 1. Expand built-in [[...]] macros (see ExpandBuiltinMacros)
-- 2. Convert markdown to HTML (Earmark; errors degrade to partial HTML)
-- 3. Rewrite href/src URLs to canonical post/media paths (see RewriteUrls)
ensures: result = rewritten_html
}
-- ─── Built-in macros ─────────────────────────────────────────
-- Macros use double-bracket syntax [[name param="value" ...]], expanded in
-- markdown before HTML conversion. NOT Liquid tags. Each renders a bundled
-- macro template (macros/{name}) in an isolated Liquid subscope.
value BuiltinMacro {
name: String -- youtube | vimeo | gallery | photo_archive | tag_cloud
params: Map<String, String>
}
surface BuiltinMacroSurface {
context macro: BuiltinMacro
exposes:
macro.name
macro.params
}
rule ExpandBuiltinMacros {
when: MacroEncountered(macro, language, post_id)
-- Unknown macro names are left verbatim in the source.
if macro.name = "youtube" or macro.name = "vimeo":
-- Embeds video by `id`; localized default title when title param absent.
ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name))
if macro.name = "gallery":
-- Image gallery from the post's linked image media (ordered by sort).
-- `columns` clamped to 1..6 (default 3). Empty when no post_id/images.
ensures: MacroTemplateRendered("macros/gallery")
if macro.name = "photo_archive":
-- Month-grouped image archive for the project (optional year/month filter).
ensures: MacroTemplateRendered("macros/photo-archive")
if macro.name = "tag_cloud":
-- Weighted tag cloud from post tag counts; per-tag colours from Tag rows.
ensures: MacroTemplateRendered("macros/tag-cloud")
}
invariant MacroIsolation {
-- Macro templates render in an isolated Liquid subscope so macro-local
-- assigns never leak into the surrounding template context.
}
-- ─── URL rewriting ───────────────────────────────────────────
rule RewriteUrls {
when: RenderedHtmlProduced(html, canonical_post_paths, canonical_media_paths)
-- Rewrites href= and src= attribute values in rendered HTML.
-- External/special URLs (scheme:, //, #) are left untouched.
-- Internal post references (/post/{slug}, /posts/{slug}, dated paths) map to
-- the post's canonical dated path; query/fragment suffixes are preserved.
-- Internal /media/YYYY/MM/{file} references map to canonical media paths.
ensures: AttributesRewritten(html)
}
-- ─── Links and languages ─────────────────────────────────────
value LinkContext {
href: String
title: String
display_slug: String
language: String
}
rule ResolveLanguagePrefix {
when: LanguagePrefixRequested(language, main_language)
-- "" for the main language (and nil/blank); "/{language}" otherwise.
if language = main_language:
ensures: prefix = ""
else:
ensures: prefix = format("/{lang}", lang: language)
}
rule CollectLinkContexts {
when: LinkContextsRequested(project, post_id, direction)
-- direction = incoming (backlinks) | outgoing.
-- One LinkContext per linked post that still exists; missing targets dropped.
-- href = canonical post path, language normalized to main when unset.
ensures: List<LinkContext>
}
-- ─── Render labels ───────────────────────────────────────────
value RenderLabels {
-- Localized strings for rendered/preview output, resolved in the "render"
-- gettext domain for the CONTENT language (not the UI locale). Includes
-- taxonomy, backlinks, archive, pagination, calendar, search, not-found,
-- and macro fallback labels. Month names resolved 1..12 per language.
}
invariant LabelsUseContentLanguage {
-- RenderLabels and the i18n filter resolve against the content/render
-- language, consistent with i18n.allium's split-localization rule.
}
-- ─── Post render assigns ─────────────────────────────────────
-- The full assigns map for a single post template, assembled from the post
-- record (Post or PostTranslation) and project metadata.
value PostRenderAssigns {
language: String
language_prefix: String
page_title: String?
pico_stylesheet_href: String
blog_languages: List<i18n/RenderLanguage>
alternate_links: List<AlternateLink> -- hreflang alternates for translations
menu_items: List<template_context/MenuItem>
post_categories: List<String>
post_tags: List<String>
tag_color_by_name: Map<String, String?>
backlinks: List<LinkContext>
canonical_post_path_by_slug: Map<String, String>
canonical_media_path_by_source_path: Map<String, String>
post_data_json_by_id: String -- PostData JSON for client widgets
post: template_context/PostContext -- includes incoming/outgoing links
labels: RenderLabels
calendar_initial_year: Integer?
calendar_initial_month: Integer?
}
value AlternateLink {
language: String
href: String
}
rule BuildPostAssigns {
when: PostAssignsRequested(project, assigns)
-- Loads the post/translation record, renders its markdown body (macros +
-- HTML + URL rewrite), collects incoming/outgoing links, and resolves all
-- metadata-derived assigns (menu, languages, alternates, tag colours,
-- calendar bounds, labels) for the post's language.
ensures: PostRenderAssigns
}
rule BuildNotFoundAssigns {
when: NotFoundAssignsRequested(project, assigns)
-- Assigns for the 404 page: page_title defaults to "404", no alternates,
-- but shares language/menu/blog_languages/stylesheet/labels with posts.
-- Consumed by the not-found template (see generation.allium 404.html).
ensures: NotFoundRenderAssigns
}
rule BuildListAssigns {
when: ListAssignsRequested(project, assigns)
-- Assigns for list/archive pages (home, category, tag, date archives):
-- paginated post list plus shared metadata-derived assigns.
ensures: ListRenderAssigns
}
invariant SharedRenderPathForPreviewAndGeneration {
-- Preview and generation produce identical HTML for the same input because
-- both build assigns through this subsystem and render via the same Liquid
-- subset. They differ only in content SOURCE (see preview.allium
-- PreviewDraftOverlay and generation.allium GenerationPublishedOnly).
}

View File

@@ -36,6 +36,7 @@ surface TaskRuntimeSurface {
TaskWorkCompleted(task)
TaskWorkFailed(task, error_message)
ProgressReported(task, value, message)
FinishedTaskEvictionDue()
}
surface TaskSurface {
@@ -54,6 +55,8 @@ surface TaskSurface {
config {
max_concurrent: Integer = 3
progress_throttle: Duration = 250.milliseconds
finished_task_ttl: Duration = 1.hour
recent_finished_limit: Integer = 10
}
invariant MaxConcurrency {
@@ -112,6 +115,23 @@ invariant ProgressThrottled {
-- At most one progress event per 250ms per task
}
invariant FinishedTaskRetention {
-- The status snapshot surfaces only the most recent finished tasks:
-- completed/failed/cancelled tasks beyond config.recent_finished_limit
-- (newest first) are not shown.
let finished = Tasks where status in {completed, failed, cancelled}
-- At most config.recent_finished_limit finished tasks are reported.
}
rule EvictFinishedTasks {
when: FinishedTaskEvictionDue()
-- Periodic sweep (every config.finished_task_ttl). A finished task whose
-- finished_at is older than config.finished_task_ttl is dropped from state.
for task in Tasks where status in {completed, failed, cancelled}:
if now - task.finished_at >= config.finished_task_ttl:
ensures: not exists task
}
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
rule RegisterExternalTask {
when: RegisterExternalTaskRequested(name)

View File

@@ -175,6 +175,12 @@ rule RebuildTemplatesFromFiles {
let parsed = parse_template_file(file)
ensures: Template.created(parsed)
-- or updated if slug already exists
-- Prune stale published templates: a published Template whose file_path is
-- neither among the scanned files nor present on disk is deleted, clearing
-- its references first (posts' template_slug, tags' post_template_slug).
for template in project.templates where status = published and file_path != "":
if template.file_path not in scanned_files and not file_exists(template.file_path):
ensures: not exists template
}
-- Exact Liquid subset required (distilled from bundled starter templates)

View File

@@ -13,6 +13,37 @@ defmodule BDS.CliSyncTest do
:ok
end
test "app-side writes do not produce notification rows (AppNoopNotifier)", %{} do
existing_before_create = Repo.aggregate(BDS.CliSync.Notification, :count)
# Perform a few app-side operations — post create, media import, metadata
# update — none should leave a notification row behind.
temp_dir =
Path.join(System.tmp_dir!(), "bds-app-noop-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "AppNoop", data_path: temp_dir})
{:ok, _post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "App-Created",
content: "body"
})
source_path = Path.join(temp_dir, "image.png")
File.write!(source_path, "fake png")
{:ok, _media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
{:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{name: "Renamed"})
existing_after = Repo.aggregate(BDS.CliSync.Notification, :count)
assert existing_after == existing_before_create
end
test "cli mutations are written to db_notifications, processed on file change, and marked seen" do
assert {:ok, notification} = CliSync.cli_mutation_performed("post", "post-1", :updated)
assert notification.from_cli == true

View File

@@ -1,7 +1,6 @@
defmodule BDS.CSM013BangRenderingTest do
use ExUnit.Case, async: false
alias BDS.Rendering
alias BDS.Rendering.PostRendering
alias BDS.Rendering.TemplateSelection

View File

@@ -3,6 +3,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
defp socket_with_assigns(extra \\ %{}) do
%Phoenix.LiveView.Socket{assigns: Map.merge(%{__changed__: %{}, workbench: nil}, extra)}
end
describe "protected_category?/1" do
test "returns true for article, aside, page, picture" do
assert ManagedCategories.protected_category?("article")
@@ -18,6 +22,362 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
end
end
describe "category_rows/1" do
test "returns a row per category" do
metadata = %{
categories: ["article", "aside", "page", "picture", "notes"],
category_settings: %{
"article" => %{"title" => "Articles", "render_in_lists" => true, "show_title" => true},
"notes" => %{"title" => "My Notes", "render_in_lists" => true, "show_title" => false}
}
}
rows = ManagedCategories.category_rows(metadata)
assert length(rows) == 5
end
test "each row has expected keys" do
metadata = %{
categories: ["article"],
category_settings: %{"article" => %{"title" => "Articles"}}
}
rows = ManagedCategories.category_rows(metadata)
row = hd(rows)
assert Map.has_key?(row, :name)
assert Map.has_key?(row, :title)
assert Map.has_key?(row, :render_in_lists)
assert Map.has_key?(row, :show_title)
assert Map.has_key?(row, :post_template_slug)
assert Map.has_key?(row, :list_template_slug)
assert Map.has_key?(row, :protected?)
end
test "maps title from category_settings" do
metadata = %{
categories: ["article", "notes"],
category_settings: %{
"article" => %{"title" => "Articles"},
"notes" => %{"title" => "My Notes"}
}
}
rows = ManagedCategories.category_rows(metadata)
article = Enum.find(rows, &(&1.name == "article"))
assert article.title == "Articles"
notes = Enum.find(rows, &(&1.name == "notes"))
assert notes.title == "My Notes"
end
test "falls back to category name when no title in settings" do
rows = ManagedCategories.category_rows(%{
categories: ["custom-cat"],
category_settings: %{}
})
row = hd(rows)
assert row.title == "custom-cat"
end
test "marks protected categories" do
rows = ManagedCategories.category_rows(%{
categories: ["article", "notes"],
category_settings: %{}
})
article = Enum.find(rows, &(&1.name == "article"))
notes = Enum.find(rows, &(&1.name == "notes"))
assert article.protected?
refute notes.protected?
end
test "applies default render_in_lists and show_title when not in settings" do
rows = ManagedCategories.category_rows(%{
categories: ["custom"],
category_settings: %{}
})
row = hd(rows)
assert row.render_in_lists == true
assert row.show_title == true
end
test "default template slugs are empty strings" do
rows = ManagedCategories.category_rows(%{
categories: ["custom"],
category_settings: %{}
})
row = hd(rows)
assert row.post_template_slug == ""
assert row.list_template_slug == ""
end
end
describe "update_new_category/3" do
test "sets settings_editor_new_category assign" do
socket = socket_with_assigns()
reload = fn s, _wb ->
send(self(), {:reloaded, s})
s
end
ManagedCategories.update_new_category(socket, "my-cat", reload)
assert_received {:reloaded, updated}
assert updated.assigns.settings_editor_new_category == "my-cat"
end
test "defaults to empty string when nil" do
socket = socket_with_assigns()
reload = fn s, _wb ->
send(self(), {:reloaded, s})
s
end
ManagedCategories.update_new_category(socket, nil, reload)
assert_received {:reloaded, updated}
assert updated.assigns.settings_editor_new_category == ""
end
end
describe "add_category/3" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-managed-cat-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} =
BDS.Projects.create_project(%{name: "AddCategory", data_path: temp_dir})
%{project: project}
end
test "adds a new category via Metadata", %{project: project} do
socket = socket_with_assigns(%{
projects: %{active_project_id: project.id},
settings_editor_new_category: "test-category"
})
reload = fn s, _wb ->
send(self(), :reloaded)
s
end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
ManagedCategories.add_category(socket, reload, append_output)
assert_received :reloaded
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
assert "test-category" in meta.categories
end
test "clears new_category input after successful add", %{project: project} do
socket = socket_with_assigns(%{
projects: %{active_project_id: project.id},
settings_editor_new_category: "test-category"
})
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
result = ManagedCategories.add_category(socket, reload, append_output)
assert result.assigns.settings_editor_new_category == ""
end
test "shows error for empty category name", %{project: project} do
socket = socket_with_assigns(%{
projects: %{active_project_id: project.id},
settings_editor_new_category: ""
})
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), :error_appended)
socket
end
ManagedCategories.add_category(socket, reload, append_output)
assert_received :error_appended
end
end
describe "save_category/4" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-managed-cat-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} =
BDS.Projects.create_project(%{name: "SaveCategory", data_path: temp_dir})
%{project: project}
end
test "saves category settings via Metadata", %{project: project} do
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
params = %{
"category" => "article",
"title" => "Articles",
"render_in_lists" => "true",
"show_title" => "true",
"post_template_slug" => "",
"list_template_slug" => ""
}
reload = fn s, _wb ->
send(self(), :reloaded)
s
end
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), :error_appended)
socket
end
ManagedCategories.save_category(socket, params, reload, append_output)
assert_received :reloaded
refute_received :error_appended
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
cat_settings = meta.category_settings["article"]
assert cat_settings["title"] == "Articles"
assert cat_settings["render_in_lists"] == true
assert cat_settings["show_title"] == true
end
test "saves template slug for a category", %{project: project} do
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
params = %{
"category" => "article",
"title" => "Articles",
"render_in_lists" => "true",
"show_title" => "true",
"post_template_slug" => "my-post",
"list_template_slug" => "my-list"
}
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
ManagedCategories.save_category(socket, params, reload, append_output)
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
cat_settings = meta.category_settings["article"]
assert cat_settings["post_template_slug"] == "my-post"
assert cat_settings["list_template_slug"] == "my-list"
end
end
describe "reset_categories/3" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-managed-cat-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} =
BDS.Projects.create_project(%{name: "ResetCategories", data_path: temp_dir})
BDS.Metadata.add_category(project.id, "custom-1")
BDS.Metadata.add_category(project.id, "custom-2")
%{project: project}
end
test "removes non-protected categories and restores defaults", %{project: project} do
assert {:ok, before} = BDS.Metadata.get_project_metadata(project.id)
assert "custom-1" in before.categories
assert "custom-2" in before.categories
socket = socket_with_assigns(%{
projects: %{active_project_id: project.id},
settings_editor_new_category: "dirty"
})
reload = fn s, _wb ->
send(self(), :reloaded)
s
end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
ManagedCategories.reset_categories(socket, reload, append_output)
assert_received :reloaded
assert {:ok, after_meta} = BDS.Metadata.get_project_metadata(project.id)
refute "custom-1" in after_meta.categories
refute "custom-2" in after_meta.categories
assert "article" in after_meta.categories
assert "aside" in after_meta.categories
assert "page" in after_meta.categories
assert "picture" in after_meta.categories
end
test "preserves protected categories during reset", %{project: project} do
socket = socket_with_assigns(%{
projects: %{active_project_id: project.id},
settings_editor_new_category: ""
})
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
ManagedCategories.reset_categories(socket, reload, append_output)
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
assert "article" in meta.categories
assert "aside" in meta.categories
end
test "clears new_category input after reset", %{project: project} do
socket = socket_with_assigns(%{
projects: %{active_project_id: project.id},
settings_editor_new_category: "dirty"
})
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
result = ManagedCategories.reset_categories(socket, reload, append_output)
assert result.assigns.settings_editor_new_category == ""
end
test "restores default category settings after reset", %{project: project} do
socket = socket_with_assigns(%{
projects: %{active_project_id: project.id},
settings_editor_new_category: ""
})
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
ManagedCategories.reset_categories(socket, reload, append_output)
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
article = meta.category_settings["article"]
assert article["title"] == "article"
assert article["render_in_lists"] == true
assert article["show_title"] == true
end
end
describe "remove_category/4" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
@@ -35,7 +395,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
end
test "rejects deletion of protected category with error output", %{project: project} do
socket = %{assigns: %{projects: %{active_project_id: project.id}, workbench: nil}}
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), :error_appended)
@@ -53,7 +413,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
end
test "rejects deletion of all protected categories", %{project: project} do
socket = %{assigns: %{projects: %{active_project_id: project.id}, workbench: nil}}
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
for cat <- ["article", "aside", "page", "picture"] do
append_output = fn _socket, _title, _msg, _nil, _kind ->
@@ -75,12 +435,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
test "allows deletion of non-protected category via Metadata.remove_category", %{
project: project
} do
socket = %{
assigns: %{
projects: %{active_project_id: project.id},
workbench: nil
}
}
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
BDS.Metadata.add_category(project.id, "test-cat")
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)

View File

@@ -0,0 +1,82 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfigTest do
use ExUnit.Case, async: false
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
describe "mcp_rows/0" do
test "returns 7 agent rows" do
rows = MCPConfig.mcp_rows()
assert length(rows) == 7
end
test "has correct agent order" do
rows = MCPConfig.mcp_rows()
ids = Enum.map(rows, & &1.id)
assert ids == [:claude_code, :claude_desktop, :github_copilot, :gemini_cli, :opencode, :mistral_vibe, :openai_codex]
end
test "Claude Code and GitHub Copilot are supported" do
rows = MCPConfig.mcp_rows()
claude = Enum.find(rows, &(&1.id == :claude_code))
copilot = Enum.find(rows, &(&1.id == :github_copilot))
assert claude.supported?
assert copilot.supported?
end
test "other agents are not supported" do
rows = MCPConfig.mcp_rows()
unsupported = Enum.reject(rows, & &1.supported?)
assert length(unsupported) == 5
assert Enum.all?(unsupported, &(!&1.supported?))
end
test "unsupported agents have nil config_path" do
rows = MCPConfig.mcp_rows()
unsupported = Enum.filter(rows, &(!&1.supported?))
assert Enum.all?(unsupported, &is_nil(&1.config_path))
end
test "unsupported agents have configured? false" do
rows = MCPConfig.mcp_rows()
unsupported = Enum.filter(rows, &(!&1.supported?))
assert Enum.all?(unsupported, &(&1.configured? == false))
end
end
describe "toggle_mcp_agent/4" do
test "unsupported agent appends not-supported error" do
socket = %{assigns: %{workbench: nil}}
reload = fn s, _wb -> send(self(), :reloaded); s end
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), :error_appended)
socket
end
MCPConfig.toggle_mcp_agent(socket, "gemini_cli", reload, append_output)
assert_received :error_appended
assert_received :reloaded
end
test "unsupported agent does not touch AgentConfig" do
socket = %{assigns: %{workbench: nil}}
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
assert MCPConfig.toggle_mcp_agent(socket, "opencode", reload, append_output) == socket
end
test "unknown agent is treated as unsupported" do
socket = %{assigns: %{workbench: nil}}
reload = fn s, _wb -> s end
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), :error_appended)
socket
end
MCPConfig.toggle_mcp_agent(socket, "nonexistent_agent", reload, append_output)
assert_received :error_appended
end
end
end

View File

@@ -0,0 +1,114 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.SettingsSearchTest do
use ExUnit.Case, async: false
alias BDS.Desktop.ShellLive.SettingsEditor
describe "build_settings/1 — search filter" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-settings-search-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} =
BDS.Projects.create_project(%{name: "SearchFilter", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
defp base_assigns(project_id, temp_dir, query) do
%{
projects: %{active_project_id: project_id},
current_project: %{data_path: temp_dir},
settings_editor_search: query,
settings_editor_project_draft: %{},
settings_editor_editor_draft: %{},
settings_editor_ai_draft: %{},
settings_editor_publishing_draft: %{},
current_tab: %{type: :settings, id: "settings"},
tab_meta: %{}
}
end
test "empty query shows all sections visible", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, ""))
assert result.project_visible?
assert result.editor_visible?
assert result.content_visible?
assert result.ai_visible?
assert result.technology_visible?
assert result.publishing_visible?
assert result.mcp_visible?
assert result.data_visible?
end
test "matching query shows only relevant sections", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "ai"))
assert result.ai_visible?
refute result.editor_visible?
refute result.content_visible?
refute result.publishing_visible?
end
test "query 'publishing' shows publishing section only", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "publishing"))
assert result.publishing_visible?
refute result.editor_visible?
refute result.ai_visible?
refute result.technology_visible?
refute result.mcp_visible?
refute result.data_visible?
end
test "query 'mcp' shows mcp section", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "mcp"))
assert result.mcp_visible?
refute result.editor_visible?
refute result.ai_visible?
end
test "query 'claude' matches mcp section", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "claude"))
assert result.mcp_visible?
end
test "query 'data' shows data section", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "data"))
assert result.data_visible?
end
test "query 'editor' shows editor section", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "editor"))
assert result.editor_visible?
end
test "no match query shows no sections", %{project: project, temp_dir: temp_dir} do
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "zzzzz"))
refute result.project_visible?
refute result.editor_visible?
refute result.content_visible?
refute result.ai_visible?
refute result.technology_visible?
refute result.publishing_visible?
refute result.mcp_visible?
refute result.data_visible?
end
end
describe "build_settings/1 — returns nil without active project" do
test "returns nil when active_project_id is nil" do
assert SettingsEditor.build_settings(%{projects: %{active_project_id: nil}}) == nil
end
end
end

View File

@@ -5279,6 +5279,101 @@ defmodule BDS.Desktop.ShellLiveTest do
run_git!(project_dir, ["commit", "-m", message])
end
test "chat editor renders welcome screen with robot icon and tips when no messages exist" do
html =
render_component(
&BDS.Desktop.ShellLive.ChatEditor.render/1,
phase3_chat_editor_assigns()
)
assert html =~ "🤖"
assert html =~ "Welcome to the AI Assistant"
assert html =~ "Search for posts about a specific topic"
assert html =~ "Show a chart of posts published per month"
assert html =~ "Compare my recent posts in a table"
assert html =~ "Update metadata for posts or media"
assert html =~ "Show post statistics by year in tabs with charts"
end
test "chat editor rewrites external image URLs in markdown to text links" do
{:safe, html} =
BDS.Desktop.ShellLive.ChatEditor.markdown_html(
"![test](https://example.com/img.jpg)"
)
assert html =~ ~s(<a href="https://example.com/img.jpg")
refute html =~ ~r/<img\b/
end
test "chat editor renders chart inline surfaces from render_chart tool calls" do
assert {:ok, conversation} = AI.start_chat(%{title: "Chart Chat", model: "gpt-4.1"})
now = Persistence.now_ms()
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :user,
content: "Show a chart",
created_at: now
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "Here is the chart data.",
tool_calls:
Jason.encode!([
%{
"id" => "call-chart",
"name" => "render_chart",
"arguments" => %{
"title" => "Posts per Month",
"chart_type" => "bar",
"series" => [%{"label" => "May", "value" => 5}, %{"label" => "Jun", "value" => 3}]
}
}
]),
created_at: now + 1
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :tool,
tool_call_id: "call-chart",
content:
Jason.encode!(%{
"type" => "chart",
"title" => "Posts per Month",
"chart_type" => "bar",
"series" => [%{"label" => "May", "value" => 5}, %{"label" => "Jun", "value" => 3}]
}),
created_at: now + 2
})
)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
assert html =~ ~s(data-testid="chat-inline-surface")
assert html =~ "Posts per Month"
assert html =~ "bar"
assert html =~ "May"
assert html =~ "Jun"
assert length(:binary.matches(html, ~s(class="chat-surface-chart-row"))) == 2
end
# Sends a message to the LiveView and blocks until it has been processed.
# Uses a test ping/pong to guarantee the message was handled before returning.
defp send_and_await(view, message) do

View File

@@ -0,0 +1,173 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditorTest do
use ExUnit.Case, async: false
alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor
defp socket_with_assigns(extra \\ %{}) do
%Phoenix.LiveView.Socket{assigns: Map.merge(%{__changed__: %{}, workbench: nil}, extra)}
end
describe "build_style/1" do
test "returns nil when no active project" do
assigns = %{projects: %{active_project_id: nil}}
assert StyleEditor.build_style(assigns) == nil
end
end
describe "build_style/1 with real project" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-style-editor-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} =
BDS.Projects.create_project(%{name: "StyleEditor", data_path: temp_dir})
%{project: project}
end
test "returns map with themes, selected_theme, applied_theme, preview_mode, preview_url", %{project: project} do
assigns = %{projects: %{active_project_id: project.id}}
result = StyleEditor.build_style(assigns)
assert is_map(result)
assert Map.has_key?(result, :themes)
assert Map.has_key?(result, :selected_theme)
assert Map.has_key?(result, :applied_theme)
assert Map.has_key?(result, :preview_mode)
assert Map.has_key?(result, :preview_url)
end
test "includes 20 themes", %{project: project} do
assigns = %{projects: %{active_project_id: project.id}}
result = StyleEditor.build_style(assigns)
assert length(result.themes) == 20
end
test "each theme has required keys", %{project: project} do
assigns = %{projects: %{active_project_id: project.id}}
result = StyleEditor.build_style(assigns)
theme = hd(result.themes)
assert Map.has_key?(theme, :name)
assert Map.has_key?(theme, :accent_color)
assert Map.has_key?(theme, :light_bg_color)
assert Map.has_key?(theme, :dark_bg_color)
end
test "includes default, amber, and zinc themes", %{project: project} do
assigns = %{projects: %{active_project_id: project.id}}
result = StyleEditor.build_style(assigns)
names = Enum.map(result.themes, & &1.name)
assert "default" in names
assert "amber" in names
assert "zinc" in names
end
test "preview_url includes theme and mode params", %{project: project} do
assigns = %{projects: %{active_project_id: project.id}}
result = StyleEditor.build_style(assigns)
assert result.preview_url =~ "theme=default"
assert result.preview_url =~ "mode=auto"
assert result.preview_url =~ "127.0.0.1:4123/__style-preview"
end
test "preview_url reflects selected_theme", %{project: project} do
assigns = %{
projects: %{active_project_id: project.id},
style_editor_theme: "amber"
}
result = StyleEditor.build_style(assigns)
assert result.selected_theme == "amber"
assert result.preview_url =~ "theme=amber"
end
test "preview_url reflects preview_mode", %{project: project} do
assigns = %{
projects: %{active_project_id: project.id},
style_editor_preview_mode: "dark"
}
result = StyleEditor.build_style(assigns)
assert result.preview_mode == "dark"
assert result.preview_url =~ "mode=dark"
end
test "all 20 theme names are strings", %{project: project} do
assigns = %{projects: %{active_project_id: project.id}}
result = StyleEditor.build_style(assigns)
assert Enum.all?(result.themes, &is_binary(&1.name))
end
end
describe "theme_display_name/1" do
test "replaces hyphens with spaces and capitalizes" do
assert StyleEditor.theme_display_name("default") == "Default"
assert StyleEditor.theme_display_name("amber") == "Amber"
end
test "handles empty string" do
assert StyleEditor.theme_display_name("") == ""
end
end
describe "select_style_theme/3" do
test "updates style_editor_theme assign" do
socket = socket_with_assigns()
reload = fn s, _wb ->
send(self(), {:reloaded, s})
s
end
StyleEditor.select_style_theme(socket, "amber", reload)
assert_received {:reloaded, updated_socket}
assert updated_socket.assigns.style_editor_theme == "amber"
end
test "defaults to default when nil" do
socket = socket_with_assigns()
reload = fn s, _wb ->
send(self(), {:reloaded, s})
s
end
StyleEditor.select_style_theme(socket, nil, reload)
assert_received {:reloaded, updated_socket}
assert updated_socket.assigns.style_editor_theme == "default"
end
end
describe "change_style_preview_mode/3" do
test "updates style_editor_preview_mode assign" do
socket = socket_with_assigns()
reload = fn s, _wb ->
send(self(), {:reloaded, s})
s
end
StyleEditor.change_style_preview_mode(socket, "dark", reload)
assert_received {:reloaded, updated_socket}
assert updated_socket.assigns.style_editor_preview_mode == "dark"
end
test "defaults to auto when nil" do
socket = socket_with_assigns()
reload = fn s, _wb ->
send(self(), {:reloaded, s})
s
end
StyleEditor.change_style_preview_mode(socket, nil, reload)
assert_received {:reloaded, updated_socket}
assert updated_socket.assigns.style_editor_preview_mode == "auto"
end
end
end

View File

@@ -597,6 +597,60 @@ defmodule BDS.MediaTest do
assert contents =~ "caption: \"Bildunterschrift\"\n---"
end
test "delete_media_translation removes the translation row and sidecar file", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media")
assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert {:ok, de_translation} =
BDS.Media.upsert_media_translation(media.id, "de", %{
title: "Titel",
alt: "Alt text",
caption: "Bildunterschrift"
})
assert {:ok, fr_translation} =
BDS.Media.upsert_media_translation(media.id, "fr", %{
title: "Titre",
alt: "Texte alternatif",
caption: "Légende"
})
de_sidecar = Path.join(temp_dir, media.file_path <> ".de.meta")
fr_sidecar = Path.join(temp_dir, media.file_path <> ".fr.meta")
assert File.exists?(de_sidecar)
assert File.exists?(fr_sidecar)
assert {:ok, true} = BDS.Media.delete_media_translation(media.id, "de")
assert Repo.get(BDS.Media.Translation, de_translation.id) == nil
assert Repo.get(BDS.Media.Translation, fr_translation.id) != nil
refute File.exists?(de_sidecar)
assert File.exists?(fr_sidecar)
end
test "delete_media_translation is a no-op for a non-existent translation before deleting known ones",
%{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "untitled.txt")
File.write!(source_path, "hello")
assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert {:ok, false} = BDS.Media.delete_media_translation(media.id, "de")
end
test "delete_media_translation returns {:error, :not_found} for unknown media", %{
temp_dir: _temp_dir
} do
assert {:error, :not_found} = BDS.Media.delete_media_translation("does-not-exist", "de")
end
test "UniqueMediaTranslation: a media has at most one translation per language", %{
project: project,
temp_dir: temp_dir
@@ -642,6 +696,85 @@ defmodule BDS.MediaTest do
assert %{language: ["has already been taken"]} = errors_on(changeset)
end
test "SidecarRoundtrip: writing then parsing a sidecar yields matching DB metadata", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "roundtrip.txt")
File.write!(source_path, "hello media")
assert {:ok, media} =
BDS.Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "Roundtrip Title",
alt: "Roundtrip Alt",
caption: "Roundtrip Caption",
author: "Tester",
language: "en",
tags: ["alpha", "beta"]
})
sidecar_path = Path.join(temp_dir, media.sidecar_path)
{:ok, contents} = File.read(sidecar_path)
{:ok, parsed} = BDS.Sidecar.parse_document(contents)
assert parsed["title"] == media.title
assert parsed["alt"] == media.alt
assert parsed["caption"] == media.caption
assert parsed["tags"] == media.tags
assert parsed["id"] == media.id
assert parsed["originalName"] == media.original_name
assert parsed["mimeType"] == media.mime_type
assert parsed["size"] == media.size
assert parsed["author"] == media.author
assert parsed["language"] == media.language
assert parsed["createdAt"] == media.created_at
assert parsed["updatedAt"] == media.updated_at
end
test "SidecarRoundtrip: conditional fields are absent from parsed sidecar when nil", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "minimal.txt")
File.write!(source_path, "hello media")
assert {:ok, media} =
BDS.Media.import_media(%{
project_id: project.id,
source_path: source_path
})
assert media.title == nil
assert media.alt == nil
assert media.caption == nil
assert media.author == nil
assert media.language == nil
assert media.width == nil
assert media.height == nil
sidecar_path = Path.join(temp_dir, media.sidecar_path)
{:ok, contents} = File.read(sidecar_path)
{:ok, parsed} = BDS.Sidecar.parse_document(contents)
refute Map.has_key?(parsed, "title")
refute Map.has_key?(parsed, "alt")
refute Map.has_key?(parsed, "caption")
refute Map.has_key?(parsed, "author")
refute Map.has_key?(parsed, "language")
refute Map.has_key?(parsed, "width")
refute Map.has_key?(parsed, "height")
assert Map.has_key?(parsed, "id")
assert Map.has_key?(parsed, "originalName")
assert Map.has_key?(parsed, "mimeType")
assert Map.has_key?(parsed, "size")
assert Map.has_key?(parsed, "tags")
assert Map.has_key?(parsed, "createdAt")
assert Map.has_key?(parsed, "updatedAt")
end
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
@@ -694,6 +827,93 @@ defmodule BDS.MediaTest do
|> Image.open!()
end
describe "validate_media (D2-16)" do
test "reports no issues for healthy media with a post link", %{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "sample.png")
File.write!(source_path, sample_image_binary(".png"))
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
{:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Linked", content: "body"})
{:ok, :linked} = BDS.Media.Linking.link_media_to_post(media.id, post.id)
assert [] == BDS.Media.validate_media(project.id)
end
test "reports missing binary file", %{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
media_id = media.id
data_dir = BDS.Projects.project_data_dir(project)
File.rm!(Path.join(data_dir, media.file_path))
issues = BDS.Media.validate_media(project.id)
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "missing_binary"))
end
test "reports missing sidecar", %{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
media_id = media.id
data_dir = BDS.Projects.project_data_dir(project)
File.rm!(Path.join(data_dir, media.sidecar_path))
issues = BDS.Media.validate_media(project.id)
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "missing_sidecar"))
end
test "reports orphan media (not linked to any post)", %{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
media_id = media.id
issues = BDS.Media.validate_media(project.id)
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "orphan"))
end
test "does not report orphan when linked to a post", %{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello")
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
{:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Linked", content: "body"})
{:ok, :linked} = BDS.Media.Linking.link_media_to_post(media.id, post.id)
issues = BDS.Media.validate_media(project.id)
refute Enum.any?(issues, &(&1.issue == "orphan"))
end
end
describe "MediaDetectLanguage rule" do
test "updating language persists to the row and rewrites the sidecar", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "detect-me.txt")
File.write!(source_path, "Bonjour tout le monde")
assert {:ok, media} =
BDS.Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "Entrée",
caption: "Un délice culinaire"
})
assert media.language == nil
assert {:ok, updated_media} = BDS.Media.update_media(media.id, %{language: "fr"})
assert updated_media.language == "fr"
data_dir = BDS.Projects.project_data_dir(project)
sidecar = File.read!(Path.join(data_dir, updated_media.sidecar_path))
assert sidecar =~ "language: fr\n"
end
end
defp assert_images_match!(left, right) do
assert Image.shape(left) == Image.shape(right)

View File

@@ -242,6 +242,106 @@ defmodule BDS.MetadataTest do
assert File.exists?(BDS.Embeddings.index_path(project.id))
end
test "remove_category removes the category and its settings from state, files, and DB", %{
project: project,
temp_dir: temp_dir
} do
# Add a category + settings first
assert {:ok, metadata} = BDS.Metadata.add_category(project.id, "news")
assert "news" in metadata.categories
assert {:ok, _metadata} =
BDS.Metadata.update_category_settings(project.id, "news", %{
render_in_lists: false,
show_title: true,
post_template_slug: "article"
})
# Remove the category
assert {:ok, updated} = BDS.Metadata.remove_category(project.id, "news")
# 1. Category removed from in-memory list
refute "news" in updated.categories
assert "article" in updated.categories
assert "aside" in updated.categories
assert "page" in updated.categories
assert "picture" in updated.categories
# 2. Category settings removed from in-memory map
refute Map.has_key?(updated.category_settings, "news")
# 3. Verify via get_project_metadata
assert {:ok, loaded} = BDS.Metadata.get_project_metadata(project.id)
refute "news" in loaded.categories
refute Map.has_key?(loaded.category_settings, "news")
# 4. meta/categories.json rewritten without the removed category
categories_path = Path.join([temp_dir, "meta", "categories.json"])
assert ["article", "aside", "page", "picture"] =
Jason.decode!(File.read!(categories_path))
# 5. meta/category-meta.json rewritten without the removed category's settings
category_meta_path = Path.join([temp_dir, "meta", "category-meta.json"])
category_meta = Jason.decode!(File.read!(category_meta_path))
refute Map.has_key?(category_meta, "news")
# 6. DB settings updated
cat_setting =
BDS.Repo.get(BDS.Settings.Setting, "project:#{project.id}:categories")
assert cat_setting != nil
refute "news" in (cat_setting.value |> Jason.decode!() |> Map.get("categories", []))
meta_setting =
BDS.Repo.get(BDS.Settings.Setting, "project:#{project.id}:category_meta")
assert meta_setting != nil
meta_categories = meta_setting.value |> Jason.decode!() |> Map.get("categories", %{})
refute Map.has_key?(meta_categories, "news")
end
test "remove_category is a no-op for non-existent category", %{
project: project
} do
{:ok, metadata_before} = BDS.Metadata.get_project_metadata(project.id)
assert {:ok, metadata} = BDS.Metadata.remove_category(project.id, "nonexistent")
assert metadata.categories == metadata_before.categories
end
test "max_posts_per_page 0 is clamped to 1", %{project: project} do
assert {:ok, metadata} =
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: 0})
assert metadata.max_posts_per_page == 1
end
test "max_posts_per_page negative is clamped to 1", %{project: project} do
assert {:ok, metadata} =
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: -5})
assert metadata.max_posts_per_page == 1
end
test "max_posts_per_page above 500 is clamped to 500", %{project: project} do
assert {:ok, metadata} =
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: 1000})
assert metadata.max_posts_per_page == 500
end
test "max_posts_per_page nil defaults to 50", %{project: project} do
assert {:ok, metadata} =
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: nil})
assert metadata.max_posts_per_page == 50
end
test "max_posts_per_page non-numeric string defaults to 50", %{project: project} do
assert {:ok, metadata} =
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: "abc"})
assert metadata.max_posts_per_page == 50
end
test "sync_project_metadata_from_filesystem materializes the canonical metadata files when missing",
%{project: project, temp_dir: temp_dir} do
meta_dir = Path.join(temp_dir, "meta")
@@ -266,4 +366,13 @@ defmodule BDS.MetadataTest do
refute File.exists?(Path.join(meta_dir, "category-meta.json.tmp"))
refute File.exists?(Path.join(meta_dir, "publishing.json.tmp"))
end
test "fresh project has default categories before any operations", %{project: project} do
assert {:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert "article" in metadata.categories
assert "aside" in metadata.categories
assert "page" in metadata.categories
assert "picture" in metadata.categories
assert length(metadata.categories) == 4
end
end

View File

@@ -428,6 +428,30 @@ defmodule BDS.PostTranslationsTest do
defp wait_for_ai_tasks(count, attempts \\ 100)
test "do_not_translate guard prevents translation upsert via the API", %{
project: project
} do
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "DNT Guarded",
content: "Body",
language: "en"
})
assert {:ok, post} = Posts.update_post(post.id, %{do_not_translate: true})
assert post.do_not_translate == true
assert {:error, changeset} =
Posts.upsert_post_translation(post.id, "de", %{
title: "Sollte fehlschlagen",
content: "Inhalt"
})
assert changeset.errors[:do_not_translate] ==
{"cannot add translations when do_not_translate is true", []}
end
defp wait_for_ai_tasks(_count, 0) do
flunk("AI tasks did not reach expected state")
end

View File

@@ -188,6 +188,55 @@ defmodule BDS.PostsTest do
refute File.exists?(full_path <> ".tmp")
end
test "post frontmatter roundtrips: written fields parsed back match the database record", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Full Roundtrip",
excerpt: "Testing full roundtrip",
content: "Body content survives roundtrip",
tags: ["elixir", "testing"],
categories: ["notes"],
author: "Test Author",
language: "de",
template_slug: "article"
})
assert {:ok, post} = BDS.Posts.update_post(post.id, %{do_not_translate: true})
assert {:ok, published} = BDS.Posts.publish_post(post.id)
assert published.status == :published
refute published.content
full_path = Path.join(temp_dir, published.file_path)
assert File.exists?(full_path)
file_contents = File.read!(full_path)
assert {:ok, %{fields: fields, body: body}} = BDS.Frontmatter.parse_document(file_contents)
assert fields["id"] == published.id
assert fields["title"] == "Full Roundtrip"
assert fields["slug"] == "full-roundtrip"
assert fields["excerpt"] == "Testing full roundtrip"
assert fields["status"] == "published"
assert fields["author"] == "Test Author"
assert fields["language"] == "de"
assert fields["doNotTranslate"] == true
assert fields["templateSlug"] == "article"
assert fields["tags"] == ["elixir", "testing"]
assert fields["categories"] == ["notes"]
assert is_integer(fields["createdAt"])
assert fields["createdAt"] == published.created_at
assert is_integer(fields["updatedAt"])
assert fields["updatedAt"] == published.updated_at
assert is_integer(fields["publishedAt"])
assert fields["publishedAt"] == published.published_at
assert body == "Body content survives roundtrip"
end
test "publish_post omits doNotTranslate from frontmatter when false", %{
project: project,
temp_dir: temp_dir
@@ -208,6 +257,31 @@ defmodule BDS.PostsTest do
refute file_contents =~ "doNotTranslate"
end
test "publish_post omits nil excerpt, author, language from frontmatter", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Nil Optional Fields",
content: "body"
})
assert is_nil(post.excerpt)
assert is_nil(post.author)
assert is_nil(post.language)
assert {:ok, published} = BDS.Posts.publish_post(post.id)
full_path = Path.join(temp_dir, published.file_path)
file_contents = File.read!(full_path)
refute file_contents =~ "excerpt:"
refute file_contents =~ "author:"
refute file_contents =~ "language:"
end
test "publish_post deletes old file when file path changes", %{
project: project,
temp_dir: temp_dir
@@ -242,6 +316,45 @@ defmodule BDS.PostsTest do
refute File.exists?(old_full)
end
test "German ß transliterates to ss" do
assert BDS.Slug.slugify("Straße") == "strasse"
end
test "German ö becomes o via NFD decomposition" do
assert BDS.Slug.slugify("Öl") == "ol"
end
test "German ä becomes a via NFD decomposition" do
assert BDS.Slug.slugify("Äpfel") == "apfel"
end
test "German ü becomes u via NFD decomposition" do
assert BDS.Slug.slugify("Über") == "uber"
end
test "mixed German characters transliterate correctly" do
assert BDS.Slug.slugify("ÄÖÜäöüß") == "aouaouss"
end
test "canonical post URL follows the format /YYYY/MM/DD/slug/", %{
project: project
} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Canonical Format",
content: "Body"
})
created = BDS.Persistence.from_unix_ms!(post.created_at)
year = Integer.to_string(created.year)
month = String.pad_leading(Integer.to_string(created.month), 2, "0")
day = String.pad_leading(Integer.to_string(created.day), 2, "0")
canonical = BDS.Rendering.LinksAndLanguages.post_path(post, nil)
assert canonical == "/#{year}/#{month}/#{day}/canonical-format/"
end
test "delete_post removes the database row and published markdown file when present" do
temp_dir =
Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}")

View File

@@ -106,7 +106,7 @@ defmodule BDS.Scripting.ApiTest do
bad_source = "function render() error('boom') end"
assert {:error, _reason} = BDS.Scripting.execute_macro(project.id, bad_source, [])
assert {:ok, ""} = BDS.Scripting.execute_macro(project.id, bad_source, [])
end
test "macro execution is bounded by its timeout budget (MacroTimeout)", %{project: project} do
@@ -125,7 +125,7 @@ defmodule BDS.Scripting.ApiTest do
)
end)
assert {:error, :timeout} = result
assert {:ok, ""} = result
elapsed_ms = div(elapsed_us, 1000)

View File

@@ -34,6 +34,42 @@ defmodule BDS.Scripting.LuaTest do
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
end
test "sandbox blocks os.execute" do
source = "function main() os.execute('echo hacked') end"
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
end
test "sandbox blocks os.rename" do
source = "function main() os.rename('/etc/passwd', '/tmp/hacked') end"
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
end
test "sandbox blocks io.open for writing" do
source = "function main() io.open('/tmp/hacked', 'w') end"
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
end
test "sandbox blocks require" do
source = "function main() require('socket') end"
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
end
test "sandbox blocks dofile" do
source = "function main() dofile('/etc/hosts') end"
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
end
test "sandbox blocks loadlib" do
source = "function main() package.loadlib('libc.so', 'system') end"
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
end
test "enforces reduction limits" do
source = "function main() while true do end end"

View File

@@ -155,6 +155,114 @@ defmodule BDS.ScriptsTest do
assert published.status == :published
end
test "create_and_publish_script creates a published script with file in one step", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, script} =
BDS.Scripts.create_and_publish_script(%{
project_id: project.id,
title: "Published Utility",
kind: :utility,
content: "function main() return 'ok' end"
})
assert script.status == :published
assert script.content == nil
assert script.slug == "published-utility"
assert script.enabled == true
assert script.version == 1
assert script.entrypoint == "main"
assert script.file_path == "scripts/published-utility.lua"
full_path = Path.join(temp_dir, script.file_path)
assert File.exists?(full_path)
contents = File.read!(full_path)
assert contents =~ "---\nid: #{script.id}\n"
assert contents =~ "projectId: #{project.id}\n"
assert contents =~ "slug: published-utility\n"
assert contents =~ "title: Published Utility\n"
assert contents =~ "kind: utility\n"
assert contents =~ "entrypoint: main\n"
assert contents =~ "enabled: true\n"
assert contents =~ "version: 1\n"
assert contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
assert contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
assert contents =~ "\n---\nfunction main() return 'ok' end\n"
end
test "create_and_publish_script uses macro default entrypoint", %{
project: project
} do
assert {:ok, script} =
BDS.Scripts.create_and_publish_script(%{
project_id: project.id,
title: "Render Helper",
kind: :macro,
content: "function render() end"
})
assert script.entrypoint == "render"
assert script.slug == "render-helper"
end
test "create_and_publish_script rejects invalid Lua syntax", %{project: project} do
assert {:error, {:invalid_script, _reason}} =
BDS.Scripts.create_and_publish_script(%{
project_id: project.id,
title: "Bad Script",
kind: :utility,
content: "function main( missing end"
})
end
test "create_and_publish_script deduplicates slug on title conflict", %{project: project} do
assert {:ok, _first} =
BDS.Scripts.create_and_publish_script(%{
project_id: project.id,
title: "Same Title",
kind: :utility,
content: "function main() return 'v1' end"
})
assert {:ok, second} =
BDS.Scripts.create_and_publish_script(%{
project_id: project.id,
title: "Same Title",
kind: :utility,
content: "function main() return 'v2' end"
})
assert second.slug == "same-title-2"
assert second.status == :published
end
test "create_script deduplicates slug on title conflict (UniqueScriptSlug)", %{
project: project
} do
assert {:ok, first} =
BDS.Scripts.create_script(%{
project_id: project.id,
title: "Dup Slug",
kind: :utility,
content: "function main() return 'first' end"
})
assert first.slug == "dup-slug"
assert {:ok, second} =
BDS.Scripts.create_script(%{
project_id: project.id,
title: "Dup Slug",
kind: :utility,
content: "function main() return 'second' end"
})
assert second.slug == "dup-slug-2"
refute second.id == first.id
end
test "rebuild_scripts_from_files recreates published scripts from disk", %{
project: project,
temp_dir: temp_dir

View File

@@ -552,4 +552,53 @@ defmodule BDS.SearchTest do
assert "hi" in languages
assert Enum.uniq(languages) == languages
end
test "search_posts finds translation text in multiple languages after reindex", %{
project: project
} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Multi Lang",
content: "root body",
language: "en"
})
now = System.system_time(:second)
languages = [{"de", "Hallo Welt"}, {"fr", "Bonjour le monde"}, {"es", "Hola mundo"}, {"it", "Ciao mondo"}]
for {lang, content} <- languages do
Repo.query!(
"""
INSERT INTO post_translations (
id, project_id, translation_for, language, title, excerpt, content, status,
created_at, updated_at, published_at, file_path, checksum
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
Ecto.UUID.generate(),
project.id,
post.id,
lang,
"Title #{lang}",
"Summary",
content,
"draft",
now,
now,
nil,
"",
nil
]
)
end
assert :ok = BDS.Search.reindex_project(project.id)
for {_lang, content} <- languages do
search_term = String.split(content) |> List.last()
assert {:ok, results} = BDS.Search.search_posts(project.id, search_term, %{})
assert Enum.map(results.posts, & &1.id) == [post.id]
end
end
end

View File

@@ -95,6 +95,34 @@ defmodule BDS.TasksTest do
assert wait_for_task(third.id, &(&1.status == :completed)).status == :completed
end
test "progress reports within 250ms throttle window are silently dropped" do
assert {:ok, task} = BDS.Tasks.register_external_task("fast progress")
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "half")
assert task_id = task.id
# The 250ms throttle has not elapsed, so progress stays at 0.25.
assert wait_for_task(task_id, & &1.progress == 0.25).progress == 0.25
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
end
test "progress report with value 1.0 bypasses the throttle" do
assert {:ok, task} = BDS.Tasks.register_external_task("completion progress")
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
# A completion report (1.0) must go through even if throttled.
assert :ok = BDS.Tasks.report_progress(task.id, 1.0, "done")
assert wait_for_task(task.id, &(&1.progress == 1.0)).progress == 1.0
assert wait_for_task(task.id, &(&1.message == "done")).message == "done"
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
end
test "external tasks are registered as running and can report progress and complete" do
assert {:ok, task} =
BDS.Tasks.register_external_task("preview build", %{

View File

@@ -488,6 +488,108 @@ defmodule BDS.TemplatesTest do
assert template.updated_at == 202
end
test "create_and_publish_template creates a published template with file in one step", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, template} =
BDS.Templates.create_and_publish_template(%{
project_id: project.id,
title: "Published Layout",
kind: :list,
content: "<section>{{ content }}</section>"
})
assert template.status == :published
assert template.content == nil
assert template.slug == "published-layout"
assert template.enabled == true
assert template.version == 1
assert template.file_path == "templates/published-layout.liquid"
full_path = Path.join(temp_dir, template.file_path)
assert File.exists?(full_path)
contents = File.read!(full_path)
assert contents =~ "---\nid: #{template.id}\n"
assert contents =~ "projectId: #{project.id}\n"
assert contents =~ "slug: published-layout\n"
assert contents =~ "title: Published Layout\n"
assert contents =~ "kind: list\n"
assert contents =~ "enabled: true\n"
assert contents =~ "version: 1\n"
assert contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
assert contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
assert contents =~ "\n---\n<section>{{ content }}</section>\n"
end
test "create_and_publish_template rejects invalid Liquid syntax", %{project: project} do
assert {:error, {:invalid_liquid, _reason}} =
BDS.Templates.create_and_publish_template(%{
project_id: project.id,
title: "Bad Layout",
kind: :list,
content: "{% for item in items %}unclosed"
})
end
test "create_and_publish_template deduplicates slug on title conflict", %{project: project} do
assert {:ok, _first} =
BDS.Templates.create_and_publish_template(%{
project_id: project.id,
title: "Same Title",
kind: :list,
content: "<section>v1</section>"
})
assert {:ok, second} =
BDS.Templates.create_and_publish_template(%{
project_id: project.id,
title: "Same Title",
kind: :list,
content: "<section>v2</section>"
})
assert second.slug == "same-title-2"
assert second.status == :published
end
test "template frontmatter roundtrips: written fields parsed back match the database record", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Roundtrip Layout",
kind: :list,
content: "<section>{{ page_title }}</section>",
enabled: true
})
assert {:ok, published} = BDS.Templates.publish_template(template.id)
full_path = Path.join(temp_dir, published.file_path)
assert File.exists?(full_path)
contents = File.read!(full_path)
assert {:ok, %{fields: fields, body: body}} = BDS.Frontmatter.parse_document(contents)
assert fields["id"] == published.id
assert fields["projectId"] == project.id
assert fields["slug"] == "roundtrip-layout"
assert fields["title"] == "Roundtrip Layout"
assert fields["kind"] == "list"
assert fields["enabled"] == true
assert fields["version"] == 1
assert is_integer(fields["createdAt"])
assert fields["createdAt"] == published.created_at
assert is_integer(fields["updatedAt"])
assert fields["updatedAt"] == published.updated_at
assert body == "<section>{{ page_title }}</section>"
end
test "rebuild_templates_from_files removes stale published default templates when no local template files exist",
%{
project: project