package beads
import (
"context"
"fmt"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// The raw section is the pane's own check on itself: the rows the detail was
// built from, as read. What it has to get right is what every other projection
// here is allowed to get wrong — the column order it was read in, and the
// difference between a cell that holds no value and one that holds a string
// which happens to read like one.
// rawTableByName finds one table's entry in a built detail's raw section.
func rawTableByName(d *Data, name string) *RawTable {
for i := range d.Raw {
if d.Raw[i].Table == name {
return &d.Raw[i]
}
}
return nil
}
// rawCellByName finds one cell of a raw row by its column name.
func rawCellByName(r RawRow, column string) *RawCell {
for i := range r.Cells {
if r.Cells[i].Column == column {
return &r.Cells[i]
}
}
return nil
}
// rawColumns lists a raw row's columns in the order they are carried.
func rawColumns(r RawRow) []string {
out := make([]string, 0, len(r.Cells))
for _, c := range r.Cells {
out = append(out, c.Column)
}
return out
}
// The issues row arrives in the table's own column order. beadsFixture orders
// its columns so nothing sits at a natural index, so an implementation that
// sorted or re-derived the order could not match this by accident.
func TestRawIssuesRowKeepsTheColumnOrderItWasReadIn(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"i-done"}})
require.NoError(t, err)
tbl := rawTableByName(d, "issues")
require.NotNil(t, tbl, "the issue's own row is the whole point of the section")
require.Len(t, tbl.Rows, 1)
assert.Equal(t, 1, tbl.Matched)
assert.False(t, tbl.Clipped())
assert.Equal(t,
[]string{"id", "title", "status", "priority", "issue_type", "assignee",
"created_at", "closed_at", "close_reason", "is_blocked"},
rawColumns(tbl.Rows[0]),
"column order is data: it is the order the table was read in")
// Every column carries its own row value, addressed by name.
require.NotNil(t, rawCellByName(tbl.Rows[0], "close_reason"))
assert.Equal(t, "Fixed in commit abc123", rawCellByName(tbl.Rows[0], "close_reason").Value)
}
// The three states the section exists for. A cell browse reported as NULL, a
// cell storing the four characters "NULL", and a cell storing the empty string
// must be three distinguishable things in the model — the first two render
// identically as strings, and the last two are identical as strings once a null
// has been flattened.
func TestRawCellsSeparateAbsentFromStoredNullFromEmpty(t *testing.T) {
issues := &browse.RowPage{
Columns: []string{"id", "title", "assignee", "notes", "status"},
Rows: [][]string{
// id title assignee notes status
{"i-1", "NULL", "NULL", "", "open"},
},
Nulls: [][]bool{
// title holds no value at all; assignee stores those four characters;
// notes stores the empty string.
{false, true, false, false, false},
},
Total: 1,
}
sess := &fakeSession{rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": {
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{},
Nulls: [][]bool{},
},
}}
d, err := Build(context.Background(), sess, "main", url.Values{"issue": {"i-1"}})
require.NoError(t, err)
tbl := rawTableByName(d, "issues")
require.NotNil(t, tbl)
require.Len(t, tbl.Rows, 1)
row := tbl.Rows[0]
absent := rawCellByName(row, "title")
require.NotNil(t, absent)
assert.True(t, absent.Null, "a cell that holds no value says so")
assert.Equal(t, "", absent.Value,
"an absent cell carries no text: browse's \"NULL\" is a rendering of absence, not stored data")
stored := rawCellByName(row, "assignee")
require.NotNil(t, stored)
assert.False(t, stored.Null, "those four characters are stored, so the cell holds a value")
assert.Equal(t, "NULL", stored.Value)
empty := rawCellByName(row, "notes")
require.NotNil(t, empty)
assert.False(t, empty.Null, "an empty string is a value")
assert.Equal(t, "", empty.Value)
// Pairwise distinguishable, which is the property the pane depends on: no two
// of the three agree on both (Value, Null).
assert.NotEqual(t, absent.Null, stored.Null, "absent vs. the stored text \"NULL\"")
assert.NotEqual(t, absent.Null, empty.Null, "absent vs. the empty string")
assert.NotEqual(t, stored.Value, empty.Value, "the stored text \"NULL\" vs. the empty string")
}
// A page that carries no mask at all — a hand-built one; browse fills a mask for
// every page it returns — is read the way this package read every page before
// the mask existed, and the raw section reads it the same way the fields above
// it do. One reading, one decision site.
func TestRawFollowsTheSameNullReadingAsTheFields(t *testing.T) {
// beadsFixture's pages carry no Nulls, so "NULL" is how absence arrives.
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"i-open"}})
require.NoError(t, err)
require.NotNil(t, d.Issue)
assert.Equal(t, "", d.Issue.ClosedAt, "the field reads the unmasked \"NULL\" as absent")
tbl := rawTableByName(d, "issues")
require.NotNil(t, tbl)
closed := rawCellByName(tbl.Rows[0], "closed_at")
require.NotNil(t, closed)
assert.True(t, closed.Null, "and so does the raw row: they cannot disagree")
assert.Equal(t, "", closed.Value)
}
// Every table the pane draws rows of this issue from is carried, in read order,
// and only the rows that belong to the issue: another issue's comment is another
// issue's business.
func TestRawCarriesEveryPerIssueTableInReadOrder(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"i-open"}})
require.NoError(t, err)
var names []string
for _, tbl := range d.Raw {
names = append(names, tbl.Table)
}
assert.Equal(t, []string{"issues", "labels", "dependencies", "comments"}, names,
"read order, and only the tables that had rows for this issue")
labels := rawTableByName(d, "labels")
require.NotNil(t, labels)
assert.Equal(t, 2, labels.Matched, "both of i-open's label rows")
// i-open is the target of i-blocked's edge: an edge is this issue's in either
// direction, because the pane draws both lists from these rows.
deps := rawTableByName(d, "dependencies")
require.NotNil(t, deps)
require.Len(t, deps.Rows, 1)
assert.Equal(t, "i-blocked", rawCellByName(deps.Rows[0], "issue_id").Value)
comments := rawTableByName(d, "comments")
require.NotNil(t, comments)
require.Len(t, comments.Rows, 1)
assert.Equal(t, "first!", rawCellByName(comments.Rows[0], "text").Value,
"i-prog's comment belongs to i-prog")
// i-open has no audit events in this fixture, and a table with nothing to say
// is not listed at all.
assert.Nil(t, rawTableByName(d, "events"))
// custom_statuses is read (the lane comes from it) and is deliberately absent:
// its rows describe the tracker's statuses, not this issue.
assert.Nil(t, rawTableByName(d, "custom_statuses"))
}
// The events table is where stored and rendered are furthest apart —
// humanizeEvent turns two JSON blobs into one sentence — so the rows behind the
// History tab are carried with the strings intact.
func TestRawCarriesTheStoredEventStrings(t *testing.T) {
d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
require.NoError(t, err)
events := rawTableByName(d, "events")
require.NotNil(t, events)
assert.Equal(t, 4, events.Matched, "i-c1's created event is not this issue's")
require.Len(t, events.Rows, 4)
assert.Equal(t, `{"status":"open"}`, rawCellByName(events.Rows[1], "old_value").Value,
"the exact stored string behind the humanized line")
}
// The epic mode is the detail mode with a rollup on top, and it carries the raw
// rows for the same reason.
func TestRawIsCarriedInEpicMode(t *testing.T) {
d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
require.NoError(t, err)
require.Equal(t, "epic", d.Mode)
tbl := rawTableByName(d, "issues")
require.NotNil(t, tbl)
require.Len(t, tbl.Rows, 1)
assert.Equal(t, "i-epic", rawCellByName(tbl.Rows[0], "id").Value)
assert.Equal(t, "epic", rawCellByName(tbl.Rows[0], "issue_type").Value)
// The three parent-child edges are the issue's, in both directions.
deps := rawTableByName(d, "dependencies")
require.NotNil(t, deps)
assert.Equal(t, 3, deps.Matched)
}
// An issue that is not there has no stored row, and the section has nothing to
// show rather than something empty to show.
func TestRawIsEmptyForAMissingIssue(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"nope"}})
require.NoError(t, err)
require.True(t, d.Missing())
assert.Empty(t, d.Raw, "nothing was found, so there is nothing stored to show")
}
// The board is not a detail pane and carries none of this: it renders no stored
// row, and a board that carried every row of every card would be the table
// browser with lanes drawn on it.
func TestRawIsNotCarriedOnTheBoard(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
require.NoError(t, err)
require.Equal(t, "board", d.Mode)
assert.Empty(t, d.Raw)
}
// A table with more of this issue's rows than RawMax is cut, and says how many
// there were: a section that dumps three hundred audit rows is a second page
// nobody reads, and one that silently shows fifty of three hundred is a lie.
func TestRawCapsARelatedTableAndCountsItWhole(t *testing.T) {
sess := beadsFixture()
rows := make([][]string, 0, RawMax+7)
for i := range RawMax + 7 {
rows = append(rows, []string{"i-open", "alice", fmt.Sprintf("comment %d", i), "2024-01-05"})
}
sess.rowsByTable["comments"] = &browse.RowPage{
Columns: []string{"issue_id", "author", "text", "created_at"},
Rows: rows,
Total: len(rows),
}
d, err := Build(context.Background(), sess, "main", url.Values{"issue": {"i-open"}})
require.NoError(t, err)
tbl := rawTableByName(d, "comments")
require.NotNil(t, tbl)
assert.Len(t, tbl.Rows, RawMax)
assert.Equal(t, RawMax+7, tbl.Matched)
assert.True(t, tbl.Clipped())
assert.Equal(t, "comment 0", rawCellByName(tbl.Rows[0], "text").Value, "the first of them, in table order")
}
// A row shorter than its own header is a malformed page. There is no cell to
// report for the missing columns, so they are left out rather than invented as
// nulls — and the cells that do exist still line up with their column names.
func TestRawSkipsColumnsAShortRowDoesNotHave(t *testing.T) {
cols := []string{"id", "title", "status"}
row := rowCells{values: []string{"i-1", "Ahoy"}}
got := rawRow(cols, row)
require.Len(t, got.Cells, 2)
assert.Equal(t, RawCell{Column: "id", Value: "i-1"}, got.Cells[0])
assert.Equal(t, RawCell{Column: "title", Value: "Ahoy"}, got.Cells[1])
}