From 0d6de490a4d5d7bc708979d2f75fdb20a80d0f38 Mon Sep 17 00:00:00 2001 From: Dave Aitken Date: Tue, 24 Feb 2026 11:06:49 +0000 Subject: [PATCH] feat(checks): collapsible sub-sections for CI checks by category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a status category (failed, in_progress, successful, pending, skipped) has more than 5 checks, group them into a collapsible sub-section with an indented header. Categories with ≤5 checks render flat as before. Tab on a sub-section header toggles just that category; gj/gk navigate to sub-section headers. --- lua/gitlad/ui/components/checks.lua | 109 ++++++- lua/gitlad/ui/components/comment.lua | 8 +- lua/gitlad/ui/views/pr_detail.lua | 20 +- tests/e2e/test_pr_detail.lua | 182 +++++++++++ .../github/pr_detail_many_checks.json | 162 ++++++++++ tests/unit/test_checks_component.lua | 299 +++++++++++++++++- 6 files changed, 758 insertions(+), 22 deletions(-) create mode 100644 tests/fixtures/github/pr_detail_many_checks.json diff --git a/lua/gitlad/ui/components/checks.lua b/lua/gitlad/ui/components/checks.lua index 19543cf..d9e7ada 100644 --- a/lua/gitlad/ui/components/checks.lua +++ b/lua/gitlad/ui/components/checks.lua @@ -10,16 +10,73 @@ local types = require("gitlad.forge.types") ---@class ChecksRenderOptions ---@field collapsed? boolean Whether the section is collapsed (default: false) +---@field sub_collapsed? table Per-category collapsed state +---@field sub_threshold? number Min checks to trigger sub-section (default: 5) ---@class CheckLineInfo ---@field type string Line type discriminator ---@field check? ForgeCheck Check reference for check lines +---@field sub_category? string Category key for sub-section headers ---@class ChecksRenderResult ---@field lines string[] Formatted lines ---@field line_info table Maps line index (1-based) to line metadata ---@field ranges table Named ranges +-- Category definitions in display order +M.categories = { + { key = "failed", label = "Failed" }, + { key = "in_progress", label = "In progress" }, + { key = "successful", label = "Successful" }, + { key = "pending", label = "Pending" }, + { key = "skipped", label = "Skipped" }, +} + +--- Classify a check into a category key +---@param check ForgeCheck +---@return string category One of "failed", "in_progress", "successful", "pending", "skipped" +function M.classify_check(check) + if check.status == "in_progress" then + return "in_progress" + end + if check.status == "queued" then + return "pending" + end + if check.status == "completed" then + local c = check.conclusion + if c == "failure" or c == "timed_out" or c == "startup_failure" then + return "failed" + elseif c == "success" then + return "successful" + elseif c == "action_required" then + return "pending" + elseif c == "cancelled" or c == "skipped" or c == "neutral" then + return "skipped" + end + end + return "pending" +end + +--- Format a single check line +---@param check ForgeCheck +---@param indent string Indentation prefix +---@return string +local function format_check_line(check, indent) + local icon, _ = types.format_check_icon(check) + local parts = { indent .. icon .. " " .. check.name } + + if check.app_name then + table.insert(parts, " (" .. check.app_name .. ")") + end + + local duration = types.format_check_duration(check.started_at, check.completed_at) + if duration ~= "" then + table.insert(parts, " " .. duration) + end + + return table.concat(parts) +end + --- Render a checks section ---@param checks_summary ForgeChecksSummary ---@param opts? ChecksRenderOptions @@ -27,6 +84,8 @@ local types = require("gitlad.forge.types") function M.render(checks_summary, opts) opts = opts or {} local collapsed = opts.collapsed or false + local sub_collapsed = opts.sub_collapsed or {} + local threshold = opts.sub_threshold or 5 local result = { lines = {}, @@ -53,24 +112,44 @@ function M.render(checks_summary, opts) return result end - -- Individual check lines - local checks_start = #result.lines + 1 + -- Classify checks into category buckets + local buckets = {} + for _, cat in ipairs(M.categories) do + buckets[cat.key] = {} + end for _, check in ipairs(checks_summary.checks) do - local icon, _ = types.format_check_icon(check) - local parts = { " " .. icon .. " " .. check.name } - - -- App name in parentheses - if check.app_name then - table.insert(parts, " (" .. check.app_name .. ")") + local cat = M.classify_check(check) + if buckets[cat] then + table.insert(buckets[cat], check) end + end - -- Duration - local duration = types.format_check_duration(check.started_at, check.completed_at) - if duration ~= "" then - table.insert(parts, " " .. duration) + -- Render each category + local checks_start = #result.lines + 1 + for _, cat in ipairs(M.categories) do + local checks = buckets[cat.key] + if #checks > 0 then + if #checks > threshold then + -- Sub-section with header + local is_collapsed = sub_collapsed[cat.key] or false + local sub_indicator = is_collapsed and ">" or "v" + local sub_header = " " .. sub_indicator .. " " .. cat.label .. " (" .. #checks .. ")" + add_line(sub_header, { type = "checks_sub_header", sub_category = cat.key }) + + local sub_start = #result.lines + if not is_collapsed then + for _, check in ipairs(checks) do + add_line(format_check_line(check, " "), { type = "check", check = check }) + end + end + result.ranges["checks_sub_" .. cat.key] = { start = sub_start, end_line = #result.lines } + else + -- Flat rendering + for _, check in ipairs(checks) do + add_line(format_check_line(check, " "), { type = "check", check = check }) + end + end end - - add_line(table.concat(parts), { type = "check", check = check }) end if #checks_summary.checks > 0 then @@ -100,6 +179,8 @@ function M.apply_highlights(bufnr, ns, start_line, result) if info.type == "checks_header" then hl.set(bufnr, ns, line_idx, 0, #line, "GitladSectionHeader") + elseif info.type == "checks_sub_header" then + hl.set(bufnr, ns, line_idx, 0, #line, "GitladSectionHeader") elseif info.type == "check" and info.check then -- Highlight the icon character based on check state local _, icon_hl = types.format_check_icon(info.check) diff --git a/lua/gitlad/ui/components/comment.lua b/lua/gitlad/ui/components/comment.lua index 59540a4..d3f8626 100644 --- a/lua/gitlad/ui/components/comment.lua +++ b/lua/gitlad/ui/components/comment.lua @@ -12,6 +12,7 @@ local types = require("gitlad.forge.types") ---@class CommentRenderOptions ---@field wrap_width? number Wrap text at this width (default: 80) ---@field checks_collapsed? boolean Whether checks section is collapsed (default: false) +---@field checks_sub_collapsed? table Per-category collapsed state ---@class CommentLineInfo ---@field type string Line type discriminator @@ -144,6 +145,7 @@ function M.render(pr, opts) local checks_component = require("gitlad.ui.components.checks") local checks_result = checks_component.render(pr.checks_summary, { collapsed = opts.checks_collapsed or false, + sub_collapsed = opts.checks_sub_collapsed, }) -- Merge checks lines into result with offset @@ -342,7 +344,11 @@ function M.apply_highlights(bufnr, ns, start_line, result) local _, merge_hl = types.format_merge_status(info.pr.mergeable, info.pr.merge_state_status) hl.set(bufnr, ns, line_idx, #prefix, #line, merge_hl) end - elseif info.type == "checks_header" or info.type == "check" then + elseif + info.type == "checks_header" + or info.type == "checks_sub_header" + or info.type == "check" + then -- Delegate to checks component highlighting local checks_component = require("gitlad.ui.components.checks") -- Build a minimal single-line result for the checks highlighter diff --git a/lua/gitlad/ui/views/pr_detail.lua b/lua/gitlad/ui/views/pr_detail.lua index 88e4496..dfad6a6 100644 --- a/lua/gitlad/ui/views/pr_detail.lua +++ b/lua/gitlad/ui/views/pr_detail.lua @@ -21,6 +21,7 @@ local hl = require("gitlad.ui.hl") ---@field line_map table Map of line numbers to line info ---@field ranges table Named ranges ---@field checks_collapsed boolean Whether checks section is collapsed +---@field checks_sub_collapsed table Per-category collapsed state local PRDetailBuffer = {} PRDetailBuffer.__index = PRDetailBuffer @@ -48,6 +49,7 @@ local function get_or_create_buffer(repo_state, provider) self.line_map = {} self.ranges = {} self.checks_collapsed = false + self.checks_sub_collapsed = {} -- Create buffer self.bufnr = vim.api.nvim_create_buf(false, true) @@ -219,10 +221,13 @@ function PRDetailBuffer:_is_nav_target(line) return true end - -- Checks header is a navigation target + -- Checks header and sub-headers are navigation targets if info.type == "checks_header" then return true end + if info.type == "checks_sub_header" then + return true + end -- Comment/review lines: only the @author header line if info.type == "comment" or info.type == "review" then @@ -287,9 +292,17 @@ function PRDetailBuffer:_action_at_cursor() end end ---- Toggle checks section collapsed/expanded +--- Toggle checks section or sub-section collapsed/expanded function PRDetailBuffer:_toggle_checks() - self.checks_collapsed = not self.checks_collapsed + local info = self:_get_current_info() + if info and info.type == "checks_sub_header" and info.sub_category then + -- Toggle sub-category + local cat = info.sub_category + self.checks_sub_collapsed[cat] = not self.checks_sub_collapsed[cat] + else + -- Toggle whole section + self.checks_collapsed = not self.checks_collapsed + end self:render() end @@ -527,6 +540,7 @@ function PRDetailBuffer:render() local result = comment_component.render(self.pr, { checks_collapsed = self.checks_collapsed, + checks_sub_collapsed = self.checks_sub_collapsed, }) local lines = result.lines self.line_map = result.line_info diff --git a/tests/e2e/test_pr_detail.lua b/tests/e2e/test_pr_detail.lua index 9001faa..be919ac 100644 --- a/tests/e2e/test_pr_detail.lua +++ b/tests/e2e/test_pr_detail.lua @@ -761,4 +761,186 @@ T["pr detail view"]["TAB toggles checks section"] = function() helpers.cleanup_repo(child, repo) end +--- Helper: open PR detail with many-checks fixture (>5 successful → sub-section) +---@param child table +---@param repo string +local function open_pr_detail_with_many_checks(child, repo) + child.lua(string.format( + [[ + vim.cmd("cd %s") + require("gitlad.ui.views.status").open() + ]], + repo + )) + helpers.wait_for_status(child) + + child.lua([[ + local pr_detail_view = require("gitlad.ui.views.pr_detail") + local status_view = require("gitlad.ui.views.status") + local buf = status_view.get_buffer() + + local fixture_path = nil + for _, path in ipairs(vim.api.nvim_list_runtime_paths()) do + local candidate = path .. "/tests/fixtures/github/pr_detail_many_checks.json" + if vim.fn.filereadable(candidate) == 1 then + fixture_path = candidate + break + end + end + + local f = io.open(fixture_path, "r") + local json = f:read("*a") + f:close() + local data = vim.json.decode(json) + + local graphql = require("gitlad.forge.github.graphql") + local pr = graphql.parse_pr_detail(data) + + local mock_provider = { + provider_type = "github", + owner = "testowner", + repo = "testrepo", + host = "github.com", + list_prs = function(self, opts, cb) + vim.schedule(function() cb({}, nil) end) + end, + get_pr = function(self, num, cb) + vim.schedule(function() cb(pr, nil) end) + end, + } + + pr_detail_view.open(buf.repo_state, mock_provider, 42) + ]]) + + helpers.wait_for_buffer(child, "gitlad://pr%-detail") + child.lua([[vim.wait(500, function() return false end)]]) +end + +T["pr detail view"]["shows sub-section header when category has > 5 checks"] = function() + local child = _G.child + local repo = helpers.create_test_repo(child) + helpers.create_file(child, repo, "test.txt", "hello") + helpers.git(child, repo, "add .") + helpers.git(child, repo, "commit -m 'init'") + + open_pr_detail_with_many_checks(child, repo) + + child.lua([[ + _G._test_lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + ]]) + local lines = child.lua_get([[_G._test_lines]]) + + -- Should have a sub-section header for Successful (7 checks > 5 threshold) + local found_sub_header = false + for _, line in ipairs(lines) do + if line:match("Successful") and line:match("%(7%)") then + found_sub_header = true + end + end + eq(found_sub_header, true) + + -- Failed checks (2) should be flat, no sub-header for Failed + local found_failed_sub_header = false + for _, line in ipairs(lines) do + if line:match("Failed") and line:match("%(2%)") then + found_failed_sub_header = true + end + end + eq(found_failed_sub_header, false) + + helpers.cleanup_repo(child, repo) +end + +T["pr detail view"]["Tab on sub-section header toggles that sub-section"] = function() + local child = _G.child + local repo = helpers.create_test_repo(child) + helpers.create_file(child, repo, "test.txt", "hello") + helpers.git(child, repo, "add .") + helpers.git(child, repo, "commit -m 'init'") + + open_pr_detail_with_many_checks(child, repo) + + local initial_count = child.lua_get([[vim.api.nvim_buf_line_count(0)]]) + + -- Navigate to the sub-section header for Successful + child.lua([[ + local pr_detail_view = require("gitlad.ui.views.pr_detail") + local buf = pr_detail_view.get_buffer() + for line_nr, info in pairs(buf.line_map) do + if info.type == "checks_sub_header" and info.sub_category == "successful" then + vim.api.nvim_win_set_cursor(0, {line_nr, 0}) + break + end + end + ]]) + + -- Press Tab to collapse the sub-section + child.type_keys("") + child.lua([[vim.wait(100, function() return false end)]]) + + local collapsed_count = child.lua_get([[vim.api.nvim_buf_line_count(0)]]) + + -- Should have fewer lines (7 check lines hidden) + expect.equality(collapsed_count < initial_count, true) + eq(initial_count - collapsed_count, 7) + + -- Press Tab again to expand + -- Re-navigate to the sub-section header (line numbers may have shifted) + child.lua([[ + local pr_detail_view = require("gitlad.ui.views.pr_detail") + local buf = pr_detail_view.get_buffer() + for line_nr, info in pairs(buf.line_map) do + if info.type == "checks_sub_header" and info.sub_category == "successful" then + vim.api.nvim_win_set_cursor(0, {line_nr, 0}) + break + end + end + ]]) + child.type_keys("") + child.lua([[vim.wait(100, function() return false end)]]) + + local expanded_count = child.lua_get([[vim.api.nvim_buf_line_count(0)]]) + eq(expanded_count, initial_count) + + helpers.cleanup_repo(child, repo) +end + +T["pr detail view"]["gj/gk navigate to sub-section headers"] = function() + local child = _G.child + local repo = helpers.create_test_repo(child) + helpers.create_file(child, repo, "test.txt", "hello") + helpers.git(child, repo, "add .") + helpers.git(child, repo, "commit -m 'init'") + + open_pr_detail_with_many_checks(child, repo) + + -- Navigate forward and check if we visit checks_sub_header + child.lua([[ + local pr_detail_view = require("gitlad.ui.views.pr_detail") + local buf = pr_detail_view.get_buffer() + _G._visited_sub_headers = {} + vim.api.nvim_win_set_cursor(0, {1, 0}) + for i = 1, 20 do + vim.cmd("normal gj") + local line = vim.api.nvim_win_get_cursor(0)[1] + local info = buf.line_map[line] + if info and info.type == "checks_sub_header" then + table.insert(_G._visited_sub_headers, info.sub_category) + end + end + ]]) + local visited = child.lua_get([[_G._visited_sub_headers]]) + + -- Should have visited the successful sub-header + local found_successful = false + for _, cat in ipairs(visited) do + if cat == "successful" then + found_successful = true + end + end + eq(found_successful, true) + + helpers.cleanup_repo(child, repo) +end + return T diff --git a/tests/fixtures/github/pr_detail_many_checks.json b/tests/fixtures/github/pr_detail_many_checks.json new file mode 100644 index 0000000..d8b001b --- /dev/null +++ b/tests/fixtures/github/pr_detail_many_checks.json @@ -0,0 +1,162 @@ +{ + "data": { + "repository": { + "pullRequest": { + "number": 42, + "title": "Fix authentication bug in login flow", + "state": "OPEN", + "isDraft": false, + "author": { + "login": "octocat", + "avatarUrl": "https://avatars.githubusercontent.com/u/1?v=4" + }, + "headRefName": "fix/auth-bug", + "baseRefName": "main", + "reviewDecision": "APPROVED", + "mergeable": "MERGEABLE", + "mergeStateStatus": "CLEAN", + "labels": { + "nodes": [ + {"name": "bug"} + ] + }, + "additions": 10, + "deletions": 3, + "createdAt": "2026-02-19T10:30:00Z", + "updatedAt": "2026-02-20T14:00:00Z", + "url": "https://github.com/owner/repo/pull/42", + "body": "Fixes the authentication bug.", + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "state": "FAILURE", + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "CI / test-unit", + "conclusion": "SUCCESS", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2001", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:02:30Z", + "checkSuite": { "app": { "name": "GitHub Actions" } } + }, + { + "__typename": "CheckRun", + "name": "CI / test-integration", + "conclusion": "SUCCESS", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2002", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:05:00Z", + "checkSuite": { "app": { "name": "GitHub Actions" } } + }, + { + "__typename": "CheckRun", + "name": "CI / test-e2e", + "conclusion": "SUCCESS", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2003", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:08:00Z", + "checkSuite": { "app": { "name": "GitHub Actions" } } + }, + { + "__typename": "CheckRun", + "name": "CI / lint", + "conclusion": "SUCCESS", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2004", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:01:00Z", + "checkSuite": { "app": { "name": "GitHub Actions" } } + }, + { + "__typename": "CheckRun", + "name": "CI / typecheck", + "conclusion": "SUCCESS", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2005", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:01:30Z", + "checkSuite": { "app": { "name": "GitHub Actions" } } + }, + { + "__typename": "CheckRun", + "name": "CI / build", + "conclusion": "SUCCESS", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2006", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:03:00Z", + "checkSuite": { "app": { "name": "GitHub Actions" } } + }, + { + "__typename": "CheckRun", + "name": "deploy-preview", + "conclusion": "FAILURE", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2007", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:05:00Z", + "checkSuite": { "app": { "name": "Vercel" } } + }, + { + "__typename": "CheckRun", + "name": "security-scan", + "conclusion": "FAILURE", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2008", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:04:00Z", + "checkSuite": { "app": { "name": "Snyk" } } + }, + { + "__typename": "CheckRun", + "name": "CI / coverage", + "conclusion": "SUCCESS", + "status": "COMPLETED", + "detailsUrl": "https://github.com/owner/repo/actions/runs/2009", + "startedAt": "2026-02-20T10:00:00Z", + "completedAt": "2026-02-20T10:02:00Z", + "checkSuite": { "app": { "name": "GitHub Actions" } } + } + ] + } + } + } + } + ] + }, + "comments": { + "nodes": [ + { + "id": "IC_1001", + "databaseId": 1001, + "author": { "login": "reviewer" }, + "body": "Looks good.", + "createdAt": "2026-02-19T14:00:00Z", + "updatedAt": "2026-02-19T14:00:00Z" + } + ] + }, + "reviews": { + "nodes": [ + { + "id": "PRR_3001", + "databaseId": 3001, + "author": { "login": "reviewer" }, + "state": "APPROVED", + "body": "LGTM", + "submittedAt": "2026-02-20T10:00:00Z", + "comments": { "nodes": [] } + } + ] + } + } + } + } +} diff --git a/tests/unit/test_checks_component.lua b/tests/unit/test_checks_component.lua index 2bbdb3a..81ee815 100644 --- a/tests/unit/test_checks_component.lua +++ b/tests/unit/test_checks_component.lua @@ -85,8 +85,14 @@ T["render()"]["shows failure icon for failing checks"] = function() }) local result = checks_component.render(summary) - -- The failing check should have ✗ - expect.equality(result.lines[3]:match("✗") ~= nil, true) + -- The failing check should have ✗ (find the line with CI / lint) + local found = false + for _, line in ipairs(result.lines) do + if line:match("CI / lint") and line:match("✗") then + found = true + end + end + eq(found, true) end T["render()"]["shows pending icon for in-progress checks"] = function() @@ -102,8 +108,14 @@ T["render()"]["shows pending icon for in-progress checks"] = function() }) local result = checks_component.render(summary) - -- The pending check should have ○ - expect.equality(result.lines[3]:match("○") ~= nil, true) + -- The in-progress check should have ○ (find the line with CI / lint) + local found = false + for _, line in ipairs(result.lines) do + if line:match("CI / lint") and line:match("○") then + found = true + end + end + eq(found, true) end T["render()"]["shows app name in parentheses"] = function() @@ -190,4 +202,283 @@ T["render()"]["omits duration when timestamps missing"] = function() expect.equality(result.lines[2]:match("%d+[smh]") == nil, true) end +-- ============================================================================= +-- classify_check +-- ============================================================================= + +T["classify_check()"] = MiniTest.new_set() + +T["classify_check()"]["classifies successful checks"] = function() + eq( + checks_component.classify_check(make_check({ status = "completed", conclusion = "success" })), + "successful" + ) +end + +T["classify_check()"]["classifies failed checks"] = function() + eq( + checks_component.classify_check(make_check({ status = "completed", conclusion = "failure" })), + "failed" + ) + eq( + checks_component.classify_check(make_check({ status = "completed", conclusion = "timed_out" })), + "failed" + ) + eq( + checks_component.classify_check( + make_check({ status = "completed", conclusion = "startup_failure" }) + ), + "failed" + ) +end + +T["classify_check()"]["classifies in-progress checks"] = function() + eq( + checks_component.classify_check(make_check({ status = "in_progress", conclusion = nil })), + "in_progress" + ) +end + +T["classify_check()"]["classifies pending checks"] = function() + eq( + checks_component.classify_check(make_check({ status = "queued", conclusion = nil })), + "pending" + ) + eq( + checks_component.classify_check( + make_check({ status = "completed", conclusion = "action_required" }) + ), + "pending" + ) +end + +T["classify_check()"]["classifies skipped checks"] = function() + eq( + checks_component.classify_check(make_check({ status = "completed", conclusion = "cancelled" })), + "skipped" + ) + eq( + checks_component.classify_check(make_check({ status = "completed", conclusion = "skipped" })), + "skipped" + ) + eq( + checks_component.classify_check(make_check({ status = "completed", conclusion = "neutral" })), + "skipped" + ) +end + +-- ============================================================================= +-- sub-sections +-- ============================================================================= + +T["sub-sections"] = MiniTest.new_set() + +--- Helper: make N checks of a given category +---@param n number +---@param category_overrides table Fields to put on each check +---@return ForgeCheck[] +local function make_n_checks(n, category_overrides) + local checks = {} + for i = 1, n do + local name = (category_overrides.name or "check") .. "-" .. i + local c = make_check(vim.tbl_extend("force", category_overrides, { name = name })) + table.insert(checks, c) + end + return checks +end + +T["sub-sections"]["no sub-sections when category has <= 5 checks"] = function() + -- 5 successful checks (at threshold, not above) + local checks = make_n_checks(5, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 5, success = 5 }) + local result = checks_component.render(summary) + + -- No sub-section headers should appear + for _, info in pairs(result.line_info) do + expect.equality(info.type ~= "checks_sub_header", true) + end + + -- All checks rendered flat (2-space indent) + for i = 2, #result.lines do + if result.line_info[i] and result.line_info[i].type == "check" then + expect.equality(result.lines[i]:match("^ ") ~= nil, true) + end + end +end + +T["sub-sections"]["sub-section header when category has > 5 checks"] = function() + -- 6 successful checks (above threshold) + local checks = make_n_checks(6, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 6, success = 6 }) + local result = checks_component.render(summary) + + -- Should have a sub-section header for "successful" + local found_sub_header = false + for _, info in pairs(result.line_info) do + if info.type == "checks_sub_header" and info.sub_category == "successful" then + found_sub_header = true + end + end + eq(found_sub_header, true) + + -- Sub-section header should show category label and count + local sub_header_line = nil + for i, info in pairs(result.line_info) do + if info.type == "checks_sub_header" then + sub_header_line = result.lines[i] + end + end + expect.equality(sub_header_line:match("Successful") ~= nil, true) + expect.equality(sub_header_line:match("%(6%)") ~= nil, true) +end + +T["sub-sections"]["sub-section checks have 4-space indent"] = function() + local checks = make_n_checks(6, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 6, success = 6 }) + local result = checks_component.render(summary) + + for i, info in pairs(result.line_info) do + if info.type == "check" then + -- Should have 4-space indent (inside sub-section) + expect.equality(result.lines[i]:match("^ ") ~= nil, true) + end + end +end + +T["sub-sections"]["collapsed sub-section shows only header"] = function() + local checks = make_n_checks(6, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 6, success = 6 }) + local result = checks_component.render(summary, { sub_collapsed = { successful = true } }) + + -- Header + sub-section header only = 2 lines + eq(#result.lines, 2) + + -- Sub-section header should have > indicator + expect.equality(result.lines[2]:match(">") ~= nil, true) +end + +T["sub-sections"]["expanded sub-section shows v indicator"] = function() + local checks = make_n_checks(6, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 6, success = 6 }) + local result = checks_component.render(summary, { sub_collapsed = { successful = false } }) + + -- Sub-section header should have v indicator + expect.equality(result.lines[2]:match("v") ~= nil, true) +end + +T["sub-sections"]["mixed categories with some grouped some flat"] = function() + -- 2 failed (flat) + 7 successful (grouped) + local failed = make_n_checks(2, { conclusion = "failure" }) + local successful = make_n_checks(7, { conclusion = "success" }) + local all_checks = {} + for _, c in ipairs(failed) do + table.insert(all_checks, c) + end + for _, c in ipairs(successful) do + table.insert(all_checks, c) + end + local summary = make_summary({ checks = all_checks, total = 9, success = 7, failure = 2 }) + local result = checks_component.render(summary) + + -- Failed checks should be flat (2-space indent, no sub-header for failed) + local failed_sub_header = false + local successful_sub_header = false + for _, info in pairs(result.line_info) do + if info.type == "checks_sub_header" then + if info.sub_category == "failed" then + failed_sub_header = true + end + if info.sub_category == "successful" then + successful_sub_header = true + end + end + end + eq(failed_sub_header, false) + eq(successful_sub_header, true) + + -- Total lines: 1 header + 2 flat failed + 1 sub-header + 7 indented = 11 + eq(#result.lines, 11) +end + +T["sub-sections"]["categories render in correct order (failed first)"] = function() + -- Mix of failed, in_progress, successful + local checks = {} + -- 6 successful + for _, c in ipairs(make_n_checks(6, { conclusion = "success" })) do + table.insert(checks, c) + end + -- 6 failed + for _, c in ipairs(make_n_checks(6, { conclusion = "failure" })) do + table.insert(checks, c) + end + -- 2 in_progress + for _, c in ipairs(make_n_checks(2, { status = "in_progress", conclusion = nil })) do + table.insert(checks, c) + end + local summary = + make_summary({ checks = checks, total = 14, success = 6, failure = 6, pending = 2 }) + local result = checks_component.render(summary) + + -- Collect sub-header categories in order + local sub_header_order = {} + -- Also track first check per category + local first_check_category = nil + for i = 2, #result.lines do + local info = result.line_info[i] + if info then + if info.type == "checks_sub_header" then + table.insert(sub_header_order, info.sub_category) + elseif info.type == "check" and not first_check_category then + -- First check line tells us which category rendered first + first_check_category = checks_component.classify_check(info.check) + end + end + end + + -- Failed should be first sub-header, successful second + eq(sub_header_order[1], "failed") + eq(sub_header_order[2], "successful") + + -- First check should be from failed category (since it renders first) + eq(first_check_category, "failed") +end + +T["sub-sections"]["checks_sub_header line_info has correct type and sub_category"] = function() + local checks = make_n_checks(6, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 6, success = 6 }) + local result = checks_component.render(summary) + + for _, info in pairs(result.line_info) do + if info.type == "checks_sub_header" then + eq(info.sub_category, "successful") + return + end + end + -- Should have found one + eq(true, false) +end + +T["sub-sections"]["named ranges include sub-section ranges"] = function() + local checks = make_n_checks(6, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 6, success = 6 }) + local result = checks_component.render(summary) + + expect.equality(result.ranges["checks_sub_successful"] ~= nil, true) +end + +T["sub-sections"]["custom threshold via sub_threshold option"] = function() + -- 4 checks with threshold of 3 → should get sub-section + local checks = make_n_checks(4, { conclusion = "success" }) + local summary = make_summary({ checks = checks, total = 4, success = 4 }) + local result = checks_component.render(summary, { sub_threshold = 3 }) + + local found_sub_header = false + for _, info in pairs(result.line_info) do + if info.type == "checks_sub_header" then + found_sub_header = true + end + end + eq(found_sub_header, true) +end + return T