Skip to content

Commit

Permalink
fix up/down arrow switching based on sort direction in HeatmapTable c…
Browse files Browse the repository at this point in the history
…omponent and add HeatmapTable + ModelCard unit tests

- fix sort indicator arrow logic to correctly reflect sort state
- new HeatmapTable unit tests cover rendering, sorting, data updates, formatting
  • Loading branch information
janosh committed Dec 22, 2024
1 parent 33ef8c6 commit faef34e
Show file tree
Hide file tree
Showing 3 changed files with 362 additions and 13 deletions.
27 changes: 14 additions & 13 deletions site/src/lib/HeatmapTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
data?.filter?.((row) => Object.values(row).some((val) => val !== undefined)) ?? []
// Helper to make column IDs (needed since column labels in different groups can be repeated)
const get_col_id = (col: { group?: string; label: string }) =>
const get_col_id = (col: HeatmapColumn) =>
col.group ? `${col.label} (${col.group})` : col.label
function sort_rows(column: string) {
Expand Down Expand Up @@ -51,10 +51,7 @@
})
}
function calc_color(
value: number | string | undefined,
col: { group?: string; label: string; better?: `higher` | `lower` | null },
) {
function calc_color(value: number | string | undefined, col: HeatmapColumn) {
if (col.color_scale === null || typeof value !== `number`)
return { bg: null, text: null }
Expand All @@ -79,15 +76,19 @@
$: visible_columns = columns.filter((col) => !col.hidden)
const sort_indicator = (col: {
group?: string
label: string
better?: `higher` | `lower` | null
}) => {
const sort_indicator = (
col: HeatmapColumn,
sort_state: { column: string; ascending: boolean },
) => {
const col_id = get_col_id(col)
if ($sort_state.column === col_id) {
return `<span style="font-size: 0.8em;">${$sort_state.ascending ? `` : ``}</span>`
if (sort_state.column === col_id) {
// When column is sorted, show ↓ for ascending (smaller values at top)
// and ↑ for descending (larger values at top)
return `<span style="font-size: 0.8em;">${sort_state.ascending ? `` : ``}</span>`
} else if (col.better) {
// When column is not sorted, show arrow indicating which values are better:
// ↑ for higher-is-better metrics
// ↓ for lower-is-better metrics
return `<span style="font-size: 0.8em;">${
col.better === `higher` ? `` : ``
}</span>`
Expand Down Expand Up @@ -125,7 +126,7 @@
class:sticky-col={col.sticky}
>
{@html col.label}
{@html sort_indicator(col)}
{@html sort_indicator(col, $sort_state)}
{#if col_idx == 0 && sort_hint}
<span title={sort_hint}>
<Icon icon="octicon:info-16" inline />
Expand Down
153 changes: 153 additions & 0 deletions site/tests/heatmap_table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { HeatmapTable } from '$lib'
import { tick } from 'svelte'
import { beforeEach, describe, expect, it } from 'vitest'
import type { HeatmapColumn } from '../src/lib/types'

describe(`HeatmapTable`, () => {
const sample_data = [
{ Model: `Model A`, Score: 0.95, Value: 100 },
{ Model: `Model B`, Score: 0.85, Value: 200 },
{ Model: `Model C`, Score: 0.75, Value: 300 },
]

const sample_columns: HeatmapColumn[] = [
{ label: `Model`, sticky: true },
{ label: `Score`, better: `higher`, format: `.2f` },
{ label: `Value`, better: `lower` },
]

beforeEach(() => {
document.body.innerHTML = ``
})

it(`renders table with correct structure and handles hidden columns`, () => {
const columns = [...sample_columns, { label: `Hidden`, hidden: true }]
new HeatmapTable({
target: document.body,
props: { data: sample_data, columns },
})

const headers = document.body.querySelectorAll(`th`)
expect(headers).toHaveLength(3)
expect(
Array.from(headers).map((h) => h.textContent?.replace(/\s+/g, ` `).trim()),
).toEqual([`Model`, `Score ↑`, `Value ↓`])

expect(document.body.querySelectorAll(`tbody tr`)).toHaveLength(3)
expect(document.body.querySelectorAll(`td[data-col="Hidden"]`)).toHaveLength(0)
})

it(`handles empty data and filters undefined rows`, async () => {
const data_with_empty = [{ Model: undefined, Score: undefined }, ...sample_data]

const table = new HeatmapTable({
target: document.body,
props: { data: data_with_empty, columns: sample_columns },
})

expect(document.body.querySelectorAll(`tbody tr`)).toHaveLength(3)

table.$set({ data: [] })
await tick()
expect(document.body.querySelectorAll(`tbody tr`)).toHaveLength(0)
})

describe(`Sorting and Data Updates`, () => {
it(`sorts correctly and handles missing values`, async () => {
const data = [
{ Model: `A`, Score: undefined, Value: 100 },
{ Model: `B`, Score: 0.85, Value: undefined },
{ Model: `C`, Score: 0.75, Value: 300 },
]

new HeatmapTable({
target: document.body,
props: { data, columns: sample_columns },
})

// Test initial sort
const value_header = document.body.querySelectorAll(`th`)[2]
value_header.click()
await tick()

const values = Array.from(
document.body.querySelectorAll(`td[data-col="Value"]`),
).map((cell) => cell.textContent?.trim())
expect(values).toEqual([`100`, `300`, `n/a`])

// Test sort direction toggle
value_header.click()
await tick()
const reversed = Array.from(
document.body.querySelectorAll(`td[data-col="Value"]`),
).map((cell) => cell.textContent?.trim())
expect(reversed).toEqual([`300`, `100`, `n/a`])
})

it(`maintains sort state on data updates`, async () => {
const component = new HeatmapTable({
target: document.body,
props: { data: sample_data, columns: sample_columns },
})

document.body.querySelectorAll(`th`)[1].click() // Sort by Score
await tick()

component.$set({ data: [{ Model: `D`, Score: 0.65 }, ...sample_data] })
await tick()

const scores = Array.from(
document.body.querySelectorAll(`td[data-col="Score"]`),
).map((cell) => cell.textContent?.trim())
expect(scores).toEqual([`0.65`, `0.95`, `0.85`, `0.75`])
})
})

it(`handles formatting, colors, and styles`, () => {
const columns: HeatmapColumn[] = [
{ label: `Num`, format: `.1%`, style: `background: rgb(255, 0, 0)` },
{ label: `Val`, better: `higher`, color_scale: `interpolateViridis` },
]
const data = [
{ Num: 0.123, Val: 0 },
{ Num: 1.234, Val: 100 },
]

new HeatmapTable({
target: document.body,
props: { data, columns },
})

// Check number formatting
expect(document.querySelector(`td[data-col="Num"]`)?.textContent).toBe(`12.3%`)

// Check custom styles
const styled_cell = document.querySelector(`td[data-col="Num"]`)!
expect(getComputedStyle(styled_cell).backgroundColor).toBe(`rgb(68, 1, 84)`)

// Check color scaling
const val_cells = document.querySelectorAll(`td[data-col="Val"]`)
const backgrounds = Array.from(val_cells).map(
(cell) => getComputedStyle(cell).backgroundColor,
)
expect(new Set(backgrounds).size).toBe(2) // Should have different colors
})

it(`handles accessibility features`, () => {
new HeatmapTable({
target: document.body,
props: {
data: sample_data,
columns: [{ label: `Col`, tooltip: `Description`, sticky: true }],
sort_hint: `Click to sort`,
},
})

const header = document.body.querySelector(`th`)!
expect(header.getAttribute(`title`) || header.getAttribute(`data-title`)).toBe(
`Description`,
)
expect(header.querySelector(`[title="Click to sort"]`)).toBeDefined()
expect(header.classList.contains(`sticky-col`)).toBe(true)
})
})
Loading

0 comments on commit faef34e

Please # to comment.