D1-18: HomeItemProtection — Home menu item cannot be moved, reordered, or deleted
This commit is contained in:
@@ -132,7 +132,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
|||||||
| D1-15 | ~~Drag-and-drop image chain~~ | action_patterns.allium:84-103 | **Resolved:** the chain had no handler — added `BDS.Desktop.ShellLive.EditorImageDrop` (`import_and_link/3` runs steps 1-4: import media + synchronous thumbnails + link to post + return `` markdown; `enrich/3` runs background steps 5-6: AI analysis auto-applied with no modal + auto-translate cascade when `do_not_translate == false`). `PostEditor.handle_event("editor_image_dropped", ...)` runs the synchronous chain (works offline since import isn't AI), pushes the cursor insert, and spawns `enrich` only when airplane mode is off. MonacoEditor JS hook captures image drops on the editor surface and pushes the file path (`phx-target={@myself}` routes the hook event to the component); i18n for de/fr/it/es. 3 tests added (module chain incl. thumbnails+link+markdown, non-image link form, full LiveView drop in airplane mode asserting import/link/insert with no AI metadata). |
|
| D1-15 | ~~Drag-and-drop image chain~~ | action_patterns.allium:84-103 | **Resolved:** the chain had no handler — added `BDS.Desktop.ShellLive.EditorImageDrop` (`import_and_link/3` runs steps 1-4: import media + synchronous thumbnails + link to post + return `` markdown; `enrich/3` runs background steps 5-6: AI analysis auto-applied with no modal + auto-translate cascade when `do_not_translate == false`). `PostEditor.handle_event("editor_image_dropped", ...)` runs the synchronous chain (works offline since import isn't AI), pushes the cursor insert, and spawns `enrich` only when airplane mode is off. MonacoEditor JS hook captures image drops on the editor surface and pushes the file path (`phx-target={@myself}` routes the hook event to the component); i18n for de/fr/it/es. 3 tests added (module chain incl. thumbnails+link+markdown, non-image link form, full LiveView drop in airplane mode asserting import/link/insert with no AI metadata). |
|
||||||
| D1-16 | ~~DebouncedPersistence (5s)~~ | embedding.allium:213-217 | **Resolved:** 3 tests added in `embeddings_test.exs` (DebouncedPersistence describe): `Index.put/3` schedules a per-project save `timer` with ~5s remaining (>4000ms, <=5000ms) instead of writing immediately (no `embeddings.usearch` on disk yet); rapid `put`s coalesce (each reschedules the single timer, the previous timer is cancelled so `Process.read_timer` returns false, and still no file after two writes); when the `{:save, project_id}` debounce message fires the index is persisted to disk and the pending `timer` cleared. The coalescing test exposed a real bug: `handle_call({:put, ...})` replaced the stored entry with `build_entry/2`'s fresh `timer: nil` entry before `schedule_save/2` ran, orphaning the previous debounce timer (left to fire a redundant save) instead of cancelling it; fixed via `cancel_pending_save/2` so bulk `put`s collapse to one deferred write |
|
| D1-16 | ~~DebouncedPersistence (5s)~~ | embedding.allium:213-217 | **Resolved:** 3 tests added in `embeddings_test.exs` (DebouncedPersistence describe): `Index.put/3` schedules a per-project save `timer` with ~5s remaining (>4000ms, <=5000ms) instead of writing immediately (no `embeddings.usearch` on disk yet); rapid `put`s coalesce (each reschedules the single timer, the previous timer is cancelled so `Process.read_timer` returns false, and still no file after two writes); when the `{:save, project_id}` debounce message fires the index is persisted to disk and the pending `timer` cleared. The coalescing test exposed a real bug: `handle_call({:put, ...})` replaced the stored entry with `build_entry/2`'s fresh `timer: nil` entry before `schedule_save/2` ran, orphaning the previous debounce timer (left to fire a redundant save) instead of cancelling it; fixed via `cancel_pending_save/2` so bulk `put`s collapse to one deferred write |
|
||||||
| D1-17 | ~~Protected categories cannot be deleted~~ | editor_settings.allium:81-84 | **Resolved:** 5 tests added in managed_categories_test.exs covering protected_category?/1 classification and remove_category/4 rejection for all 4 protected categories (article, aside, page, picture) plus non-protected deletion allowed |
|
| D1-17 | ~~Protected categories cannot be deleted~~ | editor_settings.allium:81-84 | **Resolved:** 5 tests added in managed_categories_test.exs covering protected_category?/1 classification and remove_category/4 rejection for all 4 protected categories (article, aside, page, picture) plus non-protected deletion allowed |
|
||||||
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
|
| D1-18 | ~~HomeItemProtection (menu)~~ | editor_misc.allium:206-209 | **Resolved:** 13 tests added for TreePredicates (can_move_up?/can_move_down?/can_indent?/can_unindent? return false for Home, can_delete? false for Home, true for non-Home) + TreeOps (move_selected/indent_selected/unindent_selected/delete_selected no-op for Home, drop_selected no-op when drag item is Home, non-Home operations still work); code guards added to all predicate and operation functions |
|
||||||
|
|
||||||
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
|
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@spec move_selected(term(), term()) :: term()
|
@spec move_selected(term(), term()) :: term()
|
||||||
|
def move_selected(%{selected_id: @home_item_id} = state, _direction), do: state
|
||||||
|
|
||||||
def move_selected(%{selected_id: selected_id} = state, direction)
|
def move_selected(%{selected_id: selected_id} = state, direction)
|
||||||
when direction in [:up, :down] do
|
when direction in [:up, :down] do
|
||||||
case find_path(state.items, selected_id) do
|
case find_path(state.items, selected_id) do
|
||||||
@@ -209,6 +211,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@spec indent_selected(term()) :: term()
|
@spec indent_selected(term()) :: term()
|
||||||
|
def indent_selected(%{selected_id: @home_item_id} = state), do: state
|
||||||
|
|
||||||
def indent_selected(%{selected_id: selected_id} = state) do
|
def indent_selected(%{selected_id: selected_id} = state) do
|
||||||
case find_path(state.items, selected_id) do
|
case find_path(state.items, selected_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -249,6 +253,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@spec unindent_selected(term()) :: term()
|
@spec unindent_selected(term()) :: term()
|
||||||
|
def unindent_selected(%{selected_id: @home_item_id} = state), do: state
|
||||||
|
|
||||||
def unindent_selected(%{selected_id: selected_id} = state) do
|
def unindent_selected(%{selected_id: selected_id} = state) do
|
||||||
case find_path(state.items, selected_id) do
|
case find_path(state.items, selected_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -295,6 +301,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
|||||||
when drag_item_id == target_item_id,
|
when drag_item_id == target_item_id,
|
||||||
do: state
|
do: state
|
||||||
|
|
||||||
|
def drop_selected(state, @home_item_id, _target_item_id, _position), do: state
|
||||||
|
|
||||||
@spec drop_selected(term(), term(), term(), term()) :: term()
|
@spec drop_selected(term(), term(), term(), term()) :: term()
|
||||||
def drop_selected(state, drag_item_id, target_item_id, position) do
|
def drop_selected(state, drag_item_id, target_item_id, position) do
|
||||||
drag_path = find_path(state.items, drag_item_id)
|
drag_path = find_path(state.items, drag_item_id)
|
||||||
|
|||||||
@@ -5,57 +5,73 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
|
|||||||
|
|
||||||
@spec can_move_up?(term(), term()) :: term()
|
@spec can_move_up?(term(), term()) :: term()
|
||||||
def can_move_up?(items, selected_id) do
|
def can_move_up?(items, selected_id) do
|
||||||
case TreeOps.find_path(items, selected_id) do
|
if selected_id == TreeOps.home_item_id() do
|
||||||
[_parent, index] -> index > 0
|
false
|
||||||
[index] -> index > 0
|
else
|
||||||
path when is_list(path) -> List.last(path) > 0
|
case TreeOps.find_path(items, selected_id) do
|
||||||
_other -> false
|
[_parent, index] -> index > 0
|
||||||
|
[index] -> index > 0
|
||||||
|
path when is_list(path) -> List.last(path) > 0
|
||||||
|
_other -> false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec can_move_down?(term(), term()) :: term()
|
@spec can_move_down?(term(), term()) :: term()
|
||||||
def can_move_down?(items, selected_id) do
|
def can_move_down?(items, selected_id) do
|
||||||
case TreeOps.find_path(items, selected_id) do
|
if selected_id == TreeOps.home_item_id() do
|
||||||
nil ->
|
false
|
||||||
false
|
else
|
||||||
|
case TreeOps.find_path(items, selected_id) do
|
||||||
|
nil ->
|
||||||
|
false
|
||||||
|
|
||||||
path ->
|
path ->
|
||||||
parent_path = Enum.drop(path, -1)
|
parent_path = Enum.drop(path, -1)
|
||||||
index = List.last(path)
|
index = List.last(path)
|
||||||
index < length(TreeOps.items_at_path(items, parent_path)) - 1
|
index < length(TreeOps.items_at_path(items, parent_path)) - 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec can_indent?(term(), term()) :: term()
|
@spec can_indent?(term(), term()) :: term()
|
||||||
def can_indent?(items, selected_id) do
|
def can_indent?(items, selected_id) do
|
||||||
case TreeOps.find_path(items, selected_id) do
|
if selected_id == TreeOps.home_item_id() do
|
||||||
nil ->
|
false
|
||||||
false
|
else
|
||||||
|
case TreeOps.find_path(items, selected_id) do
|
||||||
|
nil ->
|
||||||
|
false
|
||||||
|
|
||||||
[] ->
|
[] ->
|
||||||
false
|
false
|
||||||
|
|
||||||
[_index] = path ->
|
[_index] = path ->
|
||||||
index = List.last(path)
|
index = List.last(path)
|
||||||
index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1]))
|
index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1]))
|
||||||
|
|
||||||
path ->
|
path ->
|
||||||
index = List.last(path)
|
index = List.last(path)
|
||||||
|
|
||||||
index > 0 and
|
index > 0 and
|
||||||
match?(
|
match?(
|
||||||
%{kind: :submenu},
|
%{kind: :submenu},
|
||||||
TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])
|
TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])
|
||||||
)
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec can_unindent?(term(), term()) :: term()
|
@spec can_unindent?(term(), term()) :: term()
|
||||||
def can_unindent?(items, selected_id) do
|
def can_unindent?(items, selected_id) do
|
||||||
case TreeOps.find_path(items, selected_id) do
|
if selected_id == TreeOps.home_item_id() do
|
||||||
[_index] -> false
|
false
|
||||||
path when is_list(path) -> length(path) > 1
|
else
|
||||||
_other -> false
|
case TreeOps.find_path(items, selected_id) do
|
||||||
|
[_index] -> false
|
||||||
|
path when is_list(path) -> length(path) > 1
|
||||||
|
_other -> false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
108
test/bds/desktop/menu_editor/home_item_protection_test.exs
Normal file
108
test/bds/desktop/menu_editor/home_item_protection_test.exs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
defmodule BDS.Desktop.MenuEditor.HomeItemProtectionTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellLive.MenuEditor.{TreeOps, TreePredicates}
|
||||||
|
|
||||||
|
@home_id TreeOps.home_item_id()
|
||||||
|
@other_id "some-other-item"
|
||||||
|
|
||||||
|
defp home_state do
|
||||||
|
%{
|
||||||
|
items: [
|
||||||
|
TreeOps.home_item(),
|
||||||
|
%{item_id: @other_id, kind: :page, label: "About", slug: "about", children: [], is_home: false}
|
||||||
|
],
|
||||||
|
selected_id: @home_id,
|
||||||
|
draft: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp other_state do
|
||||||
|
%{
|
||||||
|
items: [
|
||||||
|
TreeOps.home_item(),
|
||||||
|
%{item_id: @other_id, kind: :page, label: "About", slug: "about", children: [], is_home: false}
|
||||||
|
],
|
||||||
|
selected_id: @other_id,
|
||||||
|
draft: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "TreePredicates" do
|
||||||
|
test "can_move_up? returns false for home item" do
|
||||||
|
items = home_state().items
|
||||||
|
refute TreePredicates.can_move_up?(items, @home_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can_move_down? returns false for home item" do
|
||||||
|
items = home_state().items
|
||||||
|
refute TreePredicates.can_move_down?(items, @home_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can_indent? returns false for home item" do
|
||||||
|
items = home_state().items
|
||||||
|
refute TreePredicates.can_indent?(items, @home_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can_unindent? returns false for home item" do
|
||||||
|
items = home_state().items
|
||||||
|
refute TreePredicates.can_unindent?(items, @home_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can_delete? returns false for home item" do
|
||||||
|
refute TreePredicates.can_delete?(@home_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can_delete? returns true for non-home item" do
|
||||||
|
assert TreePredicates.can_delete?(@other_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "TreeOps" do
|
||||||
|
test "move_selected is no-op when home item is selected" do
|
||||||
|
state = home_state()
|
||||||
|
assert TreeOps.move_selected(state, :up) == state
|
||||||
|
assert TreeOps.move_selected(state, :down) == state
|
||||||
|
end
|
||||||
|
|
||||||
|
test "move_selected works for non-home item" do
|
||||||
|
state = other_state()
|
||||||
|
moved = TreeOps.move_selected(state, :up)
|
||||||
|
assert moved != state
|
||||||
|
end
|
||||||
|
|
||||||
|
test "indent_selected is no-op when home item is selected" do
|
||||||
|
state = home_state()
|
||||||
|
assert TreeOps.indent_selected(state) == state
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unindent_selected is no-op when home item is selected" do
|
||||||
|
state = home_state()
|
||||||
|
assert TreeOps.unindent_selected(state) == state
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_selected is no-op when home item is selected" do
|
||||||
|
state = home_state()
|
||||||
|
assert TreeOps.delete_selected(state) == state
|
||||||
|
end
|
||||||
|
|
||||||
|
test "drop_selected is no-op when drag item is home item" do
|
||||||
|
state = other_state()
|
||||||
|
assert TreeOps.drop_selected(state, @home_id, @other_id, "after") == state
|
||||||
|
end
|
||||||
|
|
||||||
|
test "drop_selected works when neither item is home" do
|
||||||
|
state = %{
|
||||||
|
other_state()
|
||||||
|
| items: [
|
||||||
|
%{item_id: "a", kind: :page, label: "A", slug: "a", children: [], is_home: false},
|
||||||
|
%{item_id: "b", kind: :page, label: "B", slug: "b", children: [], is_home: false}
|
||||||
|
],
|
||||||
|
selected_id: "b"
|
||||||
|
}
|
||||||
|
|
||||||
|
dropped = TreeOps.drop_selected(state, "a", "b", "before")
|
||||||
|
assert dropped != state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user