fix: issue #7 with broken macro renderings for photo archive and tag cloud

This commit is contained in:
2026-07-05 14:11:12 +02:00
parent 28398b17f4
commit 8e60fe31a5
13 changed files with 503 additions and 41 deletions

View File

@@ -0,0 +1,57 @@
defmodule BDS.Desktop.ShellLive.GalleryImportTest do
@moduledoc """
Guards the regression where gallery images were imported but never linked to
the post when AI analysis was unavailable (offline / no local model), leaving
galleries empty. Linking must happen regardless of AI, matching the old app.
"""
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.Desktop.ShellLive.GalleryImport
alias BDS.Posts.PostMedia
alias BDS.Repo
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
temp_dir = Path.join(System.tmp_dir!(), "bds-galimport-#{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: "GalImport", data_path: temp_dir})
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Gallery Post",
content: "[[gallery]]",
language: "en"
})
%{project: project, post: post, temp_dir: temp_dir}
end
test "imports link every image to the post even without AI analysis", %{
project: project,
post: post,
temp_dir: temp_dir
} do
paths =
for i <- 1..3 do
path = Path.join(temp_dir, "img_#{i}.jpg")
File.write!(path, "fake jpeg #{i}")
path
end
GalleryImport.start(paths, project.id, post.id, "en", 2, self())
assert_received {:add_images_complete, 3}
link_count =
Repo.aggregate(from(pm in PostMedia, where: pm.post_id == ^post.id), :count)
assert link_count == 3
end
end

View File

@@ -0,0 +1,72 @@
defmodule BDS.Media.LinkRestoreTest do
@moduledoc """
Guards the regression where gallery associations (PostMedia links) were lost
on rebuild-from-filesystem: the media sidecar persists `linkedPostIds`, but
the rebuild never restored them, so galleries rendered empty afterwards.
"""
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.Media
alias BDS.Posts.PostMedia
alias BDS.Repo
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
temp_dir = Path.join(System.tmp_dir!(), "bds-linkrestore-#{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: "LinkRestore", data_path: temp_dir})
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Gallery Post",
content: "[[gallery]]",
language: "en"
})
source = Path.join(temp_dir, "linked.jpg")
File.write!(source, "fake jpeg")
{:ok, media} = Media.import_media(%{project_id: project.id, source_path: source})
{:ok, _} = Media.link_media_to_post(media.id, post.id)
%{project: project, post: post, media: media}
end
defp link_count(post_id, media_id) do
Repo.aggregate(
from(pm in PostMedia, where: pm.post_id == ^post_id and pm.media_id == ^media_id),
:count
)
end
test "media rebuild-from-filesystem restores PostMedia links from the sidecar", %{
project: project,
post: post,
media: media
} do
assert link_count(post.id, media.id) == 1
# Simulate the DB losing gallery links (e.g. a prior rebuild).
Repo.delete_all(from pm in PostMedia, where: pm.media_id == ^media.id)
assert link_count(post.id, media.id) == 0
assert {:ok, _} = BDS.Media.rebuild_media_from_files(project.id)
assert link_count(post.id, media.id) == 1
end
test "restore is idempotent and does not duplicate existing links", %{
project: project,
post: post,
media: media
} do
assert {:ok, _} = BDS.Media.rebuild_media_from_files(project.id)
assert link_count(post.id, media.id) == 1
end
end

View File

