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
57 changes: 54 additions & 3 deletions e2e/phase-one.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { defaultLevelUpParameters } from "../src/lib/level-up";
import * as OTPAuth from "otpauth";

test.describe.configure({ mode: "serial" });
Expand Down Expand Up @@ -403,6 +404,58 @@ test("Progression is a Référentiels reference and keeps Silver unconfirmed", a
).toBeVisible();
});

// Bloc 98/A: the bug this bloc fixes, end to end and in the order it was
// reported — an admin fills a league in, saves, and the public reference is
// still telling players the league is unavailable. Availability now comes from
// the stored values, so saving is all it takes.
test("Bloc 98/A: a league becomes available publicly as soon as an admin fills it in", async ({
page,
}) => {
test.setTimeout(60_000);
const leagueGroup = page.getByRole("group", { name: "Ligue" });
const endpoint = "/api/admin/guides/references/level-up";

await page.goto("/referentiels/level-up");
await leagueGroup.getByRole("button", { name: "Argent" }).click();
await expect(page.getByRole("status")).toContainText("non encore confirmée");
await expect(page.getByRole("table")).toHaveCount(0);

await b90EnsureRoot(page);
await b90Login(page, B90_ROOT.username, B90_ROOT.password);
const filled = await page.request.put(endpoint, {
data: {
...defaultLevelUpParameters,
troops: {
...defaultLevelUpParameters.troops,
silver: { coefficient: 30, ratio: 1.24 },
},
},
});
expect(filled.status()).toBe(200);

await page.goto("/referentiels/level-up");
await leagueGroup.getByRole("button", { name: "Argent" }).click();
await expect(page.getByRole("table").first()).toBeVisible();
// The saved values are what the table is built from: level 2 is
// coefficient × ratioÂČ = 30 × 1.24ÂČ = 46.
await expect(
page.getByRole("row").nth(2).getByRole("cell").nth(2),
).toHaveText("46");

// Putting the league back to blank must be savable too — the admin route
// used to reject any zero, so the reference could not be saved at all while
// a league was still unconfirmed (Bloc 98/A). This also restores the seeded
// state for the rest of the suite.
const blanked = await page.request.put(endpoint, {
data: defaultLevelUpParameters,
});
expect(blanked.status()).toBe(200);
await page.goto("/referentiels/level-up");
await leagueGroup.getByRole("button", { name: "Argent" }).click();
await expect(page.getByRole("status")).toContainText("Ligues disponibles :");
await expect(page.getByRole("table")).toHaveCount(0);
});

