Skip to content

Commit b2e372f

Browse files
committed
test(dashboard): component & page coverage
Specs for core primitives (incl. local_time / future relative_time), DataTable, LogLine, Timeline, flow graph, workflow tabs and routing.
1 parent 58fba94 commit b2e372f

8 files changed

Lines changed: 563 additions & 17 deletions

File tree

durable_dashboard/test/durable_dashboard/components/core_test.exs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,28 @@ defmodule DurableDashboard.Components.CoreTest do
108108
assert html =~ "2m ago"
109109
end
110110

111-
test "renders em-dash for nil" do
111+
test "renders 'in …' for future timestamps, not 'just now'" do
112+
future = DateTime.add(DateTime.utc_now(), 2 * 3600, :second)
113+
assigns = %{at: future}
114+
html = rendered_to_string(~H[<Core.relative_time at={@at} />])
115+
assert html =~ "in 2h"
116+
refute html =~ "just now"
117+
end
118+
119+
test "emits <time data-ts data-rel> so the client localizes the tooltip" do
120+
now = DateTime.utc_now()
121+
assigns = %{at: now}
122+
html = rendered_to_string(~H[<Core.relative_time at={@at} />])
123+
assert html =~ "<time"
124+
assert html =~ ~s(data-rel="1")
125+
assert html =~ ~s(data-ts="#{DateTime.to_iso8601(now)}")
126+
end
127+
128+
test "renders em-dash for nil, without a data-ts to localize" do
112129
assigns = %{at: nil}
113130
html = rendered_to_string(~H[<Core.relative_time at={@at} />])
114131
assert html =~ "—"
132+
refute html =~ "data-ts"
115133
end
116134
end
117135

@@ -183,4 +201,40 @@ defmodule DurableDashboard.Components.CoreTest do
183201
assert html =~ "abc12345"
184202
end
185203
end
204+
205+
describe "local_time/1" do
206+
test "emits a <time> carrying the UTC ISO for the client to localize" do
207+
assigns = %{at: ~U[2026-06-23 11:39:40.328499Z]}
208+
html = rendered_to_string(~H[<Core.local_time at={@at} format="datetime" />])
209+
210+
assert html =~ "<time"
211+
# The client hook reads data-ts (UTC ISO) and data-format.
212+
assert html =~ ~s(data-ts="2026-06-23T11:39:40.328499Z")
213+
assert html =~ ~s(data-format="datetime")
214+
# A UTC fallback renders so it's legible before/without JS.
215+
assert html =~ "Jun 23, 2026"
216+
end
217+
218+
test "the time format renders a ms-precision time-of-day fallback" do
219+
assigns = %{at: ~U[2026-06-23 11:39:40.328499Z]}
220+
html = rendered_to_string(~H[<Core.local_time at={@at} format="time" />])
221+
222+
assert html =~ ~s(data-format="time")
223+
assert html =~ "11:39:40.328"
224+
end
225+
226+
test "accepts an ISO string and normalizes it to data-ts" do
227+
assigns = %{at: "2026-06-23T11:39:40Z"}
228+
html = rendered_to_string(~H[<Core.local_time at={@at} format="datetime" />])
229+
230+
assert html =~ ~s(data-ts="2026-06-23T11:39:40Z")
231+
end
232+
233+
test "renders an em dash for a nil timestamp" do
234+
assigns = %{at: nil}
235+
html = rendered_to_string(~H[<Core.local_time at={@at} />])
236+
237+
assert html =~ "—"
238+
end
239+
end
186240
end

durable_dashboard/test/durable_dashboard/components/flow_graph_test.exs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,21 @@ defmodule DurableDashboard.Components.FlowGraphTest do
206206
assert status_for(nodes, "parallel_200__do_b") == "failed"
207207
end
208208

209+
test "a step carrying child_workflow_id flips to the child_workflow node type" do
210+
definition = simple_workflow()
211+
graph = GraphBuilder.build(definition)
212+
213+
execs = [
214+
step_execution(name: "register", status: :completed, child_workflow_id: "child-abc")
215+
]
216+
217+
%{nodes: nodes} = GraphBuilder.overlay_status(graph, execs)
218+
node = Enum.find(nodes, &(&1.id == "register"))
219+
220+
assert node.type == "child_workflow"
221+
assert node.data.child_workflow_id == "child-abc"
222+
end
223+
209224
test "top-level step names continue to match by id" do
210225
definition = simple_workflow()
211226
graph = GraphBuilder.build(definition)
@@ -244,6 +259,41 @@ defmodule DurableDashboard.Components.FlowGraphTest do
244259
end
245260
end
246261

