diff --git a/app/features/sendouq/SQGroupRepository.server.test.ts b/app/features/sendouq/SQGroupRepository.server.test.ts index 5a31dd4e6..9fa0cccd8 100644 --- a/app/features/sendouq/SQGroupRepository.server.test.ts +++ b/app/features/sendouq/SQGroupRepository.server.test.ts @@ -10,14 +10,18 @@ import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/Grou import { FULL_GROUP_SIZE } from "./q-constants"; import * as SQGroupRepository from "./SQGroupRepository.server"; -const setupConcludedMatch = async () => { - const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2); - const alphaMembers = users.slice(0, FULL_GROUP_SIZE); +const setupConcludedMatch = async ( + /** Give the same members another match, for a user with a match history. */ + returningAlphaMembers?: Array<{ id: number }>, +) => { + const bravoMembers = await UserFactory.createMany(FULL_GROUP_SIZE); + const alphaMembers = + returningAlphaMembers ?? (await UserFactory.createMany(FULL_GROUP_SIZE)); const match = await SQMatchFactory.create( { alphaUserIds: alphaMembers.map((member) => member.id), - bravoUserIds: users.slice(FULL_GROUP_SIZE).map((member) => member.id), + bravoUserIds: bravoMembers.map((member) => member.id), isMatchmade: true, }, { isConcluded: true }, @@ -57,8 +61,9 @@ describe("insert", () => { expect(result.chatCodeToRevalidate).toBe(matchChatCode); }); - test("preserves existing vote when user already voted yes on previous match", async () => { - const { alphaGroupId, alphaMembers } = await setupConcludedMatch(); + test("overrides the user's own yes vote on the previous match", async () => { + const { alphaGroupId, alphaMembers, matchChatCode } = + await setupConcludedMatch(); await castYesVote(alphaMembers[0].id, alphaGroupId); @@ -67,10 +72,12 @@ describe("insert", () => { userId: alphaMembers[0].id, }); + // leaving a yes vote standing would let the rest of the group reach a + // unanimous vote for a group the user is no longer available for const votes = await fetchVotes(alphaGroupId); expect(votes).toHaveLength(1); - expect(votes[0].isContinuing).toBe(true); - expect(result.chatCodeToRevalidate).toBeNull(); + expect(votes[0].isContinuing).toBe(false); + expect(result.chatCodeToRevalidate).toBe(matchChatCode); }); test("clears other members' yes votes on the previous group when recording implicit no", async () => { @@ -92,6 +99,54 @@ describe("insert", () => { expect(votes[0].isContinuing).toBe(false); }); + test("records the implicit no-vote on the newest matchmade group of many", async () => { + const { alphaGroupId: olderGroupId, alphaMembers } = + await setupConcludedMatch(); + const { alphaGroupId: newerGroupId, matchChatCode } = + await setupConcludedMatch(alphaMembers); + const olderVotesBefore = await fetchVotes(olderGroupId); + + const result = await SQGroupRepository.insert({ + status: "ACTIVE", + userId: alphaMembers[0].id, + }); + + expect(await fetchVotes(olderGroupId)).toEqual(olderVotesBefore); + + const votes = await fetchVotes(newerGroupId); + expect(votes).toHaveLength(1); + expect(votes[0].userId).toBe(alphaMembers[0].id); + expect(votes[0].isContinuing).toBe(false); + expect(result.chatCodeToRevalidate).toBe(matchChatCode); + }); + + test("leaves the previous group's votes alone on a later, unrelated queue action", async () => { + const { alphaGroupId, alphaMembers } = await setupConcludedMatch(); + + await SQGroupRepository.insert({ + status: "ACTIVE", + userId: alphaMembers[0].id, + }); + + // the three who stayed settle the vote among themselves + for (const member of alphaMembers.slice(1)) { + await castYesVote(member.id, alphaGroupId); + } + + // ...meanwhile the one who left gives up on queueing alone and queues again + await SQGroupRepository.leaveGroup(alphaMembers[0].id); + const result = await SQGroupRepository.insert({ + status: "ACTIVE", + userId: alphaMembers[0].id, + }); + + const votes = await fetchVotes(alphaGroupId); + expect(votes.filter((vote) => vote.isContinuing)).toHaveLength( + FULL_GROUP_SIZE - 1, + ); + expect(result.chatCodeToRevalidate).toBeNull(); + }); + test("does not record any vote when user has no previous matchmade group", async () => { const user = await UserFactory.create(); diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index be7d8e473..358eb5c9a 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -1045,6 +1045,13 @@ async function deleteReadyCheckInTrx( .execute(); } +/** + * Records the user as not continuing with the group they last played a matchmade + * match with, taking that group's votes in favour down with it. Getting a group + * elsewhere overrides a vote already cast in favour: the group can no longer + * continue at the size that vote was for, so the rest have to vote again. Once + * their no vote is in, that group is settled and later queue actions leave it be. + */ async function recordImplicitRejoinNoVote( userId: number, trx: Transaction, @@ -1060,18 +1067,19 @@ async function recordImplicitRejoinNoVote( ]), ), ) - .leftJoin("GroupMatchContinueVote", (join) => - join - .onRef("GroupMatchContinueVote.groupId", "=", "Group.id") - .on("GroupMatchContinueVote.userId", "=", userId), - ) - .select(["Group.id as groupId", "GroupMatch.chatCode as matchChatCode"]) + .select((eb) => [ + "Group.id as groupId", + "GroupMatch.chatCode as matchChatCode", + hasVotedNo(eb, userId).as("alreadySettled"), + ]) .where("GroupMember.userId", "=", userId) .where("Group.matchmade", "=", 1) - .where("GroupMatchContinueVote.id", "is", null) + // only the group they came from is still voting, older ones are long settled + .orderBy("Group.id", "desc") + .limit(1) .executeTakeFirst(); - if (!candidate) return null; + if (!candidate || candidate.alreadySettled) return null; await trx .deleteFrom("GroupMatchContinueVote") @@ -1094,6 +1102,18 @@ async function recordImplicitRejoinNoVote( return candidate.matchChatCode; } +/** Matches the `Group` rows the given user has already voted against continuing with. */ +function hasVotedNo(eb: ExpressionBuilder, userId: number) { + return eb.exists( + eb + .selectFrom("GroupMatchContinueVote") + .select("GroupMatchContinueVote.id") + .whereRef("GroupMatchContinueVote.groupId", "=", "Group.id") + .where("GroupMatchContinueVote.userId", "=", userId) + .where("GroupMatchContinueVote.isContinuing", "=", 0), + ); +} + /** Matches the `GroupMember` rows that have no confirmation for the given ready check. */ function didNotConfirmReadyCheck( eb: ExpressionBuilder, diff --git a/e2e/sendouq-match.spec.ts b/e2e/sendouq-match.spec.ts index 7e8c0b8b5..df59f3465 100644 --- a/e2e/sendouq-match.spec.ts +++ b/e2e/sendouq-match.spec.ts @@ -242,6 +242,55 @@ test.describe("SendouQ match page", () => { await expect(match.locators.declinedText).toBeVisible(); }); + test("Rejoin vote: queueing solo after voting yes shows the rest a no", async ({ + page, + factories, + }) => { + const { matchId, alpha } = await createMatch(factories, { + isMatchmade: true, + isConcluded: true, + }); + + const [owner, impatient, memberC, memberD] = alpha; + + await impersonate(page, owner.id); + const match = new SendouQMatchPage(page); + await match.goto(matchId); + await match.voteYes(); + + await impersonate(page, impatient.id); + await match.goto(matchId); + await match.voteYes(); + + // ...and then gives up on waiting for the rest and queues up alone instead + const q = new SendouQPage(page); + await q.goto(); + await q.joinSolo(); + + await impersonate(page, owner.id); + await match.goto(matchId); + + // their checkmark turned into a cross, taking the yes votes cast for a + // group of four with it + await expect(match.locators.votedNo).toHaveCount(1); + await expect(match.locators.votedYes).toHaveCount(0); + await expect(match.locators.pendingVotes).toHaveCount(3); + + for (const member of [owner, memberC, memberD]) { + await impersonate(page, member.id); + await match.goto(matchId); + await match.voteYes(); + } + + // the three who stayed get their group, rather than being sent back to /q + await impersonate(page, owner.id); + await q.goto(); + await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE); + + const looking = new SendouQLookingPage(page); + await expect(looking.ownGroupCard.members).toHaveCount(3); + }); + test("Rejoin vote: cascade wipes yes on no, revote completes and rejoins", async ({ page, factories,