feat: added a gallery quick action and fleshed out builtin macros

This commit is contained in:
2026-05-28 17:19:49 +02:00
parent 1914b05f39
commit f99e139fa5
31 changed files with 5907 additions and 4316 deletions

View File

@@ -3998,7 +3998,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='chat-send-button']")
|> render_click()
send(view.pid, {
send_and_await(view, {
:chat_tool_call,
conversation.id,
%{
@@ -4245,14 +4245,14 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(class="chat-input chat-surface-input ui-textarea")
css = desktop_css_source()
assert css =~ "--chat-input-line-height: 20px;"
assert css =~ "--chat-input-min-height: 20px;"
assert css =~ "--chat-input-line-height: 22px;"
assert css =~ "--chat-input-min-height: 24px;"
assert css =~ ".chat-panel .chat-input-container"
assert css =~ "padding: 8px 16px;"
assert css =~ "padding: 12px 16px;"
assert css =~ "padding: 6px 8px;"
assert css =~ ".chat-panel .chat-input-wrapper"
assert css =~ "min-height: 30px;"
assert css =~ "padding: 4px 6px;"
assert css =~ "min-height: 40px;"
assert css =~ "padding: 6px 8px;"
assert css =~ ".chat-panel .chat-input"
assert css =~ "box-sizing: border-box;"
assert css =~ "margin: 0;"
@@ -4565,7 +4565,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert assistant_index < user_index
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input ui-textarea"[^>]*disabled/
send(view.pid, {
send_and_await(view, {
:chat_tool_call,
conversation.id,
%{
@@ -4629,11 +4629,13 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Newest question"})
_html =
html =
view
|> element("[data-testid='chat-send-button']")
|> render_click()
assert html =~ ~s(data-testid="chat-streaming-thinking")
assert Enum.count(AI.list_chat_messages(conversation.id), fn message ->
message.role == :user and message.content == "Newest question"
end) == 1
@@ -4660,7 +4662,9 @@ defmodule BDS.Desktop.ShellLiveTest do
})
)
send(view.pid, {
_ensure_sync = render(view)
send_and_await(view, {
:chat_tool_call,
conversation.id,
%{
@@ -4931,6 +4935,20 @@ defmodule BDS.Desktop.ShellLiveTest do
run_git!(project_dir, ["commit", "-m", message])
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
ref = make_ref()
send(view.pid, message)
send(view.pid, {:test_ping, self(), ref})
receive do
{:test_pong, ^ref} -> :ok
after
5000 -> raise "LiveView did not process messages within 5s"
end
end
defp run_git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)

View File

@@ -0,0 +1,252 @@
defmodule BDS.GalleryPipelineTest do
use ExUnit.Case, async: false
alias BDS.{Repo, Media}
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-gallery-test-#{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: "Gallery Test", data_path: temp_dir})
{:ok, _project} = BDS.Projects.set_active_project(project.id)
{:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
blog_languages: ["de", "fr"],
main_language: "en"
})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Gallery Post",
content: "Content"
})
%{project: project, post: post, temp_dir: temp_dir}
end
describe "GalleryImport.start/6 concurrency" do
test "processes all paths with a sliding window", %{project: project, post: post} do
parent = self()
concurrency = 2
paths =
Enum.map(1..5, fn i ->
path = Path.join(project.data_path, "image_#{i}.txt")
File.write!(path, "content #{i}")
path
end)
ref = make_ref()
send(parent, {:test_gallery_start, ref, paths, project.id, post.id, concurrency})
end
end
# ── gallery_count with [[gallery]] ─────────────────────────────────────────
describe "gallery_count" do
test "counts [[gallery]] macro as gallery content" do
form = %{"content" => "Some text\n\n[[gallery]]\n\nMore text"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
test "counts inline images as before" do
form = %{"content" => "![Alt](image.jpg)"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
test "counts both [[gallery]] and inline images" do
form = %{"content" => "![Alt](image.jpg)\n[[gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
test "returns 0 when no gallery marks present" do
form = %{"content" => "Just plain text"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0
end
test "handles empty content" do
form = %{}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0
end
test "counts multiple [[gallery]] occurrences" do
form = %{"content" => "[[gallery]] some text [[gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 2
end
test "is case insensitive for [[gallery]]" do
form = %{"content" => "[[Gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
end
# ── Rendering macro smoke tests ────────────────────────────────────────────
describe "rendering macros" do
test "[[gallery]] renders without linked media", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[gallery]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "gallery-empty"
assert result =~ "macro-gallery"
end
test "[[gallery]] renders linked media when present", %{project: project, post: post} do
source = Path.join(project.data_path, "gallery_test.jpg")
File.write!(source, "fake jpeg")
{:ok, media} =
Media.import_media(%{project_id: project.id, source_path: source})
{:ok, _updated} =
Media.update_media(media.id, %{
title: "Test Image",
alt: "Alt text",
caption: "Caption"
})
{:ok, _link} = Media.link_media_to_post(media.id, post.id)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[gallery]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "macro-gallery"
assert result =~ "gallery-item"
end
test "[[tag_cloud]] renders without tags", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[tag_cloud]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "tag-cloud-empty"
end
test "[[photo_archive]] renders without media", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[photo_archive]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "photo-archive-empty"
end
test "[[photo_archive year=2024]] renders with date filter", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[photo_archive year=2024]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "photo-archive"
end
test "[[youtube id=abc123]] still works", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[youtube id=abc123]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "youtube"
assert result =~ "abc123"
end
test "[[vimeo id=456789]] still works", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[vimeo id=456789]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "vimeo"
assert result =~ "456789"
end
end
end

View File

@@ -0,0 +1,216 @@
defmodule BDS.ImageImportPipelineTest do
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.Desktop.{FilePicker, Overlay}
alias BDS.{Metadata, AI, Repo}
@endpoint BDS.Desktop.Endpoint
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-image-import-#{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: "Image Import Test", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
# ── FilePicker multi-select parsing ────────────────────────────────────────
describe "FilePicker.choose_files/2" do
test "single selection returns a single-item list" do
# Simulate what osascript returns for regular choose file
result = FilePicker.parse_choose_files_result("/Users/test/image.png", false)
assert result == {:ok, "/Users/test/image.png"}
end
test "multi selection parses newline-separated paths" do
result =
FilePicker.parse_choose_files_result(
"/Users/test/photo1.jpg\n/Users/test/photo2.png\n/Users/test/photo3.heic",
true
)
assert result ==
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
end
test "multi selection filters out empty lines" do
result =
FilePicker.parse_choose_files_result(
"/Users/test/photo1.jpg\n\n/Users/test/photo2.png\n \n/Users/test/photo3.heic\n",
true
)
assert result ==
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
end
test "multi selection with single file returns list with one element" do
result = FilePicker.parse_choose_files_result("/Users/test/photo1.jpg", true)
assert result == {:ok, ["/Users/test/photo1.jpg"]}
end
end
# ── Metadata image_import_concurrency ───────────────────────────────────────
describe "Metadata image_import_concurrency" do
test "defaults to 4 for new projects", %{project: project} do
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 4
end
test "clamps to minimum 1", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 0})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 1
end
test "clamps to maximum 8", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 100})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 8
end
test "persists and reads correctly", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 3})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 3
end
test "handles string input", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: "5"})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 5
end
test "handles nil as default", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: nil})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 4
end
test "reflects in form as string", %{project: project} do
{:ok, metadata} = Metadata.get_project_metadata(project.id)
form =
BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings.project_form(metadata)
assert form["image_import_concurrency"] == "4"
end
end
# ── Overlay struct post_id ────────────────────────────────────────────────
describe "Overlay insert_media" do
test "open(:post, :insert_media, context) includes post_id" do
context = %{
current_tab: %{
type: :post,
id: "post-uuid-123",
title: "Test Post",
subtitle: "draft"
},
media: [],
insert_media_title: "Insert Media"
}
overlay = Overlay.open(:post, :insert_media, context)
assert overlay.post_id == "post-uuid-123"
end
test "post_id is nil when opened from non-post context" do
context = %{
current_tab: %{
type: :media,
id: "media-uuid-456",
title: "Test Media",
subtitle: "image/png"
},
media: [],
insert_media_title: "Insert Media"
}
overlay = Overlay.open(:media, :insert_media, context)
assert overlay == nil
end
test "set_search_query preserves post_id" do
overlay = %{
kind: :insert_media,
title: "Insert Media",
search_query: "",
results: [],
all_media: [],
post_id: "post-uuid-789"
}
result = Overlay.set_search_query(overlay, "search term")
assert result.post_id == "post-uuid-789"
end
end
# ── Airplane mode gating via shell_live event ─────────────────────────────
describe "overlay_add_images airplane mode gating" do
setup do
prev_auto = System.get_env("BDS_DESKTOP_AUTOMATION")
System.put_env("BDS_DESKTOP_AUTOMATION", "1")
on_exit(fn ->
if prev_auto,
do: System.put_env("BDS_DESKTOP_AUTOMATION", prev_auto),
else: System.delete_env("BDS_DESKTOP_AUTOMATION")
end)
end
test "shows toast in airplane mode when Add Gallery Images is clicked", %{project: project} do
{:ok, _project} = BDS.Projects.set_active_project(project.id)
assert :ok = AI.set_airplane_mode(true)
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Gallery Post",
content: "Content"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
html =
view
|> element("[phx-click='add_gallery_images']")
|> render_click()
assert html =~ "Automatic AI actions stay gated by airplane mode"
assert :ok = AI.set_airplane_mode(false)
end
end
end

View File

@@ -138,6 +138,7 @@ defmodule BDS.Repo.SchemaMigrationTest do
"title",
"model",
"copilot_session_id",
"surface_state",
"created_at",
"updated_at"
],

View File

@@ -0,0 +1,86 @@
defmodule BDS.TranslationCompletenessTest do
use ExUnit.Case, async: true
@translated_locales ~w(de fr it es)
@doc """
Counts empty msgstr entries per non-English locale .po file and ensures
the count never increases. When a new msgid is added to the codebase its
translations MUST be provided for all supported locales; an empty msgstr
in de/fr/it/es causes this test to fail.
English files are excluded — gettext treats empty msgstr as "return msgid
unchanged" for the source language, which is the intended fallback.
The expected counts below represent current untranslated legacy entries
that must decrease over time, never increase. When the count decreases
because translations were added, update the expected count in this test.
"""
test "empty msgstr counts do not increase in non-English locale .po files" do
expected = %{
"de/default.po" => 0,
"de/render.po" => 0,
"de/ui.po" => 153,
"fr/default.po" => 0,
"fr/render.po" => 0,
"fr/ui.po" => 153,
"it/default.po" => 0,
"it/render.po" => 0,
"it/ui.po" => 153,
"es/default.po" => 0,
"es/render.po" => 0,
"es/ui.po" => 153
}
actual =
for locale <- @translated_locales,
domain <- ~w(ui default render) do
file = "priv/gettext/#{locale}/LC_MESSAGES/#{domain}.po"
if File.exists?(file) do
count = empty_msgstr_count(file)
key = "#{locale}/#{domain}.po"
{key, count}
end
end
|> Enum.reject(&is_nil/1)
|> Map.new()
for {file, expected_count} <- expected do
actual_count = Map.get(actual, file, 0)
assert actual_count <= expected_count,
"#{file}: empty msgstr count increased from #{expected_count} to #{actual_count}. " <>
"All new msgid entries MUST have translations for every supported locale (de, fr, it, es). " <>
"When the count decreases because translations were added, update the expected count in this test."
end
end
defp empty_msgstr_count(file) do
file
|> File.read!()
|> String.split("\n")
|> Enum.reduce({nil, 0}, fn line, {current_msgid, count} ->
trimmed = String.trim(line)
case {trimmed, current_msgid} do
{"msgstr \"\"", nil} ->
{nil, count}
{"msgstr \"\"", msgid} ->
if msgid == "" do
{nil, count}
else
{nil, count + 1}
end
{"msgid \"" <> rest, _} ->
{String.trim_trailing(rest, "\""), count}
_ ->
{current_msgid, count}
end
end)
|> elem(1)
end
end