defmodule BDS.TUITest do use ExUnit.Case, async: false import Ecto.Query alias ExRatatui.CellSession alias ExRatatui.Event.Key defp post_count(project_id) do BDS.Repo.aggregate(from(p in BDS.Posts.Post, where: p.project_id == ^project_id), :count) end setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual) end) temp_dir = Path.join(System.tmp_dir!(), "bds-tui-#{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: "TUI", data_path: temp_dir}) {:ok, _} = BDS.Projects.set_active_project(project.id) {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Hello TUI", content: "terminal body", language: "en" }) %{project: project, post: post} end defp mount!(opts \\ []) do {:ok, state} = BDS.TUI.mount(opts) state end defp screen_text(state, width \\ 100, height \\ 32) do session = CellSession.new(width, height) frame = %ExRatatui.Frame{width: width, height: height} :ok = CellSession.draw(session, BDS.TUI.render(state, frame)) snapshot = CellSession.take_cells(session) :ok = CellSession.close(session) snapshot.cells |> Enum.group_by(& &1.row) |> Enum.sort_by(fn {row, _} -> row end) |> Enum.map_join("\n", fn {_row, cells} -> cells |> Enum.sort_by(& &1.col) |> Enum.map_join("", & &1.symbol) end) end defp key(code, modifiers \\ []), do: %Key{code: code, kind: "press", modifiers: modifiers} defp press(state, code, modifiers \\ []) do {:noreply, state} = BDS.TUI.handle_event(key(code, modifiers), state) state end test "mounts against the active project and lists posts", %{post: post} do state = mount!() assert state.project_id == post.project_id text = screen_text(state) assert text =~ "Hello TUI" end test "navigates and opens a post in the editor", %{post: post} do state = mount!() |> press("enter") assert state.focus == :editor assert state.editor.post.id == post.id assert ExRatatui.textarea_get_value(state.editor.textarea) == "terminal body" assert screen_text(state) =~ "terminal body" end test "ctrl+s persists textarea edits", %{post: post} do state = mount!() |> press("enter") :ok = ExRatatui.textarea_insert_str(state.editor.textarea, "more words ") state = press(state, "s", ["ctrl"]) assert state.editor.dirty == false assert BDS.Posts.get_post(post.id).content =~ "more words" end test "ctrl+p publishes the open post", %{post: post} do state = mount!() |> press("enter") |> press("p", ["ctrl"]) published = BDS.Posts.get_post(post.id) assert published.status == :published assert state.editor.post.status == :published end test "n creates a new draft post", %{project: project} do count_before = post_count(project.id) state = mount!() |> press("n") assert post_count(project.id) == count_before + 1 assert state.focus == :editor assert state.editor.post.status == :draft end test "esc returns focus to the sidebar" do state = mount!() |> press("enter") |> press("esc") assert state.focus == :sidebar end test "ctrl+e toggles a word-wrapped preview of the draft", %{post: post} do long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD" {:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line}) state = mount!() |> press("enter") |> press("e", ["ctrl"]) assert state.editor.preview? # The textarea cannot soft-wrap, so the tail of a long line is off-screen # there; the preview renders through the Markdown widget, which wraps. assert screen_text(state, 80, 32) =~ "FINALWORD" state = press(state, "e", ["ctrl"]) refute state.editor.preview? end test "keys in preview mode do not edit the content" do state = mount!() |> press("enter") |> press("e", ["ctrl"]) before = ExRatatui.textarea_get_value(state.editor.textarea) state = press(state, "x") assert ExRatatui.textarea_get_value(state.editor.textarea) == before refute state.editor.dirty end test "esc in preview returns to editing, not to the sidebar" do state = mount!() |> press("enter") |> press("e", ["ctrl"]) |> press("esc") refute state.editor.preview? assert state.focus == :editor end test "entity_changed refreshes the sidebar", %{project: project} do state = mount!() {:ok, other} = BDS.Posts.create_post(%{project_id: project.id, title: "From Pipeline", content: "x"}) {:noreply, state} = BDS.TUI.handle_info( {:entity_changed, %{entity: "post", entity_id: other.id, action: :created}}, state ) assert screen_text(state) =~ "From Pipeline" end test "view switching shows media view" do state = mount!() |> press("2") assert state.view == "media" end test "quit key stops the app" do assert {:stop, _state} = BDS.TUI.handle_event(key("q", ["ctrl"]), mount!()) end describe "command prompt" do defp mount_with_executor! do parent = self() mount!( command_executor: fn action, params -> send(parent, {:executed, action, params}) {:ok, %{kind: "output", title: "T", message: "done"}} end ) end test "':' opens the prompt and lists commands" do state = mount_with_executor!() |> press(":") assert state.command != nil text = screen_text(state) assert text =~ "metadata_diff" assert text =~ "validate_site" end test "the palette clears the background underneath" do state = mount_with_executor!() assert screen_text(state) =~ "Select an entry" state = press(state, ":") refute screen_text(state) =~ "Select an entry" end test "'?' alone does nothing; ':?' opens the command help with GUI markers" do state = mount_with_executor!() |> press("?") assert state.command == nil state = state |> press(":") |> press("?") assert state.command.help? text = screen_text(state) assert text =~ "metadata_diff" # Commands whose report/apply UI lives in the GUI are marked. assert text =~ "GUI" state = press(state, "esc") assert state.command == nil end test "typing filters and enter runs the selected command" do state = mount_with_executor!() |> press(":") |> press("m") |> press("e") |> press("t") |> press("a") |> press("enter") assert_receive {:executed, "metadata_diff", %{}} assert state.command == nil assert state.status =~ "done" end test "up/down move the selection" do state = mount_with_executor!() |> press(":") |> press("down") |> press("enter") assert_receive {:executed, action, %{}} assert is_binary(action) end test "no match reports an unknown command" do state = mount_with_executor!() |> press(":") |> press("z") |> press("z") |> press("enter") refute_receive {:executed, _action, _params}, 50 assert state.command == nil assert state.status != nil end test "a queued task starts status polling and finishes with a toast" do parent = self() state = mount!( command_executor: fn action, _params -> send(parent, {:executed, action}) {:ok, %{kind: "task_queued", title: "Rebuild", message: "queued"}} end ) |> press(":") |> press("enter") assert_receive {:executed, _action} assert state.task_polling # No tasks are actually running in this test, so the first poll ends it. {:noreply, state} = BDS.TUI.handle_info(:poll_tasks, state) refute state.task_polling assert state.status != nil end end describe "report panels" do defp diff_snapshot do %{ running_task_message: nil, tasks: [ %{ id: "task-diff", status: :completed, result: %{ kind: "open_editor", route: "metadata_diff", title: "Metadata Diff", payload: %{ summary: %{diff_count: 1, orphan_count: 1}, diff_reports: [ %{ "entity_type" => "post", "entity_id" => "p1", "differences" => [ %{"name" => "title", "db_value" => "Old", "file_value" => "New"} ] } ], orphan_reports: [%{"file_path" => "posts/2026/07/lost.md"}] } } } ] } end defp validation_snapshot do %{ running_task_message: nil, tasks: [ %{ id: "task-validate", status: :completed, result: %{ kind: "open_editor", route: "site_validation", title: "Site Validation", payload: %{ sitemap_path: "sitemap.xml", sitemap_changed: true, summary: %{ expected_count: 10, existing_count: 8, missing_count: 2, extra_count: 1, updated_count: 1 }, missing_url_paths: ["/2026/07/a/", "/2026/07/b/"], extra_url_paths: ["/stale/"], updated_post_url_paths: ["/2026/07/c/"], expected_url_count: 10, existing_html_url_count: 8 } } } ] } end defp mount_for_report!(snapshot) do parent = self() state = mount!( command_executor: fn action, params -> send(parent, {:executed, action, params}) {:ok, %{kind: "task_queued", title: "T", message: "queued"}} end, task_snapshot_fun: fn -> snapshot end, # Pre-existing completed tasks must not pop up reports, so the # tests hand the snapshot in only after mount via this switch. initial_task_snapshot: %{running_task_message: nil, tasks: []} ) {:noreply, state} = BDS.TUI.handle_info(:poll_tasks, %{state | task_polling: true}) state end test "a completed metadata diff task opens the report panel" do state = mount_for_report!(diff_snapshot()) assert state.report.route == "metadata_diff" text = screen_text(state) assert text =~ "post p1" assert text =~ "title" assert text =~ "lost.md" end test "esc closes the report without applying" do state = mount_for_report!(diff_snapshot()) |> press("esc") assert state.report == nil refute_receive {:executed, _action, _params}, 50 end test "enter repairs all metadata diffs from files and imports orphans" do state = mount_for_report!(diff_snapshot()) |> press("enter") assert state.report == nil assert_receive {:executed, "repair_metadata_diff", %{items: items, direction: "file_to_db"}} assert [%{"entity_type" => "post", "entity_id" => "p1"} | _] = items assert_receive {:executed, "import_metadata_diff_orphans", %{orphans: orphans}} assert [%{"file_path" => "posts/2026/07/lost.md"}] = orphans end test "a completed site validation task opens the report and enter applies it" do state = mount_for_report!(validation_snapshot()) assert state.report.route == "site_validation" text = screen_text(state) assert text =~ "/2026/07/a/" assert text =~ "/stale/" state = press(state, "enter") assert state.report == nil assert_receive {:executed, "apply_site_validation", %{report: report}} assert report.missing_url_paths == ["/2026/07/a/", "/2026/07/b/"] end test "completed tasks from before mount never open a report" do parent = self() state = mount!( command_executor: fn action, params -> send(parent, {:executed, action, params}) end, task_snapshot_fun: fn -> diff_snapshot() end ) {:noreply, state} = BDS.TUI.handle_info(:poll_tasks, %{state | task_polling: true}) assert state.report == nil end end test "local tui mode stops the VM when the app exits" do parent = self() state = mount!(stop_vm_on_exit: true, stop_fun: fn -> send(parent, :vm_stopped) end) assert :ok = BDS.TUI.terminate(:normal, state) assert_receive :vm_stopped end test "ssh-served sessions never stop the VM on exit" do parent = self() state = mount!(stop_fun: fn -> send(parent, :vm_stopped) end) assert :ok = BDS.TUI.terminate(:normal, state) refute_receive :vm_stopped, 100 end end