feat: TUI settings panel on key 6 with per-section preference editors backed by the same GUI settings backends (issue #29)
This commit is contained in:
@@ -862,6 +862,110 @@ defmodule BDS.TUITest do
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
200
test/bds/ui/settings_form_test.exs
Normal file
200
test/bds/ui/settings_form_test.exs
Normal file
@@ -0,0 +1,200 @@
|
||||
defmodule BDS.UI.SettingsFormTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.UI.SettingsForm
|
||||
|
||||
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-settings-form-#{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: "Prefs", data_path: temp_dir})
|
||||
{:ok, _} = BDS.Projects.set_active_project(project.id)
|
||||
|
||||
%{project: project}
|
||||
end
|
||||
|
||||
defp field(form, key), do: Enum.find(form.fields, &(&1.key == key))
|
||||
|
||||
defp values(form, overrides) do
|
||||
form.fields
|
||||
|> Map.new(fn f -> {f.key, f.value} end)
|
||||
|> Map.merge(overrides)
|
||||
end
|
||||
|
||||
describe "project" do
|
||||
test "load exposes the project metadata as typed fields", %{project: project} do
|
||||
form = SettingsForm.load("project", project.id)
|
||||
|
||||
assert field(form, "name").value == "Prefs"
|
||||
assert field(form, "name").type == :text
|
||||
assert field(form, "main_language").type == :enum
|
||||
assert "de" in field(form, "main_language").options
|
||||
assert field(form, "semantic_similarity_enabled").type == :bool
|
||||
end
|
||||
|
||||
test "save writes through Metadata and keeps untouched values", %{project: project} do
|
||||
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{pico_theme: "jade"})
|
||||
|
||||
form = SettingsForm.load("project", project.id)
|
||||
|
||||
:ok =
|
||||
SettingsForm.save(
|
||||
"project",
|
||||
project.id,
|
||||
values(form, %{
|
||||
"name" => "Renamed",
|
||||
"max_posts_per_page" => "25",
|
||||
"blog_languages" => "en, de",
|
||||
"semantic_similarity_enabled" => true
|
||||
})
|
||||
)
|
||||
|
||||
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert metadata.name == "Renamed"
|
||||
assert metadata.max_posts_per_page == 25
|
||||
assert metadata.blog_languages == ["en", "de"]
|
||||
assert metadata.semantic_similarity_enabled
|
||||
assert metadata.pico_theme == "jade"
|
||||
end
|
||||
end
|
||||
|
||||
describe "editor" do
|
||||
test "round-trips the global editor settings", %{project: project} do
|
||||
form = SettingsForm.load("editor", project.id)
|
||||
assert field(form, "default_mode").type == :enum
|
||||
|
||||
:ok =
|
||||
SettingsForm.save(
|
||||
"editor",
|
||||
project.id,
|
||||
values(form, %{"default_mode" => "preview", "wrap_long_lines" => true})
|
||||
)
|
||||
|
||||
assert BDS.Settings.get_global_setting("ui.preferred_editor_mode") == "preview"
|
||||
assert BDS.Settings.get_global_setting("ui.git_diff_word_wrap") == "true"
|
||||
|
||||
reloaded = SettingsForm.load("editor", project.id)
|
||||
assert field(reloaded, "default_mode").value == "preview"
|
||||
assert field(reloaded, "wrap_long_lines").value == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "content" do
|
||||
test "edits category settings and adds new categories", %{project: project} do
|
||||
form = SettingsForm.load("content", project.id)
|
||||
assert field(form, "cat:aside:show_title").type == :bool
|
||||
|
||||
:ok =
|
||||
SettingsForm.save(
|
||||
"content",
|
||||
project.id,
|
||||
values(form, %{
|
||||
"new_category" => "linkroll",
|
||||
"cat:article:render_in_lists" => false,
|
||||
"cat:article:title" => "Articles"
|
||||
})
|
||||
)
|
||||
|
||||
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert "linkroll" in metadata.categories
|
||||
assert metadata.category_settings["article"]["render_in_lists"] == false
|
||||
assert metadata.category_settings["article"]["title"] == "Articles"
|
||||
end
|
||||
end
|
||||
|
||||
describe "technology" do
|
||||
test "toggles semantic similarity without wiping the project", %{project: project} do
|
||||
form = SettingsForm.load("technology", project.id)
|
||||
refute field(form, "semantic_similarity_enabled").value
|
||||
|
||||
:ok =
|
||||
SettingsForm.save(
|
||||
"technology",
|
||||
project.id,
|
||||
values(form, %{"semantic_similarity_enabled" => true})
|
||||
)
|
||||
|
||||
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert metadata.semantic_similarity_enabled
|
||||
assert metadata.name == "Prefs"
|
||||
end
|
||||
end
|
||||
|
||||
describe "publishing" do
|
||||
test "saves the SSH preferences", %{project: project} do
|
||||
form = SettingsForm.load("publishing", project.id)
|
||||
assert field(form, "ssh_mode").options == ["scp", "rsync"]
|
||||
|
||||
:ok =
|
||||
SettingsForm.save(
|
||||
"publishing",
|
||||
project.id,
|
||||
values(form, %{"ssh_host" => "example.org", "ssh_mode" => "rsync"})
|
||||
)
|
||||
|
||||
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert metadata.publishing_preferences["ssh_host"] == "example.org"
|
||||
assert metadata.publishing_preferences["ssh_mode"] == "rsync"
|
||||
end
|
||||
end
|
||||
|
||||
describe "ai" do
|
||||
test "loads the AI form and saves prompt plus airplane mode", %{project: project} do
|
||||
form = SettingsForm.load("ai", project.id)
|
||||
assert field(form, "offline_mode").type == :bool
|
||||
assert field(form, "system_prompt").type == :text
|
||||
|
||||
:ok =
|
||||
SettingsForm.save(
|
||||
"ai",
|
||||
project.id,
|
||||
values(form, %{"system_prompt" => "be terse", "offline_mode" => true})
|
||||
)
|
||||
|
||||
assert BDS.Settings.get_global_setting("ai.system_prompt") == "be terse"
|
||||
assert BDS.AI.airplane_mode?()
|
||||
end
|
||||
end
|
||||
|
||||
describe "style" do
|
||||
test "applies a theme without wiping other metadata", %{project: project} do
|
||||
form = SettingsForm.load("style", project.id)
|
||||
assert "jade" in field(form, "pico_theme").options
|
||||
|
||||
:ok = SettingsForm.save("style", project.id, values(form, %{"pico_theme" => "jade"}))
|
||||
|
||||
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert metadata.pico_theme == "jade"
|
||||
assert metadata.name == "Prefs"
|
||||
end
|
||||
end
|
||||
|
||||
describe "data" do
|
||||
test "shows the data folder read-only and save is a no-op", %{project: project} do
|
||||
form = SettingsForm.load("data", project.id)
|
||||
data_field = field(form, "data_path")
|
||||
assert data_field.type == :info
|
||||
assert data_field.value =~ "bds-settings-form"
|
||||
|
||||
assert SettingsForm.save("data", project.id, %{}) == :ok
|
||||
end
|
||||
end
|
||||
|
||||
describe "mcp" do
|
||||
test "lists the agents with supported ones as toggles", %{project: project} do
|
||||
form = SettingsForm.load("mcp", project.id)
|
||||
|
||||
claude = field(form, "mcp:claude_code")
|
||||
assert claude.type == :bool
|
||||
assert is_boolean(claude.value)
|
||||
|
||||
gemini = field(form, "mcp:gemini_cli")
|
||||
assert gemini.type == :info
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user