test("calculator pages only repeat names in their navigation tabs", async ({
page,
}) => {
Expand Down Expand Up @@ -667,9 +720,7 @@ test("Ranking converts position and percentage into league ranges", async ({
await rankingLeagueGroup.getByRole("button", { name: "Bronze" }).click();
// Bloc 92/A11y: the ranking placeholder no longer carries its own
// role="status" (it sits inside a permanent aria-live region); match its text.
await expect(
page.getByText(/Ă  dĂ©finir dans l’administration/),
).toBeVisible();
await expect(page.getByText(/Ă  dĂ©finir dans l’administration/)).toBeVisible();
});

test("Skills exposes gem distributions and exact templar costs", async ({
Expand Down
2 changes: 1 addition & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@
"name": "Stufenaufstieg",
"league": "Liga",
"select-league": "WĂ€hle eine Liga, um die Referenztabelle anzuzeigen.",
"unconfirmed": "⚠ Die Truppenformel ist fĂŒr diese Liga noch nicht bestĂ€tigt — derzeit sind nur Legende, Diamant, Platin, Bronze und Gold verfĂŒgbar.",
"unconfirmed": "⚠ Die Truppenformel ist fĂŒr diese Liga noch nicht bestĂ€tigt. {count, plural, =0 {Derzeit ist keine Liga verfĂŒgbar.} other {VerfĂŒgbare Ligen: {leagues}.}}",
"columns": {
"level": "Stufe",
"xp": "Benötigte XP",
Expand Down
2 changes: 1 addition & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@
"name": "Level Up",
"league": "League",
"select-league": "Choose a league to display the reference table.",
"unconfirmed": "⚠ The troop formula is not yet confirmed for this league — only Legend, Diamond, Platinum, Bronze and Gold are currently available.",
"unconfirmed": "⚠ The troop formula is not yet confirmed for this league. {count, plural, =0 {No league is available at the moment.} other {Available leagues: {leagues}.}}",
"columns": {
"level": "Level",
"xp": "Required XP",
Expand Down
2 changes: 1 addition & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@
"name": "Subida de Nivel",
"league": "Liga",
"select-league": "Elige una liga para mostrar la tabla de referencia.",
"unconfirmed": "⚠ La fĂłrmula de tropas aĂșn no estĂĄ confirmada para esta liga — actualmente solo estĂĄn disponibles Leyenda, Diamante, Platino, Bronce y Oro.",
"unconfirmed": "⚠ La fĂłrmula de tropas aĂșn no estĂĄ confirmada para esta liga. {count, plural, =0 {Por ahora no hay ninguna liga disponible.} other {Ligas disponibles: {leagues}.}}",
"columns": {
"level": "Nivel",
"xp": "XP requerida",
Expand Down
2 changes: 1 addition & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@
"name": "Progression",
"league": "Ligue",
"select-league": "Choisis une ligue pour afficher le référentiel.",
"unconfirmed": "⚠ Formule de troupes non encore confirmĂ©e pour cette ligue — seules LĂ©gende, Diamant, Platine, Bronze et Or sont disponibles pour le moment.",
"unconfirmed": "⚠ Formule de troupes non encore confirmĂ©e pour cette ligue. {count, plural, =0 {Aucune ligue n'est disponible pour le moment.} other {Ligues disponibles : {leagues}.}}",
"columns": {
"level": "Niveau",
"xp": "XP requis",
Expand Down
2 changes: 1 addition & 1 deletion messages/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@
"name": "Seviye Atlama",
"league": "Lig",
"select-league": "Referans tablosunu görĂŒntĂŒlemek için bir lig seç.",
"unconfirmed": "⚠ Birlik formĂŒlĂŒ bu lig için henĂŒz doğrulanmadı — ßu anda yalnızca Efsane, Elmas, Platin, Bronz ve Altın kullanılabilir.",
"unconfirmed": "⚠ Birlik formĂŒlĂŒ bu lig için henĂŒz doğrulanmadı. {count, plural, =0 {ƞu anda hiçbir lig mevcut değil.} other {Mevcut ligler: {leagues}.}}",
"columns": {
"level": "Seviye",
"xp": "Gerekli XP",
Expand Down
15 changes: 5 additions & 10 deletions src/app/api/admin/guides/references/level-up/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { NextResponse } from "next/server";
import { authorizedSession, forbiddenResponse } from "@/auth/api-authorization";
import { parseLevelUpParameters } from "@/lib/level-up";
import {
isSavableLevelUpParameters,
parseLevelUpParameters,
} from "@/lib/level-up";
import { saveFormulaParameters } from "@/services/formula-parameters-admin";

export async function PUT(request: Request) {
Expand All @@ -9,15 +12,7 @@ export async function PUT(request: Request) {
const parameters = parseLevelUpParameters(
await request.json().catch(() => null),
);
const numbers = [
parameters.xp.base,
parameters.xp.ratio,
...Object.values(parameters.troops).flatMap(({ coefficient, ratio }) => [
coefficient,
ratio,
]),
];
if (numbers.some((value) => !Number.isFinite(value) || value <= 0))
if (!isSavableLevelUpParameters(parameters))
return NextResponse.json({ error: "invalid_parameters" }, { status: 400 });
await saveFormulaParameters({
calculatorSlug: "level-up",
Expand Down
72 changes: 71 additions & 1 deletion src/components/level-up-reference.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { NextIntlClientProvider } from "next-intl";
import messages from "../../messages/fr.json";
Expand Down Expand Up @@ -145,4 +151,68 @@ describe("LevelUpReference", () => {
screen.getByRole("link", { name: /Taux de gain d’XP$/ }),
).toHaveAttribute("href", "/tools/combat?open=xp");
});

// Bloc 98/A: the reported bug, end to end on the public side — Argent's
// coefficient and ratio were saved in the admin, and the reference still
// told the player the league was unavailable.
it("Bloc98/A: shows the table for a league an admin has just filled in", () => {
const parameters = {
...defaultLevelUpParameters,
troops: {
...defaultLevelUpParameters.troops,
silver: { coefficient: 30, ratio: 1.24 },
},
};
render(
<NextIntlClientProvider locale="fr" messages={messages}>
<LevelUpReference parameters={parameters} />
</NextIntlClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Argent" }));
expect(screen.getAllByRole("row")).toHaveLength(62);
expect(screen.queryByRole("status")).toBeNull();
// And the stored values are what the table is built from: level 2 is
// coefficient × ratioÂČ = 30 × 1.24ÂČ = 46. A league merely let through the
// display check, with its formula still refused, would show 0 here.
const levelTwo = within(screen.getAllByRole("row")[2]).getAllByRole("cell");
expect(levelTwo[0]).toHaveTextContent("2");
expect(levelTwo[2]).toHaveTextContent("46");
});

it("Bloc98/A: names the leagues that really are available, not a fixed list", () => {
// With the shipped defaults Argent is the only one missing, so the notice
// must name the other five — and never Argent itself.
render(
<NextIntlClientProvider locale="fr" messages={messages}>
<LevelUpReference parameters={defaultLevelUpParameters} />
</NextIntlClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Argent" }));
const notice = screen.getByRole("status");
expect(notice).toHaveTextContent(
"Ligues disponibles : Bronze, Or, Platine, Diamant et Légende.",
);
expect(notice).not.toHaveTextContent("Argent");
});

it("Bloc98/A: drops a league from that list as soon as its values are cleared", () => {
// The same sentence, recomputed: clearing Légende must remove it from the
// notice, which a hard-coded list of names could never do.
const parameters = {
...defaultLevelUpParameters,
troops: {
...defaultLevelUpParameters.troops,
legend: { coefficient: 0, ratio: 0 },
},
};
render(
<NextIntlClientProvider locale="fr" messages={messages}>
<LevelUpReference parameters={parameters} />
</NextIntlClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Légende" }));
expect(screen.getByRole("status")).toHaveTextContent(
"Ligues disponibles : Bronze, Or, Platine et Diamant.",
);
});
});
24 changes: 21 additions & 3 deletions src/components/level-up-reference.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use client";

import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { formatGameNumber } from "../lib/format";
import {
availableLevelUpLeagues,
hasLevelUpTroopsFormula,
levelUpChestAt,
levelUpTroopsAt,
xpAt,
Expand Down Expand Up @@ -75,12 +77,19 @@ export function LevelUpReference({
parameters: LevelUpParameters;
}) {
const t = useTranslations("level-up");
const game = useTranslations("game");
const xpGainRate = useTranslations("xp-gain-rate");
const crossReference = useTranslations("crossReference");
const levelUpReference = referenceCatalog.find(
(item) => item.slug === "level-up",
)!;
const [league, setLeague] = useSyncedLeague();
const available = availableLevelUpLeagues(parameters);
// Intl handles the "A, B et C" joining per language, so the sentence below
// needs no hand-written separator in any of the 5 locales.
const listFormatter = new Intl.ListFormat(useLocale(), {
type: "conjunction",
});
const [page, setPage] = useState(0);
const start = page * parameters.pageSize + 1;
const levels = Array.from(
Expand Down Expand Up @@ -109,9 +118,18 @@ export function LevelUpReference({
<p className="empty-state" role="status">
{t("select-league")}
</p>
) : league === "silver" ? (
) : !hasLevelUpTroopsFormula(league, parameters) ? (
// Bloc 98/A: a league is unavailable because its formula is missing
// from the parameters, not because of its name — and the leagues it
// names are the ones that really do have one, so this sentence can no
// longer contradict what an admin has just saved.
<p className="empty-state" role="status">
{t("unconfirmed")}
{t("unconfirmed", {
count: available.length,
leagues: listFormatter.format(
available.map((item) => game(`leagues.${item}`)),
),
})}
</p>
) : (
<>
Expand Down
74 changes: 73 additions & 1 deletion src/components/named-parameters-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import {
cleanup,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithIntl as render } from "../test/render-with-intl";
import { defaultCityParameters } from "../lib/city-parameters";
Expand Down Expand Up @@ -214,6 +220,72 @@ describe("named formula parameter editors", () => {
expect(body.troops.silver).toEqual({ coefficient: 12.5, ratio: 1.1 });
});

// Bloc 98/C: one row per league, in the game's own progression order —
// Silver used to be a hand-written row appended after the five "confirmed"
// ones, so it sat last, after Légende.
it("Bloc98/C: lists every league in game progression order", () => {
render(<LevelUpParametersEditor initial={defaultLevelUpParameters} />);
const rows = screen.getAllByRole("row").slice(1); // drop the header row
expect(
rows.map((row) =>
within(row).getAllByRole("cell")[0].textContent?.trim(),
),
).toEqual([
"Bronze",
expect.stringContaining("Argent"),
"Or",
"Platine",
"Diamant",
"Légende",
]);
});

// Bloc 98/B: the note stayed next to Argent for good, even once values had
// been entered and saved. It now follows what is actually stored.
it("Bloc98/B: drops the 'not confirmed' note once a league has values", () => {
// The Bloc42/B test above renders the shipped defaults and finds the note
// on Argent; the same editor, given values for Argent, must not show it —
// it used to stay for good, whatever had been entered and saved.
render(
<LevelUpParametersEditor
initial={{
...defaultLevelUpParameters,
troops: {
...defaultLevelUpParameters.troops,
silver: { coefficient: 30, ratio: 1.24 },
},
}}
/>,
);
expect(
screen.getByRole("spinbutton", { name: "Argent Coefficient" }),
).toHaveValue(30);
expect(screen.queryByText(/Formule de troupes non confirmée/)).toBeNull();
});

it("Bloc98/B: puts that note on any league left empty, not only on Argent", () => {
render(
<LevelUpParametersEditor
initial={{
...defaultLevelUpParameters,
troops: {
...defaultLevelUpParameters.troops,
legend: { coefficient: 0, ratio: 0 },
},
}}
/>,
);
const noted = screen
.getAllByText(/Formule de troupes non confirmée/)
.map((note) =>
note
.closest("td")
?.textContent?.replace(/\(.*\)/, "")
.trim(),
);
expect(noted).toEqual(["Argent", "Légende"]);
});

it("Bloc35 8.1: narrows the per-skill/per-league value columns (never exceed 100%)", () => {
render(<GemParametersEditor initial={defaultGemParameters} />);
const valueCell = screen
Expand Down
Loading
Loading