Skip to content
Open
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
126 changes: 125 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"@fontsource-variable/jetbrains-mono": "5.3.0",
"@tauri-apps/api": "2.11.1",
"@winer/shared": "workspace:*",
"fflate": "0.8.2",
"html-to-image": "1.11.13",
"lucide-react": "1.52.0",
"react": "19.3.0",
"react-dom": "19.3.0"
Expand Down
61 changes: 61 additions & 0 deletions app/frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
} from "@winer/shared";

import { App } from "./App";
import * as reports from "./pages/history/reports";
import type { Backend, CommandName } from "./lib/backend";
import { demoBackend, demoGame, demoLobby } from "./lib/demo";
import { AppStore, EMPTY_SNAPSHOT, StoreContext } from "./lib/store";
Expand Down Expand Up @@ -2175,6 +2176,65 @@ describe("history", () => {
expect(how).toHaveTextContent(`落在「${rating.label}」`);
});

it("exports complete scoreboards with rune groups directly to the remembered folder", async () => {
const saved = vi.fn((_args: unknown) => "D:/Reports/match.png");
const backend = demoWith({
get_report_directory: () => "D:/Reports",
save_report_image: saved,
});
const capture = vi.spyOn(reports, "captureReport").mockImplementation(async (node) => {
expect(node.querySelectorAll("li")).toHaveLength(10);
expect(node.querySelectorAll('[aria-label="主系符文"]')).toHaveLength(10);
expect(node.querySelectorAll('[aria-label="属性碎片"]')).toHaveLength(10);
return new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
});
try {
const user = await openHistory(backend);
await screen.findByText("D:/Reports");
const checks = screen.getAllByRole("checkbox", { name: /选择对局/ });
await user.click(checks[0]!);
await user.click(checks[1]!);
await user.click(screen.getByRole("button", { name: zhCN["history.exportToFolder"] }));
await waitFor(() => expect(saved).toHaveBeenCalledTimes(2));
expect(capture).toHaveBeenCalledTimes(2);
expect(saved.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
name: expect.stringMatching(/\.png$/),
contents: [137, 80, 78, 71, 13, 10, 26, 10],
}),
);
} finally {
capture.mockRestore();
}
});

it("shows only custom games despite the hide setting and selects across pages", async () => {
const backend = demoBackend();
const call = vi.spyOn(backend, "call");
const user = await openHistory(backend);
await user.type(
screen.getByRole("textbox", { name: zhCN["history.search"] }),
"野区观光客#10005",
);
await user.click(screen.getByRole("button", { name: zhCN["history.find"] }));
await screen.findByRole("button", { name: zhCN["history.mine"] }, { timeout: 5000 });
await screen.findByText("已隐藏 2 场");
await user.click(screen.getByRole("radio", { name: zhCN["history.custom"] }));
expect(await screen.findByText("第 1–2 场 · 共 2 场")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: zhCN["history.hideCustom"] })).toBeDisabled();
await user.click(screen.getByRole("checkbox", { name: zhCN["history.selectVisible"] }));
expect(screen.getByText("已选择 2 场(跨页保留)")).toBeInTheDocument();
expect(screen.getAllByRole("checkbox", { name: /选择对局/, checked: true })).toHaveLength(2);
expect(call).not.toHaveBeenCalledWith("set_settings", expect.anything());
await user.click(screen.getByRole("radio", { name: zhCN["history.all"] }));
await user.click(screen.getByRole("checkbox", { name: zhCN["history.selectVisible"] }));
expect(screen.getByText("已选择 12 场(跨页保留)")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "下一页" }));
expect(screen.getAllByRole("checkbox", { name: /选择对局/, checked: false })).toHaveLength(10);
await user.click(screen.getByRole("button", { name: zhCN["history.clearSelection"] }));
expect(screen.getByRole("button", { name: zhCN["history.exportReports"] })).toBeDisabled();
});

