Skip to content

Fix HTML report sort to also reorder groups#5103

Merged
thomhurst merged 1 commit intomainfrom
fix/html-report-sort-groups
Mar 7, 2026
Merged

Fix HTML report sort to also reorder groups#5103
thomhurst merged 1 commit intomainfrom
fix/html-report-sort-groups

Conversation

@thomhurst
Copy link
Owner

Summary

  • Sort now applies to groups too: Previously, the sort toggle (Default/Duration/Name) only sorted tests within each group but left the groups themselves in their original order. Now groups are also sorted — by slowest test (duration) or alphabetically (name).
  • Fixed favicon 404 errors: Added an empty <link rel="icon"> to prevent browsers from requesting /favicon.ico when the report is hosted remotely.

Test plan

  • Generate an HTML report with multiple test classes
  • Verify "Duration" sort orders groups by their slowest test (descending) and tests within groups by duration
  • Verify "Name" sort orders groups alphabetically and tests within groups alphabetically
  • Verify "Default" sort preserves original group order
  • Verify no favicon 404 in browser console

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Overall this is a clean, focused fix that addresses two separate issues in a minimal diff. Good work.


Favicon Fix

sb.AppendLine("<link rel=\"icon\" href=\"data:,\">");

This is the canonical trick for suppressing favicon 404s. data:, is a valid empty data URI — correct and well-known.


sortGroups() — Logic & Correctness

The function is sound: it spreads the array before sorting to avoid mutating the original, handles all three sort modes, and returns the original reference for the default case (no unnecessary copy). The group duration sort using the max test duration is reasonable since the PR description explicitly calls this out as "slowest test (descending)".

One edge case worth noting: if a group has no tests, Math.max.apply(null, []) returns -Infinity, which is harmless (empty groups sort to the end), but it could be made explicit with a guard.


Style Inconsistency — function vs Arrow Functions

The map callbacks inside sortGroups use the old function keyword while the sort comparator already uses arrow syntax:

// Current — mixed styles
a.tests.map(function(t){return t.durationMs})

// Prefer — consistent arrow functions
a.tests.map(t => t.durationMs)

Since this file already uses arrow functions extensively in the surrounding JS, adopting them here keeps things consistent. Also consider Math.max(...arr) instead of Math.max.apply(null, arr) for the same reason.

Suggested cleaner version:

function sortGroups(grps) {
    if (sortMode === 'duration') {
        return [...grps].sort((a, b) => {
            const aMax = Math.max(...a.tests.map(t => t.durationMs));
            const bMax = Math.max(...b.tests.map(t => t.durationMs));
            return bMax - aMax;
        });
    }
    if (sortMode === 'name') return [...grps].sort((a, b) => a.label.localeCompare(b.label));
    return grps;
}

Filter vs Sort Ordering

sortGroups is called on the unfiltered groups (before the filter is applied inside render()), while sortTests operates on the already-filtered test list. This is actually the correct behaviour — group sort order should remain stable regardless of which tests are currently visible — but it's a subtle point worth keeping in mind if filter logic evolves.


Summary

Area Assessment
Favicon fix Correct
Group sorting logic Correct and non-mutating
Arrow function consistency Minor — prefer arrow style throughout
Empty group edge case Harmless, could be guarded explicitly
Filter/sort interaction Correct by design

The changes are minimal and well-scoped to the problem. The only suggestion is the style consistency in the JS map callbacks — not a blocker, but worth a quick fix to keep the generated JS idiomatic.

@thomhurst thomhurst force-pushed the fix/html-report-sort-groups branch from bfa2b65 to 0e620a2 Compare March 7, 2026 17:44
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Code Review

This review compares the current PR state against the previous review.

Previous Review Issues — All Addressed ✓

The prior review flagged three points. The current commit resolves all of them:

Previous Concern Status
Use arrow functions consistently ✅ Fixed — grps.map(g => ...) and g.tests.map(t => t.durationMs) already use arrow syntax
Use Math.max(...arr) instead of Math.max.apply() ✅ Fixed — spread syntax used: Math.max(...g.tests.map(t => t.durationMs))
Empty group edge case ✅ Fixed — g.tests.length ? Math.max(...) : 0 guards empty arrays

Current Code Quality

The pre-computation of max durations into a before sorting is a good pattern — it avoids recomputing on every comparison step (which in a naive implementation would be O(n²) across all tests). Concise and correct.

One Minor Observation (non-blocking)

The Map uses group object references as keys. This works correctly here because entries are object references that remain stable during the sort. Worth a comment only as a heads-up, not a concern.

Favicon Fix

Still the right approach — is the canonical empty data URI for suppressing favicon 404s. No changes needed.


