diff --git a/SPECGAPS.md b/SPECGAPS.md index bd346c2..ee2190a 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -177,7 +177,7 @@ All reconciled to follow code. Specs must be self-consistent and match code. | ID | Spec | Covered | Not Covered | |---|---|---|---| | ~~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, MCP agents, style/theme, search filter, categories CRUD | +| ~~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 | | 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 | @@ -198,4 +198,5 @@ All reconciled to follow code. Specs must be self-consistent and match code. 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-7** — UI test coverage +9. **D4-1 through D4-2** — ~~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 through D4-7** — remaining UI test coverage diff --git a/test/bds/desktop/managed_categories_test.exs b/test/bds/desktop/managed_categories_test.exs index a8d1761..e1d7db6 100644 --- a/test/bds/desktop/managed_categories_test.exs +++ b/test/bds/desktop/managed_categories_test.exs @@ -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) diff --git a/test/bds/desktop/mcp_config_test.exs b/test/bds/desktop/mcp_config_test.exs new file mode 100644 index 0000000..caf11d1 --- /dev/null +++ b/test/bds/desktop/mcp_config_test.exs @@ -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 diff --git a/test/bds/desktop/settings_search_test.exs b/test/bds/desktop/settings_search_test.exs new file mode 100644 index 0000000..52da432 --- /dev/null +++ b/test/bds/desktop/settings_search_test.exs @@ -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 diff --git a/test/bds/desktop/style_editor_test.exs b/test/bds/desktop/style_editor_test.exs new file mode 100644 index 0000000..f632657 --- /dev/null +++ b/test/bds/desktop/style_editor_test.exs @@ -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