@@ -0,0 +1,176 @@
defmodule BDS.Rendering.MacroRenderingTest do
@moduledoc """
End-to-end coverage for built-in macros through the *real* render pipeline
(`RenderContext.build/1`). These guard the regression where photo archive and
tag cloud rendered empty because the project id was never threaded into the
macro context, and where galleries rendered empty on list pages because the
post id was dropped.
"""
use ExUnit.Case, async: false
alias BDS.Media
alias BDS.Rendering
alias BDS.Rendering.PostRendering
alias BDS.Rendering.RenderContext
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir = Path.join(System.tmp_dir!(), "bds-macros-#{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: "MacroBlog", data_path: temp_dir})
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Macro Post",
content: "seed",
language: "en",
tags: ["elixir", "phoenix"]
})
source = Path.join(temp_dir, "macro_image.jpg")
File.write!(source, "fake jpeg")
{:ok, media} = Media.import_media(%{project_id: project.id, source_path: source})
{:ok, _} = Media.update_media(media.id, %{title: "Macro Image", alt: "Macro Alt"})
{:ok, _} = Media.link_media_to_post(media.id, post.id)
{:ok, _} = BDS.Posts.publish_post(post.id)
%{project: project, post: post}
end
test "photo archive and tag cloud resolve project data on single-post render", %{
project: project,
post: post
} do
ctx = RenderContext.build(project.id)
rendered =
PostRendering.cached_post_content(
ctx,
"[[photo_archive]]\n\n[[tag_cloud]]",
"en",
post.id
)
assert rendered =~ "photo-archive-item"
refute rendered =~ "photo-archive-empty"
assert rendered =~ "data-tag-cloud-words"
refute rendered =~ "tag-cloud-empty"
# The JSON is embedded in a double-quoted HTML attribute, so it must be
# HTML-escaped or the client-side JSON.parse silently fails (empty cloud).
assert rendered =~ "&quot;"
words = extract_tag_cloud_words(rendered)
texts = Enum.map(words, &Map.get(&1, "text"))
assert "elixir" in texts
assert "phoenix" in texts
# Matches the old app's word shape so the d3 runtime can size/link tags.
for word <- words do
assert is_integer(word["count"])
assert word["size"] >= 14 and word["size"] <= 56
assert String.starts_with?(word["url"], "/tag/")
end
end
defp extract_tag_cloud_words(html) do
[_, raw] = Regex.run(~r/data-tag-cloud-words="([^"]*)"/, html)
raw
|> String.replace("&quot;", "\"")
|> String.replace("&amp;", "&")
|> String.replace("&lt;", "<")
|> String.replace("&gt;", ">")
|> String.replace("&#39;", "'")
|> Jason.decode!()
end
test "tag cloud orientation aliases normalize like the old app", %{
project: project,
post: post
} do
ctx = RenderContext.build(project.id)
render = fn source -> PostRendering.cached_post_content(ctx, source, "en", post.id) end
assert render.("[[tag_cloud orientation=diagonal]]") =~ ~s(data-orientation="mixed-diagonal")
assert render.("[[tag_cloud orientation=diag]]") =~ ~s(data-orientation="mixed-diagonal")
assert render.("[[tag_cloud orientation=mixed-diagonal]]") =~
~s(data-orientation="mixed-diagonal")
assert render.("[[tag_cloud orientation=hv]]") =~ ~s(data-orientation="mixed-hv")
assert render.("[[tag_cloud orientation=mixed-hv]]") =~ ~s(data-orientation="mixed-hv")
assert render.("[[tag_cloud]]") =~ ~s(data-orientation="horizontal")
assert render.("[[tag_cloud orientation=bogus]]") =~ ~s(data-orientation="horizontal")
end
test "tag cloud clamps out-of-range width/height to defaults", %{project: project, post: post} do
ctx = RenderContext.build(project.id)
rendered =
PostRendering.cached_post_content(ctx, "[[tag_cloud width=99999 height=1]]", "en", post.id)
assert rendered =~ ~s(data-width="900")
assert rendered =~ ~s(data-height="420")
end
test "gallery resolves linked media on single-post render", %{project: project, post: post} do
ctx = RenderContext.build(project.id)
rendered = PostRendering.cached_post_content(ctx, "[[gallery]]", "en", post.id)
assert rendered =~ "gallery-item"
assert rendered =~ ~s(src="/media/)
assert rendered =~ "Macro Image"
refute rendered =~ "gallery-empty"
end
test "gallery resolves linked media on list-page render", %{project: project, post: post} do
posts = [
%{
id: post.id,
slug: post.slug,
title: post.title,
content: "[[gallery]]",
language: "en",
created_at: post.created_at,
updated_at: post.updated_at,
published_at: post.published_at,
tags: post.tags,
categories: post.categories,
href: "/2024/01/01/macro-post/"
}
]
assert {:ok, rendered} =
Rendering.render_list_page(project.id, %{
language: "en",
page_title: "Archive",
posts: posts,
archive_context: %{kind: "core"},
pagination: %{
current_page: 1,
total_pages: 1,
total_items: 1,
items_per_page: 10,
has_prev_page: false,
prev_page_href: nil,
has_next_page: false,
next_page_href: nil
}
})
assert rendered =~ "gallery-item"
assert rendered =~ ~s(src="/media/)
refute rendered =~ "gallery-empty"
end
end