The PR is in good shape. All feedback from the previous review has been incorporated. Ready to merge.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Code Review

This review compares the current PR state against the previous review.

Previous Review Issues — All Addressed ✓

The prior review flagged three points. The current commit resolves all of them:

Previous Concern Status
Use arrow functions consistently ✅ Fixed — grps.map(g => ...) and g.tests.map(t => t.durationMs) already use arrow syntax
Use Math.max(...arr) instead of Math.max.apply() ✅ Fixed — spread syntax used: Math.max(...g.tests.map(t => t.durationMs))
Empty group edge case ✅ Fixed — g.tests.length ? Math.max(...) : 0 guards empty arrays

Current Code Quality

The pre-computation of max durations into a Map before sorting is a notably good pattern:

const maxDur = new Map(grps.map(g => [g, g.tests.length ? Math.max(...g.tests.map(t => t.durationMs)) : 0]));
return [...grps].sort((a,b) => maxDur.get(b) - maxDur.get(a));

A naive implementation would call Math.max on every comparison step, making the sort O(n × m log n) where m is the average number of tests per group. Pre-computing into a Map reduces that to O(n × m) upfront + O(n log n) for the sort — clearly better for groups with many tests.

The Map keyed on object references works correctly here because group object identity is stable throughout the sort.

Favicon Fix

sb.AppendLine("<link rel=\"icon\" href=\"data:,\">");

Still the right approach — data:, is the canonical empty data URI for suppressing favicon 404s.


All feedback from the previous review has been incorporated. The code is clean, correct, and well-optimised. Ready to merge.

@thomhurst thomhurst force-pushed the fix/html-report-sort-groups branch from 0e620a2 to dfc9684 Compare March 7, 2026 17:48
The sort toggle in the HTML report only sorted tests within each group
but never reordered the groups themselves. Added sortGroups() to also
sort groups by duration (slowest first) or name (alphabetical).

Also added an empty favicon link to prevent 404 errors when the report
is served from a remote host.
@thomhurst thomhurst force-pushed the fix/html-report-sort-groups branch from dfc9684 to 9140f1e Compare March 7, 2026 17:49
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Code Review

Reviewing against the previous two rounds of feedback (last review at commit 0e620a22511cc3c916a90fba5df7f6709b5bc364).

Previous Review Issues — All Addressed ✓

All three points from the original review (arrow functions, spread syntax, empty-group guard) were confirmed resolved in the prior follow-up. No regression on those.


New Finding: Default Sort Behaviour Contradicts PR Description

The sortGroups fall-through case (when sortMode === 'default') now sorts groups by worst status, not by original insertion order:

// sortMode defaults to 'default' (line 1146)
// Falls through to here:
const minStatus = new Map(grps.map(g => [g, g.tests.length ? Math.min(...g.tests.map(t => statusOrder[t.status] ?? 9)) : 9]));
return [...grps].sort((a,b) => minStatus.get(a) - minStatus.get(b));

But the PR description's test plan explicitly states:

Verify "Default" sort preserves original group order

These are contradictory. The code does NOT preserve original order in default mode — it surfaces groups with failing tests first. One of two things is true:

  1. The test plan is stale and the status-based default sort is intentional (arguably a useful UX improvement — failing groups float to the top automatically). If so, the Summary and test plan should be updated to reflect this.
  2. The behaviour is unintentional and the default case should return grps unchanged (matching the original behaviour before this PR).

The intent should be clarified. If option 1 is correct, the implementation is fine — pre-computing the Map before sorting is the right pattern, and using ?? 9 is actually more correct than the existing sortTests which uses || 9 (which would incorrectly treat status order 0 — failed/error — as falsy and fall back to 9).


Minor: Inconsistency With sortTests Nullish Coalescing

// New code (sortGroups) — correct
statusOrder[t.status] ?? 9

// Existing code (sortTests, line 1208) — latent bug
(statusOrder[a.status]||9)

statusOrder.failed = 0 and statusOrder.error = 0. With || 9, failed/error tests evaluate as 0 || 9 = 9 — which accidentally places them last in the test sort. The new ?? 9 avoids this. This is a pre-existing issue, but now that we have the correct pattern in sortGroups, it may be worth fixing sortTests for consistency (or at least filing a follow-up). Not a blocker for this PR.


Summary

Area Assessment
Duration sort ✅ Correct, well-optimised with pre-computed Map
Name sort ✅ Correct
Default sort ⚠️ Behaviour change not described accurately in PR — needs clarification
Favicon fix ✅ Correct
Arrow functions / spread / empty guard ✅ All addressed