it("hides custom games by default, says how many, and shows them once switched", async () => {
const backend = demoBackend();
const call = vi.spyOn(backend, "call");
Expand All @@ -2185,6 +2245,7 @@ describe("history", () => {
"野区观光客#10005",
);
await user.click(screen.getByRole("button", { name: zhCN["history.find"] }));
await screen.findByRole("button", { name: zhCN["history.mine"] }, { timeout: 5000 });
expect(await screen.findByText("已隐藏 2 场")).toBeInTheDocument();
expect(await screen.findByText("第 1–50 场 · 共 60 场")).toBeInTheDocument();
expect(screen.queryByText("嚎哭深渊 全随机"), "custom games are hidden").toBeNull();
Expand Down
36 changes: 36 additions & 0 deletions app/frontend/src/game/MatchDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,41 @@ function HeaderRow() {
);
}

function RuneChoices({ player }: { player: PlayerLine }) {
const t = useT();
const groups = [
{
label: t("history.primaryRunes"),
ids: [player.primaryStyle, ...(player.primaryRunes ?? [player.keystone])],
},
{
label: t("history.secondaryRunes"),
ids: [player.subStyle, ...(player.secondaryRunes ?? [])],
},
{ label: t("history.statShards"), ids: player.statShards ?? [] },
]
.map((group) => ({ ...group, ids: group.ids.filter((id) => id > 0) }))
.filter((group) => group.ids.length);
if (!groups.length) return null;
return (
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
{groups.map((group) => (
<span
key={group.label}
role="group"
aria-label={group.label}
title={group.label}
className="inline-flex items-center gap-0.5"
>
{group.ids.map((id, index) => (
<AssetIcon key={index} kind="perks" id={id} size={14} />
))}
</span>
))}
</span>
);
}

function PlayerRow({
player,
maxDamage,
Expand Down Expand Up @@ -196,6 +231,7 @@ function PlayerRow({
)}
{player.award && <AwardBadge award={player.award} />}
</span>
<RuneChoices player={player} />
{(player.augments.length > 0 || player.feats.length > 0 || title) && (
<span className="flex min-w-0 items-center gap-1">
{player.augments.length > 0 && (
Expand Down
4 changes: 4 additions & 0 deletions app/frontend/src/game/game.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ function line(
spells: [4, 14],
items: [0, 0, 0, 0, 0, 0, 0],
augments: [],
primaryStyle: 8000,
primaryRunes: [8005, 9111, 9104, 8014],
secondaryRunes: [8139, 8135],
statShards: [5005, 5008, 5001],
keystone: 0,
subStyle: 0,
kills: 10,
Expand Down
4 changes: 4 additions & 0 deletions app/frontend/src/lib/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export interface Commands {
list_player_notes: Command<undefined, NoteEntry[]>;
delete_player_note: Command<{ puuid: string }, boolean>;
/** Into Downloads under a new name when the one asked for is taken; answers the file's name. */
get_report_directory: Command<undefined, string | null>;
choose_report_directory: Command<undefined, string | null>;
save_report_image: Command<{ name: string; contents: number[] }, string>;
save_report_archive: Command<{ contents: number[] }, string | null>;
save_export: Command<{ stem: string; format: ExportFormat; contents: string }, string>;
/** Asks again for a player whose record failed to load; nothing for one that loaded. */
retry_player: Command<{ puuid: string }, null>;
Expand Down
22 changes: 22 additions & 0 deletions app/frontend/src/lib/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ function line(
spells: [4, 14],
items: [3031, 3006, 3094, 0, 0, 0, 3340],
augments: [],
primaryStyle: 8000,
primaryRunes: [8005, 9111, 9104, 8014],
secondaryRunes: [8139, 8135],
statShards: [5005, 5008, 5001],
keystone: 8005,
subStyle: 8100,
kills,
Expand Down Expand Up @@ -1282,6 +1286,24 @@ export function demoBackend(): Backend {
.map(([puuid, note]) => ({ puuid, note }))
.sort((a, b) => b.note.updatedAt - a.note.updatedAt),
delete_player_note: ({ puuid }) => notes.delete(puuid),
get_report_directory: () => null,
choose_report_directory: () => {
throw new Error("Folder export is available in the desktop app / 文件夹导出请使用桌面版");
},
save_report_image: () => {
throw new Error("Folder export requires the desktop app");
},
save_report_archive: ({ contents }) => {
const url = URL.createObjectURL(
new Blob([new Uint8Array(contents)], { type: "application/zip" }),
);
const link = document.createElement("a");
link.href = url;
link.download = "Winer_Battle_Reports.zip";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
return link.download;
},
save_export: ({ stem, format }) => `${stem}.${format}`,
retry_player: () => null,
run_diagnostics: () => ({
Expand Down
Loading