Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions components/frontend/src/lib/server/hackathon/submissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, it, expect } from "vitest"
import type { Submission } from "$lib/server/grpc/generated/hackathon/entities/submission"
import type { Team } from "$lib/server/grpc/generated/hackathon/entities/team"
import type { TeamServiceClient } from "$lib/server/grpc/generated/hackathon/team_service"
import type { User } from "$lib/server/grpc/generated/user/entities/user"
import {
submissionOverview,
submissionsByTeam,
submissionStanding,
submissionVersions,
} from "./submissions"
Expand Down Expand Up @@ -248,3 +250,29 @@ describe("submissionOverview", () => {
expect(rows[0]?.members).toEqual(["Amy", "anon", "Zed"])
})
})

describe("submissionsByTeam", () => {
it("asks once for the hackathon and groups by team, keeping the order", async () => {
const asked: unknown[] = []
const client = {
listSubmissions: async (req: unknown) => {
asked.push(req)

return {
submissions: [
sub({ version: 1, teamId: "team-a" }),
sub({ version: 2, teamId: "team-a" }),
sub({ version: 1, teamId: "team-b" }),
],
}
},
} as unknown as TeamServiceClient

const byTeam = await submissionsByTeam(client, "hackathon-1")

expect(asked).toEqual([{ hackathonId: "hackathon-1" }])
expect(byTeam.get("team-a")?.map((s) => s.version)).toEqual([1, 2])
expect(byTeam.get("team-b")?.map((s) => s.version)).toEqual([1])
expect(byTeam.has("team-c")).toBe(false)
})
})
30 changes: 14 additions & 16 deletions components/frontend/src/lib/server/hackathon/submissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,12 @@ export interface TeamSubmissionRow extends TeamSubmissions {
}