262+
# ============================================================================
263+
# normalize_edge — runtime edge styling must survive serialization
264+
# ============================================================================
265+
266+
describe "FlowGraph data-graph — edge className/label reach the client (regression)" do
267+
test "a running step's inbound edge keeps its flow-edge-running className" do
268+
# b is running, so graph_builder paints the a->b edge flow-edge-running.
269+
# Before the fix, normalize_edge/1 dropped :className (and :label), so
270+
# the client never received any edge status — the graph never reflected
271+
# execution. This asserts the styling reaches the serialized data-graph.
272+
steps = [
273+
step_execution(name: "a", status: :completed, duration_ms: 5),
274+
step_execution(name: "b", status: :running)
275+
]
276+
277+
html =
278+
render_component(FlowGraph,
279+
id: "fg-edge",
280+
kind: :flow,
281+
workflow: %{
282+
id: "wf-1",
283+
workflow_module: Atom.to_string(DurableDashboard.Components.FlowGraphTestWorkflow),
284+
workflow_name: "fg_test"
285+
},
286+
steps: steps
287+
)
288+
289+
assert html =~ ~s(phx-hook="FlowGraph")
290+
assert html =~ "flow-edge-running"
291+
# The serialized edge shape now carries the className + label keys.
292+
assert html =~ "className"
293+
assert html =~ "label"
294+
end
295+
end
296+
247297
# ============================================================================
248298
# Test fixtures
249299
# ============================================================================
@@ -407,6 +457,7 @@ defmodule DurableDashboard.Components.FlowGraphTest do
407457
duration_ms: Keyword.get(opts, :duration_ms),
408458
started_at: Keyword.get(opts, :started_at),
409459
completed_at: Keyword.get(opts, :completed_at),
460+
child_workflow_id: Keyword.get(opts, :child_workflow_id),
410461
inserted_at: Keyword.get(opts, :inserted_at, DateTime.utc_now())
411462
}
412463
end
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
defmodule DurableDashboard.Components.LogLineTest do
2+
@moduledoc """
3+
Unit coverage for the shared `LogLine` component — message classification
4+
(JSON / embedded Elixir map / plain text) and the rendered row markup.
5+
"""
6+
7+
use ExUnit.Case, async: true
8+
9+
import Phoenix.LiveViewTest
10+
11+
alias DurableDashboard.Components.Workflow.LogLine
12+
13+
describe "parse_message/1" do
14+
test "classifies a whole-message JSON object as a term" do
15+
assert {:term, %{"json" => 1}} = LogLine.parse_message(~S({"json": 1}))
16+
end
17+
18+
test "classifies a whole-message Elixir map (string keys, =>) as a term" do
19+
msg = ~S(%{"a" => 1, "b" => 2.5})
20+
assert {:term, %{"a" => 1, "b" => 2.5}} = LogLine.parse_message(msg)
21+
end
22+
23+
test "handles atom keys, nesting, lists and booleans in an Elixir map" do
24+
assert {:term, %{:b => 2, :c => [1, 2, %{d: true}], "a" => 1}} =
25+
LogLine.parse_message(~S|%{"a" => 1, b: 2, c: [1, 2, %{d: true}]}|)
26+
end
27+
28+
test "splits a 'label: %{...}' message into a prefix + parsed map" do
29+
msg =
30+
~S|[Cron] Metrics gathered: %{"active_users" => 491, "error_rate" => 0.046}|
31+
32+
assert {:prefix_term, "[Cron] Metrics gathered:",
33+
%{"active_users" => 491, "error_rate" => 0.046}} = LogLine.parse_message(msg)
34+
end
35+
36+
test "falls back to plain text for non-literal terms (function calls)" do
37+
assert :text = LogLine.parse_message(~S|result: %{pid: foo()}|)
38+
end
39+
40+
test "plain text stays text" do
41+
assert :text = LogLine.parse_message("just a regular log line")
42+
end
43+
44+
test "a leading [tag] alone is never mistaken for a list literal" do
45+
assert :text = LogLine.parse_message("[Cron] nothing structured here")
46+
end
47+
end
48+
49+
describe "row/1 rendering" do
50+
defp render_entry(entry, opts \\ []) do
51+
assigns = Map.merge(%{entry: entry, show_step: true}, Map.new(opts))
52+
render_component(&LogLine.row/1, assigns)
53+
end
54+
55+
test "an embedded Elixir map is syntax-highlighted in the expanded block" do
56+
html =
57+
render_entry(%{
58+
"level" => "info",
59+
"message" =>
60+
~S|[Cron] Metrics gathered: %{"active_users" => 491, "error_rate" => 0.046}|,
61+
"timestamp" => "2026-05-04T06:21:04Z"
62+
})
63+
64+
# The text prefix renders, and the map keys/values are highlighted spans.
65+
assert html =~ "[Cron] Metrics gathered:"
66+
assert html =~ ~s(<span class="text-primary">&quot;active_users&quot;</span>)
67+
assert html =~ ~s(<span class="text-warning">491</span>)
68+
end
69+
70+
test "show_step={false} suppresses the summary step column (detail still lists it)" do
71+
entry = %{"level" => "info", "message" => "hi", "__step__" => "gather", "timestamp" => nil}
72+
with_step = render_entry(entry, show_step: true)
73+
without_step = render_entry(entry, show_step: false)
74+
75+
# The summary-line step column (its distinctive max-width class) only
76+
# appears when show_step is true...
77+
assert with_step =~ "md:max-w-[150px]"
78+
refute without_step =~ "md:max-w-[150px]"
79+
80+
# ...but the expanded fields table lists the step either way.
81+
assert without_step =~ ">step</span>"
82+
assert without_step =~ "gather"
83+
end
84+
85+
test "level drives the semantic color and left accent" do
86+
html =
87+
render_entry(%{"level" => "error", "message" => "boom", "timestamp" => nil})
88+
89+
assert html =~ "text-destructive"
90+
assert html =~ "border-l-destructive"
91+
end
92+
end
93+
end

