Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 21 additions & 17 deletions lib/taski/progress/layout/tree/structure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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|
Expand All @@ -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|
Expand All @@ -116,24 +122,22 @@ 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
ancestors_last[1..].each do |ancestor_is_last|
prefix += ancestor_is_last ? SPACE : VERTICAL
end

prefix += is_last ? LAST_BRANCH : BRANCH
prefix
prefix + (is_last ? LAST_BRANCH : BRANCH)
end
end
end
Expand Down
59 changes: 59 additions & 0 deletions test/test_layout_tree_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading