Skip to content

SendouQ · E2E tests

1 module · 8 exports · 2 repositories · 16 e2e tests · 9 routes

  • Users a test creates itself are coloured per test
  • Arrange data setup
  • Act what the user does
  • Assert what is expected

SendouQ pages

streams page shows the empty state, then a seeded live match

Renders/q/streams

LOLogged out
Q streams page
Act
  1. Open the Q streams page
    await streamsPage.goto();
Assert
  1. no error page
    await expectNoErrorPage(page);
  2. no streams text is visible
    await expect(streamsPage.locators.noStreamsText).toBeVisible();
Arrange
  1. Create named users “QStreamer”, twitch “q_streamer” → [streamer]
    const [streamer] = await createNamedUsers(factories, ["QStreamer"], {
    	twitch: STREAMER_TWITCH,
    });
  2. Create SQ match alpha user ids streamer.id, ...(await createUserIds(factories, 3)) bravo user ids createUserIds(factories, 4) → match
    const match = await factories.SQMatchFactory.create({
    	alphaUserIds: [streamer.id, ...(await createUserIds(factories, 3))],
    	bravoUserIds: await createUserIds(factories, 4),
    });
  3. Replace all live stream user id streamer.id account “q_streamer” viewer count 777
    await factories.LiveStreamFactory.replaceAll([
    	{
    		userId: streamer.id,
    		account: STREAMER_TWITCH,
    		viewerCount: STREAM_VIEWER_COUNT,
    	},
    ]);
Act
  1. Open the Q streams page
    await streamsPage.goto();
Assert
  1. streamer “QStreamer” is visible
    await expect(streamsPage.streamer("QStreamer")).toBeVisible();
  2. match link match.id has “href” = sendouQMatchPage(match.id)
    await expect(streamsPage.matchLink(match.id)).toHaveAttribute(
    	"href",
    	sendouQMatchPage(match.id),
    );
  3. platform link “Twitch” has “href” = LiveStream.url({ platform: "TWITCH", account: STREAMER_TWITCH })
    await expect(streamsPage.platformLink("Twitch")).toHaveAttribute(
    	"href",
    	LiveStream.url({ platform: "TWITCH", account: STREAMER_TWITCH }),
    );
  4. viewer count 777 is visible
    await expect(streamsPage.viewerCount(STREAM_VIEWER_COUNT)).toBeVisible();
Source
test("streams page shows the empty state, then a seeded live match", async ({
	page,
	factories,
}) => {
	const streamsPage = new QStreamsPage(page);
	await streamsPage.goto();
	await expectNoErrorPage(page);
	await expect(streamsPage.locators.noStreamsText).toBeVisible();

	const [streamer] = await createNamedUsers(factories, ["QStreamer"], {
		twitch: STREAMER_TWITCH,
	});
	const match = await factories.SQMatchFactory.create({
		alphaUserIds: [streamer.id, ...(await createUserIds(factories, 3))],
		bravoUserIds: await createUserIds(factories, 4),
	});
	await factories.LiveStreamFactory.replaceAll([
		{
			userId: streamer.id,
			account: STREAMER_TWITCH,
			viewerCount: STREAM_VIEWER_COUNT,
		},
	]);

	await streamsPage.goto();

	await expect(streamsPage.streamer("QStreamer")).toBeVisible();
	await expect(streamsPage.matchLink(match.id)).toHaveAttribute(
		"href",
		sendouQMatchPage(match.id),
	);
	await expect(streamsPage.platformLink("Twitch")).toHaveAttribute(
		"href",
		LiveStream.url({ platform: "TWITCH", account: STREAMER_TWITCH }),
	);
	await expect(streamsPage.viewerCount(STREAM_VIEWER_COUNT)).toBeVisible();
})

rules, info and tiers pages render their content

Renders/q/info/q/rules/tiers

LOLogged out
Q rules page
Act
  1. Open the Q rules page
    await rulesPage.goto();
Assert
  1. no error page
    await expectNoErrorPage(page);
  2. heading is visible
    await expect(rulesPage.locators.heading).toBeVisible();
Q info page
Act
  1. Open the Q info page
    await infoPage.goto();
Assert
  1. no error page
    await expectNoErrorPage(page);
  2. general info heading is visible
    await expect(infoPage.locators.generalInfoHeading).toBeVisible();
Tiers page
Act
  1. Open the tiers page
    await tiersPage.goto();
Assert
  1. no error page
    await expectNoErrorPage(page);
  2. tier image “LEVIATHAN” is visible
    await expect(tiersPage.tierImage("LEVIATHAN")).toBeVisible();
  3. tier image “IRON” is visible
    await expect(tiersPage.tierImage("IRON")).toBeVisible();
Source
test("rules, info and tiers pages render their content", async ({ page }) => {
	const rulesPage = new QRulesPage(page);
	await rulesPage.goto();
	await expectNoErrorPage(page);
	await expect(rulesPage.locators.heading).toBeVisible();

	const infoPage = new QInfoPage(page);
	await infoPage.goto();
	await expectNoErrorPage(page);
	await expect(infoPage.locators.generalInfoHeading).toBeVisible();

	const tiersPage = new TiersPage(page);
	await tiersPage.goto();
	await expectNoErrorPage(page);
	await expect(tiersPage.tierImage("LEVIATHAN")).toBeVisible();
	await expect(tiersPage.tierImage("IRON")).toBeVisible();
})

SendouQ

owneroutsiderinvitedsendouq.spec.ts:37

Renders/q/q/looking/q/preparing

Setup
Arrange
  1. Create 4 × user → [owner, friend, invited, outsider]
    const [owner, friend, invited, outsider] =
    	await factories.UserFactory.createMany(4);
  2. Create friendship user one id owner.id user two id friend.id
    await factories.FriendshipFactory.create({
    	userOneId: owner.id,
    	userTwoId: friend.id,
    });
Oowner
SendouQ page
Act
  1. Open the SendouQ page
    await q.goto();
  2. Join with mates → preparing
    const preparing = await q.joinWithMates();
SendouQ preparing page
Assert
  1. members count is 1
    await expect(preparing.groupCard.members).toHaveCount(1);
Ooutsider
SendouQ page
Act
  1. Open the SendouQ page
    await q.goto();
  2. Join solo → looking
    const looking = await q.joinSolo();
SendouQ looking page
Assert
  1. group cards count is 1
    await expect(looking.locators.groupCards).toHaveCount(1);
Oowner
SendouQ preparing page
Act
  1. Open the SendouQ preparing page
    await preparing.goto();
  2. Add first friend
    await preparing.addFirstFriend();
Assert
  1. members count is 2
    await expect(preparing.groupCard.members).toHaveCount(2);
Act
  1. Invite code → inviteCode
    const inviteCode = await preparing.inviteCode();
Iinvited
SendouQ page
Act
  1. Go to invite link inviteCode
    await q.gotoInviteLink(inviteCode);
Assert
  1. join group dialog is visible
    await expect(q.locators.joinGroupDialog).toBeVisible();
Act
  1. Join invited group
    await q.joinInvitedGroup();
Assert
  1. the page URL is SENDOUQ_PREPARING_PAGE
    await expect(page).toHaveURL(SENDOUQ_PREPARING_PAGE);
SendouQ preparing page
Assert
  1. members count is 3
    await expect(preparing.groupCard.members).toHaveCount(3);
Act
  1. Join queue
    await preparing.joinQueue();
Assert
  1. the page URL is SENDOUQ_LOOKING_PAGE
    await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
Source
test("Group preparation flow - add friends and users via invite link", async ({
	page,
	factories,
}) => {
	const [owner, friend, invited, outsider] =
		await factories.UserFactory.createMany(4);
	await factories.FriendshipFactory.create({
		userOneId: owner.id,
		userTwoId: friend.id,
	});

	await impersonate(page, owner.id);

	const q = new SendouQPage(page);
	await q.goto();

	const preparing = await q.joinWithMates();
	await expect(preparing.groupCard.members).toHaveCount(1);

	// a preparing group is not part of the pool other users are matched from
	await impersonate(page, outsider.id);
	await q.goto();

	const looking = await q.joinSolo();
	await expect(looking.locators.groupCards).toHaveCount(1);

	await impersonate(page, owner.id);
	await preparing.goto();
	await preparing.addFirstFriend();

	await expect(preparing.groupCard.members).toHaveCount(2);

	const inviteCode = await preparing.inviteCode();

	await impersonate(page, invited.id);
	await q.gotoInviteLink(inviteCode);

	await expect(q.locators.joinGroupDialog).toBeVisible();
	await q.joinInvitedGroup();

	await expect(page).toHaveURL(SENDOUQ_PREPARING_PAGE);
	await expect(preparing.groupCard.members).toHaveCount(3);

	await preparing.joinQueue();
	await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
})

Leaving takes the quick added friends along but not who joined on their own

adderjoinerfriendsendouq.spec.ts:84

Renders/q/q/looking/q/preparing

Setup
Arrange
  1. Create 3 × user → [adder, friend, joiner]
    const [adder, friend, joiner] = await factories.UserFactory.createMany(3);
  2. Create friendship user one id adder.id user two id friend.id
    await factories.FriendshipFactory.create({
    	userOneId: adder.id,
    	userTwoId: friend.id,
    });
  3. Create SQ group member user ids joiner.id
    await factories.SQGroupFactory.create({ memberUserIds: [joiner.id] });
Aadder
SendouQ page
Act
  1. Open the SendouQ page
    await q.goto();
  2. Join with mates → preparing
    const preparing = await q.joinWithMates();
SendouQ preparing page
Act
  1. Add first friend
    await preparing.addFirstFriend();
Assert
  1. members count is 2
    await expect(preparing.groupCard.members).toHaveCount(2);
Act
  1. Join queue → looking
    const looking = await preparing.joinQueue();
Assert
  1. the page URL is SENDOUQ_LOOKING_PAGE
    await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
SendouQ looking page
Act
  1. Press group action
    await looking.pressGroupAction();
Jjoiner
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
  2. Press group action
    await looking.pressGroupAction();
Assert
  1. members count is 3
    await expect(looking.ownGroupCard.members).toHaveCount(3);
Aadder
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
  2. Leave group
    await looking.leaveGroup();
Assert
  1. the page URL is SENDOUQ_PAGE
    await expect(page).toHaveURL(SENDOUQ_PAGE);
Jjoiner
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. members count is 1
    await expect(looking.ownGroupCard.members).toHaveCount(1);
Ffriend
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. the page URL is SENDOUQ_PAGE
    await expect(page).toHaveURL(SENDOUQ_PAGE);
Source
test("Leaving takes the quick added friends along but not who joined on their own", async ({
	page,
	factories,
}) => {
	const [adder, friend, joiner] = await factories.UserFactory.createMany(3);
	await factories.FriendshipFactory.create({
		userOneId: adder.id,
		userTwoId: friend.id,
	});
	await factories.SQGroupFactory.create({ memberUserIds: [joiner.id] });

	await impersonate(page, adder.id);

	const q = new SendouQPage(page);
	await q.goto();

	const preparing = await q.joinWithMates();
	await preparing.addFirstFriend();
	await expect(preparing.groupCard.members).toHaveCount(2);

	const looking = await preparing.joinQueue();
	await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
	await looking.pressGroupAction();

	await impersonate(page, joiner.id);
	await looking.goto();
	await looking.pressGroupAction();
	await expect(looking.ownGroupCard.members).toHaveCount(3);

	await impersonate(page, adder.id);
	await looking.goto();
	await looking.leaveGroup();
	await expect(page).toHaveURL(SENDOUQ_PAGE);

	// the one who joined by morphing stays in the queue
	await impersonate(page, joiner.id);
	await looking.goto();
	await expect(looking.ownGroupCard.members).toHaveCount(1);

	await impersonate(page, friend.id);
	await looking.goto();
	await expect(page).toHaveURL(SENDOUQ_PAGE);
})

Request flow - partial groups morph together

requesteracceptersendouq.spec.ts:128

Renders/q/looking

Setup
Arrange
  1. Create 2 × user → [requester, accepter]
    const [requester, accepter] = await factories.UserFactory.createMany(2);
  2. Create SQ group member user ids requester.id
    await factories.SQGroupFactory.create({ memberUserIds: [requester.id] });
  3. Create SQ group member user ids accepter.id
    await factories.SQGroupFactory.create({ memberUserIds: [accepter.id] });
Rrequester
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
  2. Press group action
    await looking.pressGroupAction();
Aaccepter
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. root is visible
    await expect(looking.groupCard(1).root).toBeVisible();
Act
  1. Press group action
    await looking.pressGroupAction();
Assert
  1. the morphed group keeps looking, it is not matched up yet

    the page URL is SENDOUQ_LOOKING_PAGE
    await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
  2. members count is 2
    await expect(looking.ownGroupCard.members).toHaveCount(2);
Source
test("Request flow - partial groups morph together", async ({
	page,
	factories,
}) => {
	const [requester, accepter] = await factories.UserFactory.createMany(2);
	await factories.SQGroupFactory.create({ memberUserIds: [requester.id] });
	await factories.SQGroupFactory.create({ memberUserIds: [accepter.id] });

	await impersonate(page, requester.id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();
	await looking.pressGroupAction();

	await impersonate(page, accepter.id);
	await looking.goto();

	await expect(looking.groupCard(1).root).toBeVisible();
	await looking.pressGroupAction();

	// the morphed group keeps looking, it is not matched up yet
	await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
	await expect(looking.ownGroupCard.members).toHaveCount(2);
})

Changing match preferences cancels pending requests

Renders/q/looking/settings

Setup
Arrange
  1. Create FULL_GROUP_SIZE × user → ownGroup
    const ownGroup = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  2. Create FULL_GROUP_SIZE × user → otherGroup
    const otherGroup = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  3. Create SQ group member user ids ownGroup.map((member) => member.id)
    await factories.SQGroupFactory.create({
    	memberUserIds: ownGroup.map((member) => member.id),
    });
  4. Create SQ group member user ids otherGroup.map((member) => member.id)
    await factories.SQGroupFactory.create({
    	memberUserIds: otherGroup.map((member) => member.id),
    });
OGown group[0]
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
  2. Press group action
    await looking.pressGroupAction();
Assert
  1. the challenge is now pending and can be undone

    undo buttons count is 1
    await expect(looking.locators.undoButtons).toHaveCount(1);
Match profile page
Act
  1. Open the match profile page
    await matchProfile.goto();
  2. Check [Accessibility] Avoid Splattercolor Screen
    await matchProfile.form.check("noScreen");
  3. Save
    await matchProfile.save();
SendouQ looking page
Act
  1. the pending challenge has been undone

    Open the SendouQ looking page
    await looking.goto();
Assert
  1. undo buttons count is 0
    await expect(looking.locators.undoButtons).toHaveCount(0);
Source
test("Changing match preferences cancels pending requests", async ({
	page,
	factories,
}) => {
	const ownGroup = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	const otherGroup = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	await factories.SQGroupFactory.create({
		memberUserIds: ownGroup.map((member) => member.id),
	});
	await factories.SQGroupFactory.create({
		memberUserIds: otherGroup.map((member) => member.id),
	});

	// challenge the other full group as the owner of our own full group
	await impersonate(page, ownGroup[0].id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();
	await looking.pressGroupAction();

	// the challenge is now pending and can be undone
	await expect(looking.locators.undoButtons).toHaveCount(1);

	// Changing a matchmaking preference (noScreen) last second must undo the
	// pending request so it can't be matched on terms the challenger never saw
	const matchProfile = new MatchProfilePage(page);
	await matchProfile.goto();
	await matchProfile.form.check("noScreen");
	await matchProfile.save();

	// the pending challenge has been undone
	await looking.goto();
	await expect(looking.locators.undoButtons).toHaveCount(0);
})

Suggesting floats a group to the top and leaves a trail for the group

ownerteammateothersendouq.spec.ts:188

Renders/q/looking

Setup
Arrange
  1. Create user discord name “Owner” profile null → owner
    const owner = await factories.UserFactory.create({
    	discordName: "Owner",
    	profile: null,
    });
  2. Create user discord name “Teammate” profile null → teammate
    const teammate = await factories.UserFactory.create({
    	discordName: "Teammate",
    	profile: null,
    });
  3. Create user discord name “Target” profile null → target
    const target = await factories.UserFactory.create({
    	discordName: "Target",
    	profile: null,
    });
  4. Create user discord name “Other” profile null → other
    const other = await factories.UserFactory.create({
    	discordName: "Other",
    	profile: null,
    });
  5. Create SQ group member user ids owner.id, teammate.id
    await factories.SQGroupFactory.create({
    	memberUserIds: [owner.id, teammate.id],
    });
  6. Create SQ group member user ids target.id → suggestedGroup
    const suggestedGroup = await factories.SQGroupFactory.create({
    	memberUserIds: [target.id],
    });
  7. Create SQ group member user ids other.id
    await factories.SQGroupFactory.create({ memberUserIds: [other.id] });
  8. the least recently active group sorts last, so the suggestion has somewhere to move it from

    Backdate “Group” suggestedGroup.id latest action at sub(new Date(), { minutes: 10 })
    await factories.backdate("Group", suggestedGroup.id, {
    	latestActionAt: sub(new Date(), { minutes: 10 }),
    });
Oowner
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. the own group's card is always the first one

    root contains “Target”
    await expect(looking.groupCard(2).root).toContainText("Target");
Act
  1. Press suggest
    await looking.groupCard(2).pressSuggest();
Assert
  1. root contains “Target”
    await expect(looking.groupCard(1).root).toContainText("Target");
  2. trail reads “Suggested by Owner”
    await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner");
  3. a suggestion can't be undone or repeated

    suggest button is not visible
    await isNotVisible(looking.groupCard(1).suggestButton);
Tteammate
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. trail reads “Suggested by Owner”
    await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner");
Act
  1. Press action
    await looking.groupCard(1).pressAction();
Assert
  1. inviting says everything the suggestion said, so it takes the trail over

    trail reads “Invited by Teammate”
    await expect(looking.groupCard(1).trail).toHaveText("Invited by Teammate");
  2. suggest button is not visible
    await isNotVisible(looking.groupCard(1).suggestButton);
Oother
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. suggest buttons is not visible
    await isNotVisible(looking.locators.suggestButtons);
Source
test("Suggesting floats a group to the top and leaves a trail for the group", async ({
	page,
	factories,
}) => {
	const owner = await factories.UserFactory.create({
		discordName: "Owner",
		profile: null,
	});
	const teammate = await factories.UserFactory.create({
		discordName: "Teammate",
		profile: null,
	});
	const target = await factories.UserFactory.create({
		discordName: "Target",
		profile: null,
	});
	const other = await factories.UserFactory.create({
		discordName: "Other",
		profile: null,
	});

	await factories.SQGroupFactory.create({
		memberUserIds: [owner.id, teammate.id],
	});
	const suggestedGroup = await factories.SQGroupFactory.create({
		memberUserIds: [target.id],
	});
	await factories.SQGroupFactory.create({ memberUserIds: [other.id] });

	// the least recently active group sorts last, so the suggestion has somewhere to move it from
	await factories.backdate("Group", suggestedGroup.id, {
		latestActionAt: sub(new Date(), { minutes: 10 }),
	});

	await impersonate(page, owner.id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();

	// the own group's card is always the first one
	await expect(looking.groupCard(2).root).toContainText("Target");

	await looking.groupCard(2).pressSuggest();

	await expect(looking.groupCard(1).root).toContainText("Target");
	await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner");
	// a suggestion can't be undone or repeated
	await isNotVisible(looking.groupCard(1).suggestButton);

	// the whole group sees the suggestion, and any of them can act on it
	await impersonate(page, teammate.id);
	await looking.goto();
	await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner");

	await looking.groupCard(1).pressAction();

	// inviting says everything the suggestion said, so it takes the trail over
	await expect(looking.groupCard(1).trail).toHaveText("Invited by Teammate");
	await isNotVisible(looking.groupCard(1).suggestButton);

	// a solo queuer has no teammates to suggest anything to
	await impersonate(page, other.id);
	await looking.goto();
	await isNotVisible(looking.locators.suggestButtons);
})

Previewing the queue shows the groups without any way to act on them

Renders/q/looking

Setup
Arrange
  1. Create user null patron tier 2 → supporter
    const supporter = await factories.UserFactory.create(null, {
    	patronTier: 2,
    });
  2. Create 1 × user → [soloQueuer]
    const [soloQueuer] = await factories.UserFactory.createMany(1);
  3. Create FULL_GROUP_SIZE × user → fullGroupMembers
    const fullGroupMembers =
    	await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  4. Create SQ group member user ids soloQueuer.id
    await factories.SQGroupFactory.create({ memberUserIds: [soloQueuer.id] });
  5. Create SQ group member user ids fullGroupMembers.map((member) => member.id)
    await factories.SQGroupFactory.create({
    	memberUserIds: fullGroupMembers.map((member) => member.id),
    });
Ssupporter
SendouQ looking page
Act
  1. Go to preview
    await looking.gotoPreview();
Assert
  1. group cards count is 2
    await expect(looking.locators.groupCards).toHaveCount(2);
  2. without a group of their own there is nothing to invite, challenge or suggest with

    action buttons is not visible
    await isNotVisible(looking.locators.actionButtons);
  3. suggest buttons is not visible
    await isNotVisible(looking.locators.suggestButtons);
Source
test("Previewing the queue shows the groups without any way to act on them", async ({
	page,
	factories,
}) => {
	const supporter = await factories.UserFactory.create(null, {
		patronTier: 2,
	});
	const [soloQueuer] = await factories.UserFactory.createMany(1);
	const fullGroupMembers =
		await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	await factories.SQGroupFactory.create({ memberUserIds: [soloQueuer.id] });
	await factories.SQGroupFactory.create({
		memberUserIds: fullGroupMembers.map((member) => member.id),
	});

	await impersonate(page, supporter.id);

	const looking = new SendouQLookingPage(page);
	await looking.gotoPreview();

	await expect(looking.locators.groupCards).toHaveCount(2);
	// without a group of their own there is nothing to invite, challenge or suggest with
	await isNotVisible(looking.locators.actionButtons);
	await isNotVisible(looking.locators.suggestButtons);
})

Previewing the queue is only for supporters

Renders/q

Setup
Arrange
  1. Create 1 × user → [user]
    const [user] = await factories.UserFactory.createMany(1);
Uuser
SendouQ looking page
Act
  1. Go to preview
    await looking.gotoPreview();
Assert
  1. the page URL is SENDOUQ_PAGE
    await expect(page).toHaveURL(SENDOUQ_PAGE);
Source
test("Previewing the queue is only for supporters", async ({
	page,
	factories,
}) => {
	const [user] = await factories.UserFactory.createMany(1);

	await impersonate(page, user.id);

	const looking = new SendouQLookingPage(page);
	await looking.gotoPreview();

	await expect(page).toHaveURL(SENDOUQ_PAGE);
})

A team's map preferences decide its modes and the maps it is given

opponents[0]opponents.at( 1)!sendouq.spec.ts:337

Renders/q/looking/q/match/:id/q/ready

Setup
Arrange
  1. Create FULL_GROUP_SIZE × user → members
    const members = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  2. the opponents want the same mode as the team, so an overlap of exactly it can only come from the team's own preferences: its members have none

    Create FULL_GROUP_SIZE × user null match profile map mode preferences modes mode TEAM_MODE preference “PREFER” pool → opponents
    const opponents = await factories.UserFactory.createMany(
    	FULL_GROUP_SIZE,
    	null,
    	{
    		matchProfile: {
    			mapModePreferences: {
    				modes: [{ mode: TEAM_MODE, preference: "PREFER" }],
    				pool: [],
    			},
    		},
    	},
    );
  3. Create team member user ids members.map((member) => member.id) map mode preferences modes mode TEAM_MODE preference “PREFER” pool mode TEAM_MODE stages TEAM_MAP_POOL.map((map) => map.stageId)
    await factories.TeamFactory.create(
    	{ memberUserIds: members.map((member) => member.id) },
    	{
    		mapModePreferences: {
    			modes: [{ mode: TEAM_MODE, preference: "PREFER" }],
    			pool: [
    				{
    					mode: TEAM_MODE,
    					stages: TEAM_MAP_POOL.map((map) => map.stageId),
    				},
    			],
    		},
    	},
    );
  4. Create SQ group member user ids members.map((member) => member.id) → teamGroup
    const teamGroup = await factories.SQGroupFactory.create({
    	memberUserIds: members.map((member) => member.id),
    });
  5. Create SQ group member user ids opponents.map((member) => member.id) → opponentGroup
    const opponentGroup = await factories.SQGroupFactory.create({
    	memberUserIds: opponents.map((member) => member.id),
    });
Oopponents[0]
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. the modes the match would be played on are the team's, not its members'

    modes count is 1
    await expect(looking.groupCard(1).modes).toHaveCount(1);
  2. mode TEAM_MODE is visible
    await expect(looking.groupCard(1).mode(TEAM_MODE)).toBeVisible();
Arrange
  1. Create SQ ready check alpha group id teamGroup.id bravo group id opponentGroup.id confirmed by user id members[0].id confirmed by user ids ...members.slice(1).map((member) => member.id), ...opponents.slice(0, -1).map((member) => member.id)
    await factories.SQReadyCheckFactory.create(
    	{
    		alphaGroupId: teamGroup.id,
    		bravoGroupId: opponentGroup.id,
    		confirmedByUserId: members[0].id,
    	},
    	{
    		confirmedByUserIds: [
    			...members.slice(1).map((member) => member.id),
    			...opponents.slice(0, -1).map((member) => member.id),
    		],
    	},
    );
O1opponents.at( 1)!
SendouQ ready page
Act
  1. Open the SendouQ ready page
    await ready.goto();
  2. Confirm ready
    await ready.confirmReady();
Assert
  1. the page URL is /\/q\/match\/\d+/
    await expect(page).toHaveURL(/\/q\/match\/\d+/);
SendouQ match page
Assert
  1. mode progress TEAM_MODE is visible
    await expect(match.modeProgress(TEAM_MODE)).toBeVisible();
  2. map count text SENDOUQ_BEST_OF is visible
    await expect(match.mapCountText(SENDOUQ_BEST_OF)).toBeVisible();
  3. current map TEAM_MAP_POOL is visible
    await expect(match.currentMap(TEAM_MAP_POOL)).toBeVisible();
Source
test("A team's map preferences decide its modes and the maps it is given", async ({
	page,
	factories,
}) => {
	const members = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	// the opponents want the same mode as the team, so an overlap of exactly it
	// can only come from the team's own preferences: its members have none
	const opponents = await factories.UserFactory.createMany(
		FULL_GROUP_SIZE,
		null,
		{
			matchProfile: {
				mapModePreferences: {
					modes: [{ mode: TEAM_MODE, preference: "PREFER" }],
					pool: [],
				},
			},
		},
	);

	await factories.TeamFactory.create(
		{ memberUserIds: members.map((member) => member.id) },
		{
			mapModePreferences: {
				modes: [{ mode: TEAM_MODE, preference: "PREFER" }],
				pool: [
					{
						mode: TEAM_MODE,
						stages: TEAM_MAP_POOL.map((map) => map.stageId),
					},
				],
			},
		},
	);

	const teamGroup = await factories.SQGroupFactory.create({
		memberUserIds: members.map((member) => member.id),
	});
	const opponentGroup = await factories.SQGroupFactory.create({
		memberUserIds: opponents.map((member) => member.id),
	});

	await impersonate(page, opponents[0].id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();

	// the modes the match would be played on are the team's, not its members'
	await expect(looking.groupCard(1).modes).toHaveCount(1);
	await expect(looking.groupCard(1).mode(TEAM_MODE)).toBeVisible();

	await factories.SQReadyCheckFactory.create(
		{
			alphaGroupId: teamGroup.id,
			bravoGroupId: opponentGroup.id,
			confirmedByUserId: members[0].id,
		},
		{
			confirmedByUserIds: [
				...members.slice(1).map((member) => member.id),
				...opponents.slice(0, -1).map((member) => member.id),
			],
		},
	);

	// the last confirmation is what makes the match, and with it its map list
	await impersonate(page, opponents.at(-1)!.id);

	const ready = new SendouQReadyPage(page);
	await ready.goto();
	await ready.confirmReady();

	await expect(page).toHaveURL(/\/q\/match\/\d+/);

	const match = new SendouQMatchPage(page);
	await expect(match.modeProgress(TEAM_MODE)).toBeVisible();
	await expect(match.mapCountText(SENDOUQ_BEST_OF)).toBeVisible();
	await expect(match.currentMap(TEAM_MAP_POOL)).toBeVisible();
})

Ready check flow - both groups confirm and the match starts

accepters[0]rest of the queue.at( 1)!sendouq.spec.ts:417

Renders/q/looking/q/match/:id/q/ready

LOLogged out
Act
  1. every one of the eight members logs in and confirms on their own

    Slow
    test.slow();
Arrange
  1. Create FULL_GROUP_SIZE × user → challengers
    const challengers = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  2. Create FULL_GROUP_SIZE × user → accepters
    const accepters = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  3. Create SQ group member user ids challengers.map((member) => member.id) → challengerGroup
    const challengerGroup = await factories.SQGroupFactory.create({
    	memberUserIds: challengers.map((member) => member.id),
    });
  4. Create SQ group member user ids accepters.map((member) => member.id) liked by group ids challengerGroup.id
    await factories.SQGroupFactory.create(
    	{ memberUserIds: accepters.map((member) => member.id) },
    	{ likedByGroupIds: [challengerGroup.id] },
    );
Aaccepters[0]
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
  2. Press group action
    await looking.pressGroupAction();
Assert
  1. accepting the challenge starts the ready check instead of the match

    the page URL is SENDOUQ_READY_PAGE
    await expect(page).toHaveURL(SENDOUQ_READY_PAGE);
SendouQ ready page
Assert
  1. countdown is visible
    await expect(ready.locators.countdown).toBeVisible();
  2. who they will be playing is not revealed yet

    hidden group card is visible
    await expect(ready.locators.hiddenGroupCard).toBeVisible();
  3. accepting counted as being ready, so it is 1 of the 8

    members ready count is 1
    await expect(ready.locators.membersReady).toHaveCount(1);
  4. confirmed text is visible
    await expect(ready.locators.confirmedText).toBeVisible();
Act
  1. restOfTheQueue = [...accepters.slice(1), ...challengers]
    const restOfTheQueue = [...accepters.slice(1), ...challengers];
  2. For each member of restOfTheQueue.slice(0, -1)
    for (const member of restOfTheQueue.slice(0, -1)) {
    	await impersonate(page, member.id);
    	await ready.goto();
    
    	// the ready check notification stays unseen until they respond to it
    	await expect(notifications.locators.bellDot).toBeVisible();
    
    	await ready.confirmReady();
    
    	await expect(page).toHaveURL(SENDOUQ_READY_PAGE);
    	await expect(ready.locators.confirmedText).toBeVisible();
    	await expect(notifications.locators.bellDot).toBeHidden();
    }
    1. await impersonate(page, member.id);
    2. Open the SendouQ ready page
      await ready.goto();
    3. the ready check notification stays unseen until they respond to it

      bell dot is visible
      await expect(notifications.locators.bellDot).toBeVisible();
    4. Confirm ready
      await ready.confirmReady();
    5. the page URL is SENDOUQ_READY_PAGE
      await expect(page).toHaveURL(SENDOUQ_READY_PAGE);
    6. confirmed text is visible
      await expect(ready.locators.confirmedText).toBeVisible();
    7. bell dot is hidden
      await expect(notifications.locators.bellDot).toBeHidden();
ROrest of the queue.at( 1)!
SendouQ ready page
Act
  1. Open the SendouQ ready page
    await ready.goto();
Notification popover
Assert
  1. bell dot is visible
    await expect(notifications.locators.bellDot).toBeVisible();
SendouQ ready page
Act
  1. Confirm ready
    await ready.confirmReady();
Assert
  1. the page URL is /\/q\/match\/\d+/
    await expect(page).toHaveURL(/\/q\/match\/\d+/);
Notification popover
Assert
  1. confirming resolved the ready check notification and the new match notification arrives already seen for the one who created the match

    bell dot is hidden
    await expect(notifications.locators.bellDot).toBeHidden();
Source
test("Ready check flow - both groups confirm and the match starts", async ({
	page,
	factories,
}) => {
	// every one of the eight members logs in and confirms on their own
	test.slow();

	const challengers = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	const accepters = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	const challengerGroup = await factories.SQGroupFactory.create({
		memberUserIds: challengers.map((member) => member.id),
	});
	await factories.SQGroupFactory.create(
		{ memberUserIds: accepters.map((member) => member.id) },
		{ likedByGroupIds: [challengerGroup.id] },
	);

	await impersonate(page, accepters[0].id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();
	await looking.pressGroupAction();

	// accepting the challenge starts the ready check instead of the match
	await expect(page).toHaveURL(SENDOUQ_READY_PAGE);

	const ready = new SendouQReadyPage(page);
	await expect(ready.locators.countdown).toBeVisible();
	// who they will be playing is not revealed yet
	await expect(ready.locators.hiddenGroupCard).toBeVisible();
	// accepting counted as being ready, so it is 1 of the 8
	await expect(ready.locators.membersReady).toHaveCount(1);
	await expect(ready.locators.confirmedText).toBeVisible();

	const notifications = new NotificationPopover(page);

	const restOfTheQueue = [...accepters.slice(1), ...challengers];
	for (const member of restOfTheQueue.slice(0, -1)) {
		await impersonate(page, member.id);
		await ready.goto();

		// the ready check notification stays unseen until they respond to it
		await expect(notifications.locators.bellDot).toBeVisible();

		await ready.confirmReady();

		await expect(page).toHaveURL(SENDOUQ_READY_PAGE);
		await expect(ready.locators.confirmedText).toBeVisible();
		await expect(notifications.locators.bellDot).toBeHidden();
	}

	// the last one to confirm gets everyone into the match
	await impersonate(page, restOfTheQueue.at(-1)!.id);
	await ready.goto();

	await expect(notifications.locators.bellDot).toBeVisible();

	await ready.confirmReady();

	await expect(page).toHaveURL(/\/q\/match\/\d+/);

	// confirming resolved the ready check notification and the new match
	// notification arrives already seen for the one who created the match
	await expect(notifications.locators.bellDot).toBeHidden();
})

Ready check expiring sends the groups back to looking and lets them kick who missed it

own members[0]sendouq.spec.ts:483

Renders/q/looking

Setup
Arrange
  1. Create FULL_GROUP_SIZE × user profile null → ownMembers
    const ownMembers = await factories.UserFactory.createMany(FULL_GROUP_SIZE, {
    	profile: null,
    });
  2. Create FULL_GROUP_SIZE × user profile null → theirMembers
    const theirMembers = await factories.UserFactory.createMany(
    	FULL_GROUP_SIZE,
    	{ profile: null },
    );
  3. Create SQ group member user ids ownMembers.map((member) => member.id) → ownGroup
    const ownGroup = await factories.SQGroupFactory.create({
    	memberUserIds: ownMembers.map((member) => member.id),
    });
  4. Create SQ group member user ids theirMembers.map((member) => member.id) → theirGroup
    const theirGroup = await factories.SQGroupFactory.create({
    	memberUserIds: theirMembers.map((member) => member.id),
    });
  5. everyone but the last member of the own group confirms

    Create SQ ready check alpha group id ownGroup.id bravo group id theirGroup.id confirmed by user id ownMembers[0].id confirmed by user ids ...ownMembers.slice(1, -1).map((member) => member.id), ...theirMembers.map((member) => member.id) → readyCheck
    const readyCheck = await factories.SQReadyCheckFactory.create(
    	{
    		alphaGroupId: ownGroup.id,
    		bravoGroupId: theirGroup.id,
    		confirmedByUserId: ownMembers[0].id,
    	},
    	{
    		confirmedByUserIds: [
    			...ownMembers.slice(1, -1).map((member) => member.id),
    			...theirMembers.map((member) => member.id),
    		],
    	},
    );
  6. Backdate “GroupReadyCheck” readyCheck.id created at sub(new Date(), { minutes: SENDOUQ.READY_CHECK_MINUTES + 1 })
    await factories.backdate("GroupReadyCheck", readyCheck.id, {
    	createdAt: sub(new Date(), { minutes: SENDOUQ.READY_CHECK_MINUTES + 1 }),
    });
LOLogged out
Act
  1. Run routine “ExpireReadyChecks”
    await runRoutine(page, "ExpireReadyChecks");
OMown members[0]
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. the group is looking again and the one who never confirmed can be kicked

    members count is FULL_GROUP_SIZE
    await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE);
  2. kick buttons count is 1
    await expect(looking.ownGroupCard.kickButtons).toHaveCount(1);
Act
  1. Press kick
    await looking.ownGroupCard.pressKick();
Assert
  1. members count is FULL_GROUP_SIZE - 1
    await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE - 1);
  2. kick buttons count is 0
    await expect(looking.ownGroupCard.kickButtons).toHaveCount(0);
Source
test("Ready check expiring sends the groups back to looking and lets them kick who missed it", async ({
	page,
	factories,
}) => {
	const ownMembers = await factories.UserFactory.createMany(FULL_GROUP_SIZE, {
		profile: null,
	});
	const theirMembers = await factories.UserFactory.createMany(
		FULL_GROUP_SIZE,
		{ profile: null },
	);
	const ownGroup = await factories.SQGroupFactory.create({
		memberUserIds: ownMembers.map((member) => member.id),
	});
	const theirGroup = await factories.SQGroupFactory.create({
		memberUserIds: theirMembers.map((member) => member.id),
	});

	// everyone but the last member of the own group confirms
	const readyCheck = await factories.SQReadyCheckFactory.create(
		{
			alphaGroupId: ownGroup.id,
			bravoGroupId: theirGroup.id,
			confirmedByUserId: ownMembers[0].id,
		},
		{
			confirmedByUserIds: [
				...ownMembers.slice(1, -1).map((member) => member.id),
				...theirMembers.map((member) => member.id),
			],
		},
	);
	await factories.backdate("GroupReadyCheck", readyCheck.id, {
		createdAt: sub(new Date(), { minutes: SENDOUQ.READY_CHECK_MINUTES + 1 }),
	});

	await runRoutine(page, "ExpireReadyChecks");

	await impersonate(page, ownMembers[0].id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();

	// the group is looking again and the one who never confirmed can be kicked
	await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE);
	await expect(looking.ownGroupCard.kickButtons).toHaveCount(1);

	await looking.ownGroupCard.pressKick();

	await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE - 1);
	await expect(looking.ownGroupCard.kickButtons).toHaveCount(0);
})

A ready check outliving the season doesn't turn into a match

own members.at( 1)!sendouq.spec.ts:536

Renders/q/q/ready

Setup
Arrange
  1. Create FULL_GROUP_SIZE × user → ownMembers
    const ownMembers = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  2. Create FULL_GROUP_SIZE × user → theirMembers
    const theirMembers =
    	await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  3. Create SQ group member user ids ownMembers.map((member) => member.id) → ownGroup
    const ownGroup = await factories.SQGroupFactory.create({
    	memberUserIds: ownMembers.map((member) => member.id),
    });
  4. Create SQ group member user ids theirMembers.map((member) => member.id) → theirGroup
    const theirGroup = await factories.SQGroupFactory.create({
    	memberUserIds: theirMembers.map((member) => member.id),
    });
  5. everyone but the last member of the own group confirmed

    Create SQ ready check alpha group id ownGroup.id bravo group id theirGroup.id confirmed by user id ownMembers[0].id confirmed by user ids ...ownMembers.slice(1, -1).map((member) => member.id), ...theirMembers.map((member) => member.id)
    await factories.SQReadyCheckFactory.create(
    	{
    		alphaGroupId: ownGroup.id,
    		bravoGroupId: theirGroup.id,
    		confirmedByUserId: ownMembers[0].id,
    	},
    	{
    		confirmedByUserIds: [
    			...ownMembers.slice(1, -1).map((member) => member.id),
    			...theirMembers.map((member) => member.id),
    		],
    	},
    );
OMown members.at( 1)!
SendouQ ready page
Act
  1. Open the SendouQ ready page
    await ready.goto();
  2. the season ends before the last one confirms

    End season
    await endSeason(page);
  3. Confirm ready
    await ready.confirmReady();
Assert
  1. there is no rated match left to make, so the ready check ends and both groups leave the queue instead of being sent to play

    the page URL is SENDOUQ_PAGE
    await expect(page).toHaveURL(SENDOUQ_PAGE);
Source
test("A ready check outliving the season doesn't turn into a match", async ({
	page,
	factories,
}) => {
	const ownMembers = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	const theirMembers =
		await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	const ownGroup = await factories.SQGroupFactory.create({
		memberUserIds: ownMembers.map((member) => member.id),
	});
	const theirGroup = await factories.SQGroupFactory.create({
		memberUserIds: theirMembers.map((member) => member.id),
	});

	// everyone but the last member of the own group confirmed
	await factories.SQReadyCheckFactory.create(
		{
			alphaGroupId: ownGroup.id,
			bravoGroupId: theirGroup.id,
			confirmedByUserId: ownMembers[0].id,
		},
		{
			confirmedByUserIds: [
				...ownMembers.slice(1, -1).map((member) => member.id),
				...theirMembers.map((member) => member.id),
			],
		},
	);

	await impersonate(page, ownMembers.at(-1)!.id);

	const ready = new SendouQReadyPage(page);
	await ready.goto();

	// the season ends before the last one confirms
	await endSeason(page);

	await ready.confirmReady();

	// there is no rated match left to make, so the ready check ends and both
	// groups leave the queue instead of being sent to play
	await expect(page).toHaveURL(SENDOUQ_PAGE);
})

The season ending takes the groups out of the queue

Renders/q/q/looking

Setup
Arrange
  1. Create FULL_GROUP_SIZE × user → members
    const members = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
  2. Create SQ group member user ids members.map((member) => member.id)
    await factories.SQGroupFactory.create({
    	memberUserIds: members.map((member) => member.id),
    });
Mmembers[0]
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Assert
  1. members count is FULL_GROUP_SIZE
    await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE);
Act
  1. End season
    await endSeason(page);
  2. the group is taken out of the queue on the spot, so there is no looking page left to be on

    Open the SendouQ looking page
    await looking.goto();
Assert
  1. the page URL is SENDOUQ_PAGE
    await expect(page).toHaveURL(SENDOUQ_PAGE);
  2. join with mates button is not visible
    await isNotVisible(q.locators.joinWithMatesButton);
Source
test("The season ending takes the groups out of the queue", async ({
	page,
	factories,
}) => {
	const members = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
	await factories.SQGroupFactory.create({
		memberUserIds: members.map((member) => member.id),
	});

	await impersonate(page, members[0].id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();
	await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE);

	await endSeason(page);

	// the group is taken out of the queue on the spot, so there is no
	// looking page left to be on
	await looking.goto();
	await expect(page).toHaveURL(SENDOUQ_PAGE);

	// and no way back in until the next season starts
	const q = new SendouQPage(page);
	await isNotVisible(q.locators.joinWithMatesButton);
})

Joining the queue is blocked when the season's initial powers were never seeded

Renders/q

Setup
Arrange
  1. Create 1 × user → [user]
    const [user] = await factories.UserFactory.createMany(1);
  2. the previous season concluded with skills but the current one has none, meaning season-initial-powers was forgotten

    Create skill user id user.id season 0
    await factories.SkillFactory.create({ userId: user.id, season: 0 });
Uuser
SendouQ page
Act
  1. Open the SendouQ page
    await q.goto();
  2. Join solo
    await q.joinSolo();
Assert
  1. text “Season's starting powers are not set yet” is in the page
    await expect(
    	page.getByText("Season's starting powers are not set yet"),
    ).toBeAttached();
  2. the page URL is SENDOUQ_PAGE
    await expect(page).toHaveURL(SENDOUQ_PAGE);
Source
test("Joining the queue is blocked when the season's initial powers were never seeded", async ({
	page,
	factories,
}) => {
	const [user] = await factories.UserFactory.createMany(1);
	// the previous season concluded with skills but the current one has none,
	// meaning season-initial-powers was forgotten
	await factories.SkillFactory.create({ userId: user.id, season: 0 });

	await impersonate(page, user.id);

	const q = new SendouQPage(page);
	await q.goto();
	await q.joinSolo();

	await expect(
		page.getByText("Season's starting powers are not set yet"),
	).toBeAttached();
	await expect(page).toHaveURL(SENDOUQ_PAGE);
})

On a phone sized screen

Runs with { viewport: { width: 390, height: 844 } }

Suggesting floats the group without dragging the viewer's scroll position along

Renders/q/looking

Setup
Arrange
  1. Create 2 × user → [owner, teammate]
    const [owner, teammate] = await factories.UserFactory.createMany(2);
  2. Create SQ group member user ids owner.id, teammate.id
    await factories.SQGroupFactory.create({
    	memberUserIds: [owner.id, teammate.id],
    });
  3. Create 12 × user → otherUsers
    const otherUsers = await factories.UserFactory.createMany(12);
  4. For each [index, user] of otherUsers.entries()
    for (const [index, user] of otherUsers.entries()) {
    	const group = await factories.SQGroupFactory.create({
    		memberUserIds: [user.id],
    	});
    	// the least recently active group sorts last, giving the suggestion
    	// the whole page to move the card up
    	await factories.backdate("Group", group.id, {
    		latestActionAt: sub(new Date(), { minutes: index }),
    	});
    }
    1. Create SQ group member user ids user.id → group
      const group = await factories.SQGroupFactory.create({
      	memberUserIds: [user.id],
      });
    2. the least recently active group sorts last, giving the suggestion the whole page to move the card up

      Backdate “Group” group.id latest action at sub(new Date(), { minutes: index })
      await factories.backdate("Group", group.id, {
      	latestActionAt: sub(new Date(), { minutes: index }),
      });
Oowner
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
  2. Group card otherUsers.length - 1 → lastCard
    const lastCard = looking.groupCard(otherUsers.length - 1);
Group card
Act
  1. Scroll into view if needed
    await lastCard.root.scrollIntoViewIfNeeded();
Assert
  1. evaluate () => window.scrollY is more than 0
    expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
Act
  1. Track scroll Y at press → scrollYAtPress
    const scrollYAtPress = await trackScrollYAtPress(page);
  2. Press suggest
    await lastCard.pressSuggest();
SendouQ looking page
Assert
  1. trail is visible
    await expect(looking.groupCard(0).trail).toBeVisible();
  2. evaluate () => window.scrollY is scrollYAtPress()
    expect(await page.evaluate(() => window.scrollY)).toBe(
    	await scrollYAtPress(),
    );
Source
test("Suggesting floats the group without dragging the viewer's scroll position along", async ({
	page,
	factories,
}) => {
	const [owner, teammate] = await factories.UserFactory.createMany(2);
	await factories.SQGroupFactory.create({
		memberUserIds: [owner.id, teammate.id],
	});

	const otherUsers = await factories.UserFactory.createMany(12);
	for (const [index, user] of otherUsers.entries()) {
		const group = await factories.SQGroupFactory.create({
			memberUserIds: [user.id],
		});
		// the least recently active group sorts last, giving the suggestion
		// the whole page to move the card up
		await factories.backdate("Group", group.id, {
			latestActionAt: sub(new Date(), { minutes: index }),
		});
	}

	await impersonate(page, owner.id);

	const looking = new SendouQLookingPage(page);
	await looking.goto();

	const lastCard = looking.groupCard(otherUsers.length - 1);
	await lastCard.root.scrollIntoViewIfNeeded();
	expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);

	const scrollYAtPress = await trackScrollYAtPress(page);
	await lastCard.pressSuggest();

	await expect(looking.groupCard(0).trail).toBeVisible();
	expect(await page.evaluate(() => window.scrollY)).toBe(
		await scrollYAtPress(),
	);
})