durable_dashboard/test/durable_dashboard/components/logs_tab_test.exs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,121 @@ defmodule DurableDashboard.Components.LogsTabTest do
107107
assert html =~ "text-warning"
108108
assert html =~ "text-info"
109109
end
110+
111+
test "error and warning rows get a tinted left accent" do
112+
html =
113+
render_component(LogsTab,
114+
id: "logs-tint",
115+
steps: sample_steps_with_logs()
116+
)
117+
118+
assert html =~ "border-l-destructive"
119+
assert html =~ "bg-destructive/5"
120+
assert html =~ "border-l-warning"
121+
end
122+
123+
test "a JSON message shows compact inline and pretty-prints in the expanded fields table" do
124+
now = DateTime.utc_now() |> DateTime.to_iso8601()
125+
126+
steps = [
127+
%{
128+
id: "s-json",
129+
step_name: "emit",
130+
logs: [
131+
%{
132+
"level" => "info",
133+
"message" => ~s({"order_id":42,"total":9.99}),
134+
"timestamp" => now
135+
}
136+
]
137+
}
138+
]
139+
140+
html = render_component(LogsTab, id: "logs-json", steps: steps)
141+
142+
assert html =~ "<details"
143+
assert html =~ "order_id"
144+
# Syntax-highlighted in the expanded message block: the key renders in the
145+
# primary color, the numeric value in the warning color (separate spans).
146+
assert html =~ ~s(<span class="text-primary">&quot;total&quot;</span>)
147+
assert html =~ ~s(<span class="text-warning">9.99</span>)
148+
end
149+
150+
test "an embedded Elixir map in the message is highlighted, not dumped as text" do
151+
now = DateTime.utc_now() |> DateTime.to_iso8601()
152+
153+
steps = [
154+
%{
155+
id: "s-elixir",
156+
step_name: "gather",
157+
logs: [
158+
%{
159+
"level" => "info",
160+
"message" =>
161+
~S|[Cron] Metrics gathered: %{"active_users" => 491, "error_rate" => 0.046}|,
162+
"timestamp" => now
163+
}
164+
]
165+
}
166+
]
167+
168+
html = render_component(LogsTab, id: "logs-elixir", steps: steps)
169+
170+
# The text prefix survives, and the map is syntax-highlighted (separate
171+
# spans), not rendered as a flat `%{...}` blob.
172+
assert html =~ "[Cron] Metrics gathered:"
173+
assert html =~ ~s(<span class="text-primary">&quot;active_users&quot;</span>)
174+
assert html =~ ~s(<span class="text-warning">491</span>)
175+
end
176+
177+
test "metadata: meaningful keys inline (key=value), source noise filtered, all keys in detail" do
178+
now = DateTime.utc_now() |> DateTime.to_iso8601()
179+
180+
steps = [
181+
%{
182+
id: "s-meta",
183+
step_name: "emit",
184+
logs: [
185+
%{
186+
"level" => "info",
187+
"message" => "plain text line",
188+
"timestamp" => now,
189+
"metadata" => %{"request_id" => "req-abc", "user_id" => 7, "line" => 33}
190+
}
191+
]
192+
}
193+
]
194+
195+
html = render_component(LogsTab, id: "logs-meta", steps: steps)
196+
197+
# Inline labels surface meaningful keys, not source-location noise.
198+
assert html =~ "request_id=req-abc"
199+
assert html =~ "user_id=7"
200+
refute html =~ "line=33"
201+
202+
# The message renders in its own labeled block, and the expanded fields
203+
# table (a clean key/value grid, not a JSON box) DOES include the
204+
# noise-filtered source field.
205+
assert html =~ "plain text line"
206+
assert html =~ ">line</span>"
207+
end
208+
209+
test "each line is an expandable detail row with a fields table" do
210+
html = render_component(LogsTab, id: "logs-plain", steps: sample_steps_with_logs())
211+
212+
assert html =~ "<details"
213+
# The expanded detail renders a key/value fields table with labeled keys.
214+
assert html =~ ">level</span>"
215+
end
216+
217+
test "renders the timestamp sort toggle and a pager count" do
218+
html = render_component(LogsTab, id: "logs-sort", steps: sample_steps_with_logs())
219+
220+
# Sort control (defaults to ascending).
221+
assert html =~ "sort:toggle"
222+
assert html =~ "Time"
223+
224+
# Pager shows the visible range over the total (4 entries in the sample).
225+
assert html =~ "of 4"
226+
end
110227
end

0 commit comments

Comments
 (0)