/**
* Every team's submissions, keyed by team id.
* Every submission in the hackathon, keyed by team id. A team with none has no
* entry.
*
* One `ListSubmissions` per team, in parallel: `TeamService.List` returns teams
* without their submissions (`team_service.go:63-68`), and there is no RPC that
* lists a hackathon's submissions in one go.
*
* TODO(backend: submissions-hackathon-list): one call once the backend offers
* one — either `List` loading the submissions edge that `Get` already loads, or
* a hackathon-scoped `ListSubmissions`.
* One hackathon-scoped `ListSubmissions`. It comes back ordered by team, then by
* version ascending, so each team's list keeps the order a team-scoped call
* would give it.
*
* `ListSubmissions` rather than the submissions nested in `team.get`, for the
* same reason the participant page gives: only this one is meant to be complete.
Expand All @@ -137,17 +134,18 @@ export interface TeamSubmissionRow extends TeamSubmissions {
*/
export async function submissionsByTeam(
team: TeamServiceClient,
teams: Team[],
hackathonId: string,
): Promise<Map<string, Submission[]>> {
const lists = await Promise.all(
teams.map(async (t) => {
const { submissions } = await team.listSubmissions({ teamId: t.id })
const { submissions } = await team.listSubmissions({ hackathonId })

return [t.id, submissions] as const
}),
)
const byTeam = new Map<string, Submission[]>()
for (const s of submissions) {
const list = byTeam.get(s.teamId)
if (list) list.push(s)
else byTeam.set(s.teamId, [s])
}

return new Map(lists)
return byTeam
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ import type { HackathonState } from "$lib/server/grpc/generated/hackathon/entiti
import type { TeamServiceClient } from "$lib/server/grpc/generated/hackathon/team_service"
import { ClientError, Status } from "nice-grpc-common"

/** A client whose `listSubmissions` answers per team id. Nothing else is called. */
function client(perTeam: Record<string, number | "refuse">): TeamServiceClient {
/**
* A client whose `listSubmissions` answers for hackathon `h1` only. Nothing
* else is called.
*/
function client(count: number | "refuse"): TeamServiceClient {
return {
listSubmissions: async ({ teamId }: { teamId: string }) => {
const answer = perTeam[teamId]
if (answer === "refuse") {
listSubmissions: async ({ hackathonId }: { hackathonId?: string }) => {
if (count === "refuse") {
throw new ClientError(
"/hackathon.TeamService/ListSubmissions",
Status.PERMISSION_DENIED,
"nope",
)
}
const n = hackathonId === "h1" ? count : 0

return { submissions: Array.from({ length: answer ?? 0 }, () => ({})) }
return { submissions: Array.from({ length: n }, () => ({})) }
},
} as unknown as TeamServiceClient
}
Expand All @@ -33,43 +36,31 @@ function state(viewTeams: boolean): HackathonState {

describe("assignmentLockReasons", () => {
it("is open with no submissions and teams not published", async () => {
expect(
await assignmentLockReasons(
client({ t1: 0, t2: 0 }),
["t1", "t2"],
state(false),
),
).toEqual([])
expect(await assignmentLockReasons(client(0), "h1", state(false))).toEqual(
[],
)
})

it("counts submissions across every team", async () => {
expect(
await assignmentLockReasons(
client({ t1: 2, t2: 1 }),
["t1", "t2"],
state(false),
),
).toEqual(["3 submissions exist"])
it("counts every submission in the hackathon", async () => {
expect(await assignmentLockReasons(client(3), "h1", state(false))).toEqual([
"3 submissions exist",
])
})

it("locks on published teams alone", async () => {
expect(await assignmentLockReasons(client({}), [], state(true))).toEqual([
expect(await assignmentLockReasons(client(0), "h1", state(true))).toEqual([
"teams are published",
])
})

it("locks when submissions cannot be checked, rather than guess open", async () => {
expect(
await assignmentLockReasons(
client({ t1: "refuse" }),
["t1"],
state(false),
),
await assignmentLockReasons(client("refuse"), "h1", state(false)),
).toEqual(["submissions could not be checked"])
})

it("reads a hackathon with no state row as not published", async () => {
expect(await assignmentLockReasons(client({}), [], undefined)).toEqual([])
expect(await assignmentLockReasons(client(0), "h1", undefined)).toEqual([])
})
})

Expand Down
20 changes: 8 additions & 12 deletions components/frontend/src/lib/server/hackathon/teamAssignmentLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,22 @@ import { enabledCapabilities } from "./phaseForm"
*/
export async function assignmentLockReasons(
team: TeamServiceClient,
teamIds: readonly string[],
hackathonId: string,
state: HackathonState | undefined,
): Promise<string[]> {
const published = enabledCapabilities(state).includes(
Capability.CAPABILITY_VIEW_TEAMS,
)

// `TeamService.List` does not load submissions, so each team is asked. A
// refusal or failure counts as "cannot tell", which locks: unlocking wrongly
// can delete a submission, locking wrongly costs a reload.
// Every team's submissions in one hackathon-scoped call, not only those on
// approved projects: a submission anywhere in the hackathon is one a save
// could take with it. A refusal or failure counts as "cannot tell", which
// locks: unlocking wrongly can delete a submission, locking wrongly costs a
// reload.
let submissions: number | null = 0
try {
const counts = await Promise.all(
teamIds.map(async (teamId) => {
const { submissions } = await team.listSubmissions({ teamId })

return submissions.length
}),
)
submissions = counts.reduce((n, c) => n + c, 0)
submissions = (await team.listSubmissions({ hackathonId })).submissions
.length
} catch {
submissions = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,15 @@ export const load: PageServerLoad = async (event) => {
// Every team, whichever tab is open. The tab is applied on the page, for two
// reasons: a search has to see every team to say which tab its matches are
// on, and a load that never reads `?standing=` is one SvelteKit does not
// re-run when only the tab changes — switching tabs would otherwise repeat a
// `ListSubmissions` per team.
// re-run when only the tab changes — switching tabs would otherwise repeat
// the `ListSubmissions`.
return {
hackathonId: hackathon.id,
submissionsOpen,
teamsReadable: true,
rows: submissionOverview(
teams,
await submissionsByTeam(team, teams),
await submissionsByTeam(team, hackathon.id),
projectTitles,
names,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const GET: RequestHandler = async (event) => {

const overview = submissionOverview(
teams,
await submissionsByTeam(team, teams),
await submissionsByTeam(team, h.id),
new Map(h.projects.map((p) => [p.id, p.title])),
namesByUserId(h.members, h.owners),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,9 @@ export const load: PageServerLoad = async (event) => {

const { teams } = await team.list({ hackathonId: event.params.id })

// Every team, not only those on approved projects: a submission anywhere in
// the hackathon is one a save could take with it.
const lockReasons = await assignmentLockReasons(
team,
teams.map((t) => t.id),
event.params.id,
hackathon.state,
)

Expand Down Expand Up @@ -224,7 +222,7 @@ export const actions: Actions = {
})
const locked = await assignmentLockReasons(
team,
all.map((t) => t.id),
event.params.id,
latest?.state,
)

Expand Down
Loading