The implementation is otherwise clean. The only blocker is clarifying whether the default-mode status sort is intentional, and updating the PR description/test plan accordingly.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review (Follow-up)

Comparing against previous reviews — all prior concerns have been addressed.

Changes at a glance

Change Assessment
<link rel=\"icon\" href=\"data:,\"> favicon fix Correct — canonical empty data URI, suppresses 404s
||9??9 in sortTests Good — ?? is semantically correct (falsy 0 would be mishandled by ||, though not a real issue here since status keys are strings)
New sortGroups() function Well-implemented (see below)
sortGroups(computeDisplayGroups()) in render() Correct integration point

sortGroups — Final Assessment

The implementation is clean and correct:

  • Duration sort: Pre-computes maxDur into a Map before sorting — avoids recomputing Math.max on every comparator call (O(n×m) upfront vs O(n×m×log n) naive). Group object references are stable during the sort, so Map keying on object identity is safe.
  • Name sort: Simple localeCompare — correct.
  • Default sort: minStatus approach (group ordered by its worst-status test) is a sensible and consistent choice that mirrors the test-level behaviour.
  • Empty group guard: g.tests.length ? ... : 0 correctly handles empty groups for both maxDur and minStatus.
  • Non-mutating: All paths spread before sorting.

No further issues found. The PR is clean, focused, and correct. Ready to merge.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review (Follow-up)

Comparing against previous reviews — all prior concerns have been addressed.

Changes at a glance

Change Assessment
<link rel="icon" href="data:,"> favicon fix Correct — canonical empty data URI, suppresses 404s
`
New sortGroups() function Well-implemented (see below)
sortGroups(computeDisplayGroups()) in render() Correct integration point

sortGroups — Final Assessment

The implementation is clean and correct:

  • Duration sort: Pre-computes maxDur into a Map before sorting — avoids recomputing Math.max on every comparator call (O(n×m) upfront vs O(n×m×log n) naive). Group object references are stable during the sort, so Map keying on object identity is safe.
  • Name sort: Simple localeCompare — correct.
  • Default sort: minStatus approach (group ordered by its worst-status test) is a sensible and consistent choice that mirrors the test-level behaviour.
  • Empty group guard: g.tests.length ? ... : 0 correctly handles empty groups for both maxDur and minStatus.
  • Non-mutating: All paths spread before sorting.

No further issues found. The PR is clean, focused, and correct. Ready to merge.

intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Mar 9, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.18.37 to
1.19.16.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.19.16

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.16 -->

## What's Changed
### Other Changes
* Truncate exceptions in GitHub summary tables by @​thomhurst in
thomhurst/TUnit#5108
### Dependencies
* chore(deps): update tunit to 1.19.11 by @​thomhurst in
thomhurst/TUnit#5106
* chore(deps): bump dompurify from 3.3.0 to 3.3.2 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5096
* chore(deps): bump svgo from 3.2.0 to 3.3.3 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5086


**Full Changelog**:
thomhurst/TUnit@v1.19.11...v1.19.16

## 1.19.11

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.11 -->

## What's Changed
### Other Changes
* Fix HTML report sort to also reorder groups by @​thomhurst in
thomhurst/TUnit#5103
* fix: correct expand-all icon SVG in HTML report by @​slang25 in
thomhurst/TUnit#5105
* Avoid some redundant list allocations by @​SimonCropp in
thomhurst/TUnit#4963
* avoid some redundant enumerable alloc by @​SimonCropp in
thomhurst/TUnit#4972
* use char instead of string for joins by @​SimonCropp in
thomhurst/TUnit#4989
### Dependencies
* chore(deps): update dependency nunit to 4.5.1 by @​thomhurst in
thomhurst/TUnit#5097
* chore(deps): update tunit to 1.19.0 by @​thomhurst in
thomhurst/TUnit#5099
* chore(deps): update dependency humanizer to 3.0.10 by @​thomhurst in
thomhurst/TUnit#5101
* chore(deps): update dependency nunit.analyzers to 4.12.0 by
@​thomhurst in thomhurst/TUnit#5102


**Full Changelog**:
thomhurst/TUnit@v1.19.0...v1.19.11

## 1.19.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.0 -->

## What's Changed
### Other Changes
* fix: improve CreateTestVariant API and fix void/ValueTask return types
by @​thomhurst in thomhurst/TUnit#5095
### Dependencies
* chore(deps): update tunit to 1.18.37 by @​thomhurst in
thomhurst/TUnit#5094


**Full Changelog**:
thomhurst/TUnit@v1.18.37...v1.19.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.18.37...v1.19.16).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.18.37&new-version=1.19.16)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This was referenced Mar 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant