package web
import (
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/oltp"
)
// The tab comment that held the strip at two set its own acceptance
// test: "a tab that opens onto an empty page teaches a reader the forge
// is broken rather than that the feature is unbuilt." So the Teams tab
// appears only when the forge has one.
func TestTheTeamsTabAppearsOnlyOnceATeamExists(t *testing.T) {
var none, some strings.Builder
if err := peoplePage.ExecuteTemplate(&none, "content",
pageData{Explore: "people", FleetTotal: 20, PeopleTotal: 3}); err != nil {
t.Fatal(err)
}
if strings.Contains(none.String(), `href="/teams"`) {
t.Error("FAIL: a forge with no teams draws the Teams tab")
}
if err := peoplePage.ExecuteTemplate(&some, "content",
pageData{Explore: "people", FleetTotal: 20, PeopleTotal: 3, TeamsTotal: 2}); err != nil {
t.Fatal(err)
}
if !strings.Contains(some.String(), `href="/teams"`) {
t.Error("FAIL: a forge with teams does not draw the Teams tab")
}
// And the other two are still there, in order.
body := some.String()
repos, people, teams := strings.Index(body, `href="/?view=explore"`),
strings.Index(body, `href="/people"`), strings.Index(body, `href="/teams"`)
if !(repos < people && people < teams) {
t.Errorf("FAIL: tab order is repos %d, people %d, teams %d", repos, people, teams)
}
}
// A team is shown by its title where it has one, and never by both —
// "Craft Design Group (craft-design-group)" is the same fact twice and
// the handle is already in the URL.
func TestATeamIsLabelledOnceNotTwice(t *testing.T) {
named := teamRow{Name: "craft-design-group", Title: "Craft Design Group"}
if got := named.Label(); got != "Craft Design Group" {
t.Errorf("FAIL: label = %q", got)
}
if !named.Named() {
t.Error("FAIL: a titled team does not read as named")
}
bare := teamRow{Name: "core"}
if got := bare.Label(); got != "core" {
t.Errorf("FAIL: an untitled team labels as %q", got)
}
if bare.Named() {
t.Error("FAIL: an untitled team would print its handle twice")
}
// A title that merely repeats the handle is not a second fact.
if (teamRow{Name: "core", Title: "core"}).Named() {
t.Error("FAIL: a title equal to the handle reads as named")
}
}
// The directory says where a team's substance is, and a team with no
// repositories is a real row rather than one that reads as broken.
func TestTheDirectoryNamesAnEmptyTeamHonestly(t *testing.T) {
var buf strings.Builder
if err := teamsPage.ExecuteTemplate(&buf, "content", pageData{
Explore: "teams", TeamsTotal: 2,
Teams: []teamRow{
{Name: "craft-design-group", Title: "Craft Design Group",
Descr: "quarry and zit", Members: 1, Repos: 0},
{Name: "firestorm-dataworks", Title: "FireStorm DataWorks", Members: 4, Repos: 3},
},
}); err != nil {
t.Fatal(err)
}
body := buf.String()
for _, want := range []string{
`href="/t/craft-design-group"`, "Craft Design Group", "quarry and zit",
"no repositories yet", "3 repositories", "1 member", "4 members",
} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: the directory is missing %q", want)
}
}
// A count of zero must not render as a bare "0 repositories".
if strings.Contains(body, "0 repositor") {
t.Error("FAIL: an empty team reads as a zero rather than as a sentence")
}
}
// An empty forge says what a team is for, rather than showing an empty
// list — the same rule the tab comment states.
func TestAnEmptyDirectorySaysWhatATeamIs(t *testing.T) {
var buf strings.Builder
if err := teamsPage.ExecuteTemplate(&buf, "content", pageData{Explore: "teams"}); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "No teams yet") {
t.Error("FAIL: an empty directory renders an empty list")
}
}
// A member's standing is named in the words the People editor uses: the
// preset where the string matches one, the letters where it does not.
func TestAMembersRoleReadsAsARoleWhereItIsOne(t *testing.T) {
for _, tc := range []struct {
caps, want string
}{
{"", "by membership"}, // RFC 0018's third route, named
{oltp.Maintain.Caps(), "Maintainer"},
{"a", "Admin"},
{"2i", "2i"}, // no preset covers it; the letters stand
} {
if got := (teamMember{Caps: tc.caps}).Role(); got != tc.want {
t.Errorf("FAIL: Role(%q) = %q, want %q", tc.caps, got, tc.want)
}
}
}
// One team's page: the repositories under it, the members, and the
// owner marked. An unlisted member reaching a manager is marked as
// such, so the manager can see why the public page differs.
func TestTheTeamPageShowsReposMembersAndWhoOwnsIt(t *testing.T) {
var buf strings.Builder
td := teamDetail{
Team: teamRow{Name: "craft-design-group", Title: "Craft Design Group",
Descr: "quarry and zit", Members: 2, Repos: 2},
Repos: []teamRepo{
{Repo: "quarry", Added: "2026-09-12T00:00:00Z"},
{Repo: "zit", Added: "2026-09-12T00:00:00Z"},
},
Members: []teamMember{
{Account: "dmestas", Owner: true, Listed: true},
{Account: "lou", Caps: oltp.Maintain.Caps(), Listed: false},
},
CanManage: true,
}
// The whole page, not just "content": the team's name is on the
// bar, and the bar is now drawn by the layout beside <main> the
// way a repository's is, so rendering the content block alone
// would assert against a page nobody is served.
if err := teamPage.Execute(&buf,
pageData{Title: "Craft Design Group", OwnH1: true, Team: &td,
TeamBar: &teamBarView{Team: td.Team, CanManage: td.CanManage}}); err != nil {
t.Fatal(err)
}
body := buf.String()
for _, want := range []string{
"Craft Design Group", "quarry and zit",
`href="/r/quarry"`, `href="/r/zit"`,
`href="/people/dmestas"`, "owner", "Maintainer", "unlisted",
} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: the team page is missing %q", want)
}
}
}
// A team holding nothing says so on its own page too, in a sentence
// that tells a reader how a repository gets there.
func TestAnEmptyTeamPageExplainsItself(t *testing.T) {
var buf strings.Builder
td := teamDetail{Team: teamRow{Name: "core", Members: 1}}
if err := teamPage.ExecuteTemplate(&buf, "content", pageData{Team: &td}); err != nil {
t.Fatal(err)
}
body := buf.String()
if !strings.Contains(body, "somebody who administers it") {
t.Error("FAIL: an empty team does not say how a repository joins")
}
if regexp.MustCompile(`0 repositor`).MatchString(body) {
t.Error("FAIL: renders a zero instead of a sentence")
}
}
// The repository's own People section must name the route, not just the
// result. RFC 0018 calls this the riskiest change in the feature and
// not one of the new pages: the section answered from stored strings
// alone, so a floor-only team member held read access and did not
// appear at all.
func TestTheRepoRosterNamesTheTeamRoute(t *testing.T) {
for _, tc := range []struct {
name string
row rosterRow
want []string
not []string
}{{
name: "floor only, no string of their own",
row: rosterRow{Name: "lou", Team: "core", Route: "by membership of core"},
want: []string{"by membership via core", `href="/t/core"`, "All of it comes from"},
// No role chip: they hold no string here, so there is no role
// to name, and no controls either — the change is on the team.
not: []string{`class="chip role"`, "row-act"},
}, {
name: "a team role",
row: rosterRow{Name: "lou", Team: "core", TeamCaps: "a", Route: "via core"},
want: []string{"Admin via core"},
not: []string{"row-act"},
}, {
name: "both routes at once",
row: rosterRow{Name: "lou", Caps: "a", Role: "Admin", Team: "core", TeamCaps: "uv"},
want: []string{`class="chip role"`, "via core"},
// It has a string of its own, so the controls appear and the
// "all of it comes from" line does not.
not: []string{"All of it comes from"},
}, {
name: "no team at all, the ordinary case",
row: rosterRow{Name: "dan", Caps: "a", Role: "Admin"},
want: []string{`class="chip role"`},
not: []string{"via-team", "All of it comes from"},
}} {
t.Run(tc.name, func(t *testing.T) {
var buf strings.Builder
err := settingsPage.ExecuteTemplate(&buf, "content", pageData{
Repo: "secret",
Roster: &rosterPanel{Rows: []rosterRow{tc.row}, Presets: oltp.Presets},
})
if err != nil {
t.Fatal(err)
}
body := buf.String()
for _, w := range tc.want {
if !strings.Contains(body, w) {
t.Errorf("FAIL: missing %q", w)
}
}
for _, n := range tc.not {
if strings.Contains(body, n) {
t.Errorf("FAIL: should not contain %q", n)
}
}
})
}
}
// The team route reads in the same words on both surfaces, so one
// grant cannot be described two ways.
func TestTheRouteReadsTheSameOnBothSurfaces(t *testing.T) {
caps := oltp.Maintain.Caps()
onTeamPage := (teamMember{Caps: caps}).Role()
onRepoPage := (rosterRow{Team: "core", TeamCaps: caps}).TeamRole()
if onTeamPage != onRepoPage {
t.Errorf("FAIL: team page says %q, repo page says %q", onTeamPage, onRepoPage)
}
if floor := (rosterRow{Team: "core"}).TeamRole(); floor != "by membership" {
t.Errorf("FAIL: the floor reads as %q", floor)
}
}
func peopleScreen(t *testing.T, tp teamPeople) string {
t.Helper()
tp.Presets = oltp.Presets
var buf strings.Builder
if err := teamPeoplePage.ExecuteTemplate(&buf, "content",
pageData{TeamPeople: &tp}); err != nil {
t.Fatal(err)
}
return buf.String()
}
// The screen RFC 0018 exists for. The heading counts what the list is
// ABOUT, because "everyone" means nothing without saying everyone who
// reaches what.
func TestThePeopleScreenSaysWhatItIsCounting(t *testing.T) {
body := peopleScreen(t, teamPeople{
Team: teamRow{Name: "core", Title: "Core"}, Repos: 3,
People: []teamPersonRow{{Account: "dan", Member: true, Owner: true}},
})
if !strings.Contains(body, "reaches these 3 repositories") {
t.Error("FAIL: the heading does not say what it counts")
}
// A team holding nothing says so rather than listing people
// against no repositories.
empty := peopleScreen(t, teamPeople{
Team: teamRow{Name: "core"}, Repos: 0,
People: []teamPersonRow{{Account: "dan", Member: true, Owner: true}},
})
if !strings.Contains(empty, "holds no repositories yet") {
t.Error("FAIL: a team holding nothing does not say so")
}
if strings.Contains(empty, "reaches these 0") {
t.Error("FAIL: renders a zero instead of a sentence")
}
}
// All three routes on one screen, each distinguishable, and a direct
// grant linked to where it can actually be changed.
func TestThePeopleScreenNamesEveryRoute(t *testing.T) {
body := peopleScreen(t, teamPeople{
Team: teamRow{Name: "core"}, Repos: 2, CanManage: true,
People: []teamPersonRow{
{Account: "dan", Member: true, Owner: true},
{Account: "lou", Member: true},
{Account: "zeitgeist", Member: true, Caps: oltp.Maintain.Caps()},
{Account: "ann", Direct: []teamDirectRow{{Repo: "zit", Caps: "a"}}},
},
})
for _, want := range []string{
"owner", "by membership", "Maintainer",
`href="/r/zit/settings#people"`, "zit: a",
"Not in this team", "Change that where it was granted",
} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: missing %q", want)
}
}
}
// A manager edits a team role in place — the whole point of one screen
// — and cannot edit a direct grant here, because it is not the team's.
func TestOnlyTheTeamsOwnGrantIsEditableHere(t *testing.T) {
rows := []teamPersonRow{
{Account: "lou", Member: true},
{Account: "ann", Direct: []teamDirectRow{{Repo: "zit", Caps: "a"}}},
}
managed := peopleScreen(t, teamPeople{
Team: teamRow{Name: "core"}, Repos: 1, CanManage: true, People: rows})
if !strings.Contains(managed, `action="/t/core/member"`) {
t.Error("FAIL: a manager gets no control at all")
}
if strings.Count(managed, "row-act") != 1 {
t.Errorf("FAIL: %d editable rows, want only the member's", strings.Count(managed, "row-act"))
}
// A reader who cannot manage gets the list and no controls.
read := peopleScreen(t, teamPeople{
Team: teamRow{Name: "core"}, Repos: 1, People: rows})
if strings.Contains(read, "row-act") || strings.Contains(read, "roster-add") {
t.Error("FAIL: a reader is offered controls")
}
if !strings.Contains(read, "lou") {
t.Error("FAIL: a reader cannot see the list")
}
}
// A team string no preset covers must not be silently rounded to the
// nearest role by an edit.
func TestACustomTeamStringSaysSoBeforeItIsOverwritten(t *testing.T) {
body := peopleScreen(t, teamPeople{
Team: teamRow{Name: "core"}, Repos: 1, CanManage: true,
People: []teamPersonRow{{Account: "lou", Member: true, Caps: "2i"}},
})
if !strings.Contains(body, "which no role covers") {
t.Error("FAIL: a custom string does not warn before being replaced")
}
row := teamPersonRow{Member: true, Caps: "2i"}
if !row.Custom() || row.Preset() != "" {
t.Errorf("FAIL: %q reads as a preset", row.Caps)
}
if (teamPersonRow{Member: true, Caps: "a"}).Custom() {
t.Error("FAIL: Admin reads as custom")
}
}
// Removal warns what it actually does, because on a private repository
// the membership floor is the whole of somebody's access.
func TestRemovalSaysWhatItCosts(t *testing.T) {
body := peopleScreen(t, teamPeople{
Team: teamRow{Name: "core"}, Repos: 2, CanManage: true,
People: []teamPersonRow{{Account: "lou", Member: true}},
})
if !strings.Contains(body, "that is all of their access") {
t.Error("FAIL: removal does not say what it costs on a private repository")
}
}
// One grant, one set of words, on all three surfaces.
func TestTheRoleReadsTheSameOnAllThreeSurfaces(t *testing.T) {
caps := oltp.Maintain.Caps()
onTeam := (teamMember{Caps: caps}).Role()
onRepo := (rosterRow{Team: "core", TeamCaps: caps}).TeamRole()
onScreen := (teamPersonRow{Member: true, Caps: caps}).Role()
if onTeam != onRepo || onRepo != onScreen {
t.Errorf("FAIL: team page %q, repo roster %q, people screen %q", onTeam, onRepo, onScreen)
}
if got := (teamPersonRow{Member: true}).Role(); got != "by membership" {
t.Errorf("FAIL: the floor reads as %q on the people screen", got)
}
}
// A control that would fail is not offered — quarry's rule, and the
// one the repository roster already follows for the last admin. The
// guard refuses removing a team's last owner, so the button must not
// be live.
func TestTheLastOwnersRemoveIsNotOffered(t *testing.T) {
sole := teamPeople{Team: teamRow{Name: "core"}, Repos: 1, CanManage: true, Owners: 1,
People: []teamPersonRow{{Account: "dmestas", Member: true, Owner: true}}}
body := peopleScreen(t, sole)
if !strings.Contains(body, "the last owner; make somebody else an owner first") {
t.Error("FAIL: the last owner is offered a Remove that would be refused")
}
if strings.Contains(body, `name="remove"`) {
t.Error("FAIL: the live Remove button is still there for the last owner")
}
// With a second owner, both are removable.
two := teamPeople{Team: teamRow{Name: "core"}, Repos: 1, CanManage: true, Owners: 2,
People: []teamPersonRow{
{Account: "dmestas", Member: true, Owner: true},
{Account: "lou", Member: true, Owner: true},
}}
body = peopleScreen(t, two)
if strings.Count(body, `name="remove"`) != 2 {
t.Errorf("FAIL: %d live Remove buttons with two owners, want 2", strings.Count(body, `name="remove"`))
}
// And an ordinary member is always removable.
if !sole.LastOwner(teamPersonRow{Member: true, Owner: true}) {
t.Error("FAIL: LastOwner is false for the sole owner")
}
if sole.LastOwner(teamPersonRow{Member: true}) {
t.Error("FAIL: an ordinary member reads as the last owner")
}
}
// The room is the CHAT LOG, not a list of its own. It used to invent a
// message row, and lost everything the real one had learned; these
// assert the component is what draws it, by structure rather than by
// anything anybody reads.
func TestTheTeamRoomIsDrawnByTheChatComponents(t *testing.T) {
tr := teamRoom{
Team: teamRow{Name: "core", Title: "Core"},
Rooms: []string{"quarry"},
Messages: []roomLine{
{Seq: 9, Kind: "msg", User: "dmestas", Text: "to everyone",
At: "2026-09-13T17:40:00Z"},
{Seq: 4, Kind: "msg", User: "lou", Repo: "quarry", Text: "in quarry",
At: "2026-09-13T17:12:00Z"},
},
Cursor: "abc",
}
var buf strings.Builder
if err := teamRoomPage.Execute(&buf, pageData{Title: "Core · Room", OwnH1: true,
TeamRoom: &tr, TeamBar: tr.bar(), ChatOn: "/t/core", ChatName: "core",
ChatMsgs: tr.log(), ChatOlder: tr.older()}); err != nil {
t.Fatal(err)
}
body := buf.String()
for _, want := range []string{
// the shared row, the shared log, the shared composer
`class="chatmsg"`, `id="chatlog"`, `class="composer"`,
// each line says where it was said — both kinds asserted
// separately, or one branch covers for the other
`data-hook="chat-origin" href="/r/quarry"`,
`data-hook="chat-origin" data-to-team`,
// every endpoint hangs off the TEAM, not a repository
`action="/t/core/chat"`, `href="/t/core/chat?cursor=abc"`,
} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: the room is missing %q", want)
}
}
// The composer's repository-only halves are absent rather than
// pointed at routes a team does not have.
for _, gone := range []string{"mention.frag", "preview.frag", "/chat/read"} {
if strings.Contains(body, gone) {
t.Errorf("FAIL: the room offers %q, which only a repository serves", gone)
}
}
}
// A run of messages never spans two channels: in a merged room the
// origin lives in the run's header, so grouping across origins would
// hide the one thing the room exists to show.
func TestARunNeverSpansTwoChannels(t *testing.T) {
at := "2026-09-13T17:40:00Z"
tr := teamRoom{Messages: []roomLine{
{Seq: 3, Kind: "msg", User: "lou", Repo: "zit", Text: "b", At: at},
{Seq: 2, Kind: "msg", User: "lou", Repo: "quarry", Text: "a", At: at},
}}
log := tr.log()
if len(log) != 2 {
t.Fatalf("FAIL: want 2 rows, got %d", len(log))
}
if log[1].Grouped {
t.Errorf("FAIL: the same author in two repositories grouped into one run")
}
// The same author, the same minute, the same channel still does.
same := teamRoom{Messages: []roomLine{
{Seq: 3, Kind: "msg", User: "lou", Repo: "zit", Text: "b", At: at},
{Seq: 2, Kind: "msg", User: "lou", Repo: "zit", Text: "a", At: at},
}}.log()
if !same[1].Grouped {
t.Errorf("FAIL: a run in one channel stopped grouping")
}
}