fix: issue #7 with broken macro renderings for photo archive and tag cloud
This commit is contained in:
57
test/bds/desktop/shell_live/gallery_import_test.exs
Normal file
57
test/bds/desktop/shell_live/gallery_import_test.exs
Normal 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
|
||||
72
test/bds/media/link_restore_test.exs
Normal file
72
test/bds/media/link_restore_test.exs
Normal 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
|
||||
176
test/bds/rendering/macro_rendering_test.exs
Normal file
176
test/bds/rendering/macro_rendering_test.exs
Normal 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 =~ """
|
||||
|
||||
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(""", "\"")
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("'", "'")
|
||||
|> 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
|
||||
Reference in New Issue
Block a user