diff --git a/lib/taski/progress/layout/tree/structure.rb b/lib/taski/progress/layout/tree/structure.rb index 955bec8..8ee270e 100644 --- a/lib/taski/progress/layout/tree/structure.rb +++ b/lib/taski/progress/layout/tree/structure.rb @@ -24,8 +24,7 @@ def render_tree def init_tree_structure @tree_nodes = {} - @node_depths = {} - @node_is_last = {} + @node_prefixes = {} end def build_ready_tree @@ -64,9 +63,17 @@ def register_tree_nodes(node, depth:, is_last:, ancestors_last:) task_class = node[:task_class] @tasks[task_class] ||= new_task_progress - @tree_nodes[task_class] = node - @node_depths[task_class] = depth - @node_is_last[task_class] = {is_last: is_last, ancestors_last: ancestors_last.dup} + @tree_nodes[task_class] ||= node + + # A shared dependency is registered once per occurrence in the + # tree. The prefix belongs to the OCCURRENCE (stored on the node), + # not to the task class — a class-keyed prefix would be clobbered + # by the last occurrence and corrupt every earlier row. The + # class-keyed map keeps only the FIRST occurrence, as the one + # canonical prefix for per-event output lines. + prefix = compute_tree_prefix(depth: depth, is_last: is_last, ancestors_last: ancestors_last) + node[:prefix] = prefix + @node_prefixes[task_class] ||= prefix children = node[:children] children.each_with_index do |child, index| @@ -88,9 +95,8 @@ def build_tree_lines def build_node_lines(node, lines) return unless node - task_class = node[:task_class] - prefix = build_tree_prefix(task_class) - content = build_task_content(task_class) + prefix = node[:prefix] || "" + content = build_task_content(node[:task_class]) lines << "#{prefix}#{content}" node[:children].each do |child| @@ -116,15 +122,14 @@ def build_task_content(task_class) end end + # Canonical (first-occurrence) prefix for a task class, used by + # per-event output lines in Tree::Event. def build_tree_prefix(task_class) - depth = @node_depths[task_class] - return "" if depth.nil? || depth == 0 - - last_info = @node_is_last[task_class] - return "" unless last_info + @node_prefixes[task_class] || "" + end - ancestors_last = last_info[:ancestors_last] - is_last = last_info[:is_last] + def compute_tree_prefix(depth:, is_last:, ancestors_last:) + return "" if depth.zero? prefix = "" # Skip the first ancestor (root) since root has no visual prefix @@ -132,8 +137,7 @@ def build_tree_prefix(task_class) prefix += ancestor_is_last ? SPACE : VERTICAL end - prefix += is_last ? LAST_BRANCH : BRANCH - prefix + prefix + (is_last ? LAST_BRANCH : BRANCH) end end end diff --git a/test/test_layout_tree_event.rb b/test/test_layout_tree_event.rb index 9b9525d..28a42b8 100644 --- a/test/test_layout_tree_event.rb +++ b/test/test_layout_tree_event.rb @@ -283,6 +283,65 @@ def test_root_task_has_no_prefix refute_includes @output.string, "└── ⠋ RootTask" end + # A shared dependency (diamond: Root -> Left -> Shared, Root -> Shared) + # appears once per occurrence in the rendered tree — each occurrence with + # the prefix of ITS OWN position. Prefix data must be per-node, not keyed + # by task class (a later registration must not clobber an earlier one). + def test_render_tree_with_shared_dependency_renders_each_occurrence_with_its_own_prefix + shared = stub_task_class("SharedTask") + left = stub_task_class_with_deps("LeftTask", [shared]) + root = stub_task_class_with_deps("RootTask", [left, shared]) + + ctx = mock_execution_facade(root_task_class: root) + @layout.context = ctx + @layout.on_ready + + assert_equal [ + "○ RootTask", + "├── ○ LeftTask", + "│ └── ○ SharedTask", + "└── ○ SharedTask" + ], @layout.render_tree.lines.map(&:chomp) + end + + # Event-mode per-event lines use one canonical prefix for a shared task: + # its first occurrence in the tree (the position it is first printed at). + def test_event_output_for_shared_dependency_uses_first_occurrence_prefix + shared = stub_task_class("SharedTask") + left = stub_task_class_with_deps("LeftTask", [shared]) + root = stub_task_class_with_deps("RootTask", [left, shared]) + + ctx = mock_execution_facade(root_task_class: root) + @layout.context = ctx + @layout.on_ready + @layout.on_start + @layout.on_task_updated(shared, previous_state: :pending, current_state: :running, phase: :run, timestamp: Time.now) + @layout.on_stop + + assert_includes @output.string, "│ └── ⠋ SharedTask" + end + + # A circular graph renders the cycle as a childless leaf occurrence. When + # the ROOT class reappears (Root -> A -> Root), the class-keyed node map + # must keep the ROOT occurrence (the first registration), not the circular + # leaf — otherwise build_tree_lines walks the leaf and the whole tree + # collapses to a single orphan line. + def test_render_tree_with_root_cycle_renders_full_tree_from_root_occurrence + a = stub_task_class("ATask") + root = stub_task_class_with_deps("RootTask", [a]) + a.define_singleton_method(:cached_dependencies) { [root] } + + ctx = mock_execution_facade(root_task_class: root) + @layout.context = ctx + @layout.on_ready + + assert_equal [ + "○ RootTask", + "└── ○ ATask", + " └── ○ RootTask" + ], @layout.render_tree.lines.map(&:chomp) + end + private def stub_task_class(name)