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_cells(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 end defp screen_text(state, width \\ 100, height \\ 32) do state |> screen_cells(width, height) |> 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 # The cell where `text` starts on screen (used to assert per-cell styles). defp cell_at_text(cells, text) do {_row, row_cells} = cells |> Enum.group_by(& &1.row) |> Enum.find(fn {_row, row_cells} -> row_cells |> Enum.sort_by(& &1.col) |> Enum.map_join("", & &1.symbol) |> String.contains?(text) end) sorted = Enum.sort_by(row_cells, & &1.col) joined = Enum.map_join(sorted, & &1.symbol) {byte_offset, _len} = :binary.match(joined, text) char_offset = joined |> binary_part(0, byte_offset) |> String.length() Enum.at(sorted, char_offset) 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 "opening a post lands in the editor with the raw source", %{post: post} do {:ok, _} = BDS.Posts.update_post(post.id, %{content: "# Big Heading\n\nsome **bold** words"}) state = mount!() |> press("enter") assert state.focus == :editor assert state.editor.post.id == post.id refute state.editor.preview? # The editor shows the raw markdown source. assert screen_text(state) =~ "**bold**" # ctrl+e flips to the rendered markdown preview: inline markers are # consumed by the styling. state = press(state, "e", ["ctrl"]) assert state.editor.preview? text = screen_text(state) assert text =~ "Big Heading" assert text =~ "some bold words" refute text =~ "**bold**" # and back to the editor. state = press(state, "e", ["ctrl"]) refute state.editor.preview? assert screen_text(state) =~ "**bold**" end test "the editor word-wraps long lines", %{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") refute state.editor.preview? # The tail of a long line wraps onto visible rows without any # horizontal scrolling. assert screen_text(state, 80, 32) =~ "FINALWORD" end test "the cursor cell stays visible when the cursor sits below the viewport", %{post: post} do content = Enum.map_join(1..30, "\n", &"line #{&1}") {:ok, _} = BDS.Posts.update_post(post.id, %{content: content}) state = mount!() |> press("enter") # The cursor opens at the end of the buffer — source row 29 of 30. assert ExRatatui.textarea_cursor(state.editor.textarea) == {29, 7} cells = screen_cells(state) # The cursor cell is the single space carrying the cursor background # inside the editor area (the sidebar selection also uses a cyan bg). assert Enum.any?(cells, &(&1.bg == :cyan and &1.symbol == " " and &1.col >= 34)) end test "the cursor line has a distinct background from the other lines", %{post: post} do {:ok, _} = BDS.Posts.update_post(post.id, %{content: "first\nsecond\nthird"}) state = mount!() |> press("enter") # Move the cursor from the end of the buffer onto the first line. state = state |> press("up") |> press("up") assert ExRatatui.textarea_cursor(state.editor.textarea) == {0, 5} cells = screen_cells(state) # A fixed dark grey — readable against the light syntax colours in # any terminal palette (the named :dark_gray is palette-dependent). assert cell_at_text(cells, "first").bg == {:rgb, 52, 61, 70} refute cell_at_text(cells, "second").bg == {:rgb, 52, 61, 70} 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 and starts in the editor, not the preview", %{ 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 refute state.editor.preview? end test "esc returns focus to the sidebar" do state = mount!() |> press("enter") |> press("esc") assert state.focus == :sidebar end test "the preview word-wraps long lines", %{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? assert screen_text(state, 80, 32) =~ "FINALWORD" end test "keys in preview mode do not edit the content" do state = mount!() |> press("enter") |> press("e", ["ctrl"]) assert state.editor.preview? 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 returns to the sidebar from both preview and editor" do state = mount!() |> press("enter") |> press("esc") assert state.focus == :sidebar state = state |> press("enter") |> press("e", ["ctrl"]) |> press("esc") assert state.focus == :sidebar 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 "input that matches no command renders without errors" do state = mount_with_executor!() |> press(":") |> press("j") # Filtering down to zero commands must not blow up the render # (List panics on a selected index into an empty item list). text = screen_text(state) assert text =~ ":j" 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 describe "projects overlay" do defp type(state, text) do text |> String.graphemes() |> Enum.reduce(state, &press(&2, &1)) end defp move_to(state, index) do moves = index - state.projects.selected code = if moves >= 0, do: "down", else: "up" Enum.reduce(List.duplicate(code, abs(moves)), state, &press(&2, &1)) end test "'p' opens the overlay listing all projects with the active one selected", %{ project: project } do state = mount!() |> press("p") assert state.projects.mode == :list active = Enum.at(state.projects.projects, state.projects.selected) assert active.id == project.id text = screen_text(state, 140, 40) assert text =~ project.name end test "esc closes the overlay" do state = mount!() |> press("p") |> press("esc") assert state.projects == nil end test "enter switches the active project and reloads the sidebar", %{project: project} do other_dir = Path.join(System.tmp_dir!(), "bds-tui-other-#{System.unique_integer([:positive])}") File.mkdir_p!(other_dir) on_exit(fn -> File.rm_rf(other_dir) end) {:ok, other} = BDS.Projects.create_project(%{name: "Other Blog", data_path: other_dir}) {:ok, _} = BDS.Posts.create_post(%{project_id: other.id, title: "Other Post", content: "x"}) state = mount!() |> press("p") index = Enum.find_index(state.projects.projects, &(&1.id == other.id)) state = state |> move_to(index) |> press("enter") assert state.projects == nil assert state.project_id == other.id assert BDS.Projects.get_active_project().id == other.id assert screen_text(state) =~ "Other Post" # switching back must not churn: selecting the active project is a no-op {:ok, _} = BDS.Projects.set_active_project(project.id) end test "'o' plus a valid path opens the folder as a project and queues a rebuild" do parent = self() import_dir = Path.join(System.tmp_dir!(), "bds-tui-import-#{System.unique_integer([:positive])}") File.mkdir_p!(import_dir) on_exit(fn -> File.rm_rf(import_dir) end) state = mount!( command_executor: fn action, params -> send(parent, {:executed, action, params}) {:ok, %{kind: "task_queued", title: "Rebuild", message: "queued"}} end ) |> press("p") |> press("o") assert state.projects.mode == :open state = state |> type(import_dir) |> press("enter") assert state.projects == nil imported = Enum.find(BDS.Projects.list_projects(), &(&1.data_path == import_dir)) assert imported != nil assert BDS.Projects.get_active_project().id == imported.id assert state.project_id == imported.id assert_receive {:executed, "rebuild_database", %{}} assert state.task_polling end test "an invalid path keeps the prompt open and reports the error" do parent = self() count_before = length(BDS.Projects.list_projects()) state = mount!(command_executor: fn action, params -> send(parent, {:executed, action, params}) end) |> press("p") |> press("o") |> type("/nonexistent-bds-folder-xyz") |> press("enter") assert state.projects.mode == :open assert state.status != nil assert length(BDS.Projects.list_projects()) == count_before refute_receive {:executed, _action, _params}, 50 end test "esc in the path prompt returns to the project list" do state = mount!() |> press("p") |> press("o") |> press("esc") assert state.projects.mode == :list end end describe "sidebar search" do setup %{project: project} do {:ok, tagged} = BDS.Posts.create_post(%{ project_id: project.id, title: "Tagged Post", content: "x", tags: ["elixir"], categories: ["news"] }) {:ok, tagged: tagged} end defp item_titles(state) do for {:item, item} <- state.items, do: to_string(item[:title] || item[:name]) end defp search(state, query) do state |> press("/") |> type(query) end test "'/' opens the prompt and typing filters the list live" do state = mount!() |> search("tagged") assert state.search != nil assert item_titles(state) == ["Tagged Post"] assert screen_text(state) =~ "/tagged" end test "tag: filters by tag" do state = mount!() |> search("tag:elixir") assert item_titles(state) == ["Tagged Post"] end test "category: filters by category" do state = mount!() |> search("category:news") assert item_titles(state) == ["Tagged Post"] end test "an ISO date shows the posts from that date", %{post: post} do on_ms = DateTime.new!(~D[2026-07-04], ~T[10:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond) BDS.Repo.update_all( from(p in BDS.Posts.Post, where: p.id == ^post.id), set: [updated_at: on_ms] ) state = mount!() |> search("2026-07-04") assert item_titles(state) == ["Hello TUI"] end test "tokens combine: tag plus text must both match" do state = mount!() |> search("tag:elixir hello") assert item_titles(state) == [] end test "enter keeps the filter and shows it in the sidebar title" do state = mount!() |> search("tag:elixir") |> press("enter") assert state.search == nil assert item_titles(state) == ["Tagged Post"] assert screen_text(state) =~ "/tag:elixir" end test "esc clears the filter and restores the full list" do state = mount!() |> search("tag:elixir") |> press("esc") assert state.search == nil assert "Hello TUI" in item_titles(state) assert "Tagged Post" in item_titles(state) end test "the filter applies per view and works for media", %{project: project} do media_file = Path.join(System.tmp_dir!(), "bds-tui-media-#{System.unique_integer([:positive])}.txt") File.write!(media_file, "media body") on_exit(fn -> File.rm(media_file) end) {:ok, _media} = BDS.Media.import_media(%{ project_id: project.id, source_path: media_file, title: "Findable Media" }) state = mount!() |> press("2") |> search("findable") |> press("enter") assert item_titles(state) == ["Findable Media"] # posts view keeps its own (empty) filter state = press(state, "1") assert "Hello TUI" in item_titles(state) end end describe "path completion" do setup do base = Path.join(System.tmp_dir!(), "bds-tui-tab-#{System.unique_integer([:positive])}") for dir <- ["blog-one", "blog-two", "alpha-unique", ".secret"] do File.mkdir_p!(Path.join(base, dir)) end File.write!(Path.join(base, "blog-notes.txt"), "not a folder") on_exit(fn -> File.rm_rf(base) end) {:ok, base: base} end defp prompt!(path) do mount!() |> press("p") |> press("o") |> type(path) end test "tab completes a unique directory prefix with a trailing slash", %{base: base} do state = prompt!(Path.join(base, "alph")) |> press("tab") assert state.projects.input == Path.join(base, "alpha-unique") <> "/" assert state.projects.matches == [] end test "tab completes ambiguous prefixes to the longest common prefix and lists candidates", %{base: base} do state = prompt!(Path.join(base, "bl")) |> press("tab") # Files are not offered — this is a folder picker. assert state.projects.input == Path.join(base, "blog-") assert state.projects.matches == ["blog-one", "blog-two"] assert screen_text(state, 140, 40) =~ "blog-one" end test "candidates are cleared as soon as typing continues", %{base: base} do state = prompt!(Path.join(base, "bl")) |> press("tab") |> press("o") assert state.projects.matches == [] end test "hidden folders are not offered unless the prefix starts with a dot", %{base: base} do state = prompt!(base <> "/") |> press("tab") refute ".secret" in state.projects.matches state = prompt!(Path.join(base, ".se")) |> press("tab") assert state.projects.input == Path.join(base, ".secret") <> "/" end test "tab with no matches leaves the input unchanged", %{base: base} do input = Path.join(base, "zzz") state = prompt!(input) |> press("tab") assert state.projects.input == input assert state.projects.matches == [] end test "a leading ~ expands to the home directory" do state = prompt!("~/") |> press("tab") assert String.starts_with?(state.projects.input, System.user_home!() <> "/") end end describe "git view" do defp git!(dir, args) do {output, 0} = System.cmd("git", args, cd: dir, stderr_to_stdout: true) output end defp init_repo!(dir) do git!(dir, ["init"]) git!(dir, ["config", "user.email", "tui@test"]) git!(dir, ["config", "user.name", "TUI Test"]) File.write!(Path.join(dir, "tracked.md"), "original\n") git!(dir, ["add", "-A"]) git!(dir, ["commit", "-m", "initial"]) end defp modify!(dir, file, content), do: File.write!(Path.join(dir, file), content) test "'7' opens the git view with changed files and a scrollable diff", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) modify!(dir, "tracked.md", "changed\n") File.write!(Path.join(dir, "new-file.md"), "brand new\n") state = mount!() |> press("7") assert state.view == "git" labels = for {:item, item} <- state.items, do: item.label assert "M tracked.md" in labels assert "U new-file.md" in labels assert state.git != nil assert Enum.any?(state.git.lines, &String.starts_with?(&1, "diff --git")) assert screen_text(state, 140, 40) =~ "diff --git" end test "pgdn/pgup scroll the diff", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) modify!(dir, "tracked.md", Enum.map_join(1..60, "\n", &"line #{&1}") <> "\n") state = mount!() |> press("7") |> press("pagedown") assert state.git.scroll > 0 state = press(state, "pageup") assert state.git.scroll == 0 end test "enter on a changed file jumps the diff to that file", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) File.write!(Path.join(dir, "zz-later.md"), "z\n") git!(dir, ["add", "-A"]) git!(dir, ["commit", "-m", "second file"]) modify!(dir, "tracked.md", "changed\n") modify!(dir, "zz-later.md", "changed too\n") state = mount!() |> press("7") index = Enum.find_index(state.items, fn {:item, item} -> item.id == "zz-later.md" end) state = %{state | selected: index} |> press("enter") line = Enum.at(state.git.lines, state.git.scroll) assert line =~ "diff --git" assert line =~ "zz-later.md" end test "'c' opens the commit prompt and enter commits everything", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) modify!(dir, "tracked.md", "changed\n") state = mount!() |> press("7") |> press("c") assert state.git_commit != nil assert screen_text(state) =~ "Commit message:" state = state |> type("content sync") assert screen_text(state) =~ "content sync" state = press(state, "enter") assert state.git_commit == nil assert {:ok, %{files: []}} = BDS.Git.status(project.id) assert state.items == [] assert git!(dir, ["log", "-1", "--format=%s"]) =~ "content sync" end test "an empty commit message is rejected", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) modify!(dir, "tracked.md", "changed\n") state = mount!() |> press("7") |> press("c") |> press("enter") assert state.git_commit == nil assert state.status != nil assert {:ok, %{files: [_change]}} = BDS.Git.status(project.id) end test "'u' pulls asynchronously and reports the result", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) state = mount!() |> press("7") |> press("u") assert state.busy # No remote is configured, so the streamed result is an error toast. assert_receive {:git_result, :pull, {:error, _reason}}, 5_000 {:noreply, state} = BDS.TUI.handle_info({:git_result, :pull, {:error, {:git_failed, "no remote"}}}, state) refute state.busy assert state.status =~ "no remote" end test "'s' pushes asynchronously", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) state = mount!() |> press("7") |> press("s") assert state.busy assert_receive {:git_result, :push, {:error, _reason}}, 5_000 end test "the sidebar shows the commit history in its lower half", %{project: project} do dir = BDS.Projects.project_data_dir(project) init_repo!(dir) state = mount!() |> press("7") assert [entry | _] = state.sidebar.history_entries assert entry.subject == "initial" text = screen_text(state, 140, 40) assert text =~ "History" # No remote exists, so the commit is local-only and marked for push. assert text =~ "↑ #{entry.short_hash} initial" # A commit from the panel shows up in the history immediately. modify!(dir, "tracked.md", "changed\n") state = state |> press("r") |> press("c") |> type("sync commit") |> press("enter") assert screen_text(state, 140, 40) =~ "sync commit" end test "git keys in a non-repo project only report", %{project: project} do state = mount!() |> press("7") assert state.view == "git" assert state.git == nil assert state.items == [] assert screen_text(state, 140, 40) =~ "Not a git repository." state = press(state, "c") assert state.git_commit == nil assert state.status != nil state = press(state, "u") refute state.busy refute_receive {:git_result, _op, _result}, 100 _ = project end end describe "settings panel (issue #29)" do defp open_section(state, title) do index = Enum.find_index(state.items, fn {:item, item} -> item.title == title _other -> false end) moves = index - state.selected direction = if moves < 0, do: "up", else: "down" 1..abs(moves)//1 |> Enum.reduce(state, fn _step, acc -> press(acc, direction) end) |> press("enter") end defp select_field(state, key) do index = Enum.find_index(state.settings_form.fields, &(&1.key == key)) moves = index - state.settings_form.selected direction = if moves < 0, do: "up", else: "down" Enum.reduce(1..abs(moves)//1, state, fn _step, acc -> press(acc, direction) end) end test "6 opens the settings view and lists the sections" do state = mount!() |> press("6") assert state.view == "settings" text = screen_text(state) assert text =~ "Project" assert text =~ "Publishing" assert text =~ "Style" end test "enter opens a section form; editing a text field persists on ctrl+s", %{ project: project } do state = mount!() |> press("6") |> open_section("Project") assert state.settings_form != nil assert state.settings_form.section == "project" assert screen_text(state, 140, 40) =~ "Posts per Page" state = select_field(state, "name") |> press("enter") assert state.settings_form.input != nil # The prompt is seeded with the current value; replace it. state = Enum.reduce(1..3, state, fn _n, acc -> press(acc, "backspace") end) |> type("TUI Prefs") |> press("enter") assert state.settings_form.input == nil assert state.settings_form.dirty state = press(state, "s", ["ctrl"]) refute state.settings_form.dirty {:ok, metadata} = BDS.Metadata.get_project_metadata(project.id) assert metadata.name == "TUI Prefs" end test "enter toggles booleans and cycles enums", %{project: project} do state = mount!() |> press("6") |> open_section("Publishing") state = select_field(state, "ssh_mode") assert current_field(state).value == "scp" state = press(state, "enter") assert current_field(state).value == "rsync" state = state |> select_field("ssh_host") |> press("enter") |> type("example.org") |> press("enter") state = press(state, "s", ["ctrl"]) {:ok, metadata} = BDS.Metadata.get_project_metadata(project.id) assert metadata.publishing_preferences["ssh_mode"] == "rsync" assert metadata.publishing_preferences["ssh_host"] == "example.org" # Booleans toggle in place. state = state |> press("esc") |> open_section("Technology") state = select_field(state, "semantic_similarity_enabled") refute current_field(state).value state = press(state, "enter") assert current_field(state).value end test "esc in a prompt cancels only the prompt; esc again closes the form" do state = mount!() |> press("6") |> open_section("Publishing") state = select_field(state, "ssh_host") |> press("enter") assert state.settings_form.input != nil state = press(state, "esc") assert state.settings_form != nil assert state.settings_form.input == nil state = press(state, "esc") assert state.settings_form == nil assert state.view == "settings" end defp current_field(state), do: Enum.at(state.settings_form.fields, state.settings_form.selected) end describe "tags panel (issue #34)" do defp open_tags_section(state, section) do state = press(state, "5") index = Enum.find_index(state.items, fn {:item, %{id: id}} -> id == "tags-" <> section _other -> false end) assert index != nil press(%{state | selected: index}, "enter") end defp type_text(state, text) do text |> String.graphemes() |> Enum.reduce(state, fn char, acc -> press(acc, char) end) end defp tag_names(project_id) do project_id |> BDS.Tags.list_tags() |> Enum.map(& &1.name) end test "the sidebar entries open the panel sections in the main area", %{project: project} do {:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "elixir"}) state = open_tags_section(mount!(), "cloud") assert state.tags_panel.section == "cloud" assert screen_text(state) =~ "elixir (0)" state = press(state, "esc") assert state.tags_panel == nil state = open_tags_section(state, "manage") assert state.tags_panel.section == "manage" state = open_tags_section(press(state, "esc"), "merge") assert state.tags_panel.section == "merge" assert screen_text(state) =~ "[ ] elixir (0)" end test "manage creates a tag through the n prompt", %{project: project} do state = open_tags_section(mount!(), "manage") state = press(state, "n") assert state.tags_panel.input.kind == :new state = state |> type_text("neu") |> press("enter") assert "neu" in tag_names(project.id) assert state.tags_panel.input == nil assert state.status =~ "neu" end test "manage renames the selected tag through the enter prompt", %{project: project} do {:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "alpha"}) state = open_tags_section(mount!(), "manage") state = press(state, "enter") assert state.tags_panel.input.kind == :rename assert state.tags_panel.input.value == "alpha" state = Enum.reduce(1..5, state, fn _n, acc -> press(acc, "backspace") end) _state = state |> type_text("beta") |> press("enter") assert tag_names(project.id) == ["beta"] end test "manage cycles the tag colour with c", %{project: project} do {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "colored"}) state = open_tags_section(mount!(), "manage") _state = press(state, "c") updated = BDS.Repo.get!(BDS.Tags.Tag, tag.id) assert updated.color == List.first(BDS.UI.TagsPanel.colour_presets()) end test "manage deletes only after y confirmation", %{project: project} do {:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "doomed"}) state = open_tags_section(mount!(), "manage") # Any other key cancels the pending delete. state = state |> press("d") |> press("x") assert state.tags_panel.confirm_delete == nil assert "doomed" in tag_names(project.id) state = state |> press("d") assert state.tags_panel.confirm_delete == "doomed" state = press(state, "y") assert tag_names(project.id) == [] assert state.status =~ "doomed" end test "merge marks tags with space and merges them into the selection", %{project: project} do {:ok, _one} = BDS.Tags.create_tag(%{project_id: project.id, name: "one"}) {:ok, _two} = BDS.Tags.create_tag(%{project_id: project.id, name: "two"}) {:ok, tagged_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Tagged", content: "body", tags: ["one"] }) state = open_tags_section(mount!(), "merge") # Mark "one", move to "two", merge — "one" is folded into "two". _state = state |> press(" ") |> press("down") |> press("m") assert tag_names(project.id) == ["two"] assert BDS.Posts.get_post!(tagged_post.id).tags == ["two"] end test "manage syncs tags from post tags with s", %{project: project} do {:ok, _post} = BDS.Posts.create_post(%{ project_id: project.id, title: "With Tags", content: "body", tags: ["from-post"] }) state = open_tags_section(mount!(), "manage") state = press(state, "s") assert "from-post" in tag_names(project.id) assert Enum.any?(state.tags_panel.tags, &(&1.name == "from-post")) 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