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
1 change: 1 addition & 0 deletions .maestro/ci/ios.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ appId: app.cuetracker
- runFlow: ../flows/show-detail-bulk-mark.yaml
- runFlow: ../flows/episode-sheet.yaml
- runFlow: ../flows/calendar.yaml
- runFlow: ../flows/history.yaml
- runFlow: ../flows/library.yaml
- runFlow: ../flows/search.yaml
- runFlow: ../flows/returning-user-app-idle.yaml
48 changes: 48 additions & 0 deletions .maestro/flows/history.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
appId: app.cuetracker
---
- runScript: lib/reset-fake.js
- assertTrue: ${output.fakeReset}
- runFlow: lib/connect.yaml
- tapOn:
id: "avatar-link"
- tapOn:
id: "profile-to-history"
- assertVisible:
id: "screen-history"
- assertVisible:
id: "history-day-header-0"
- copyTextFrom:
id: "history-day-header-0"
- evalScript: ${output.historyDay = maestro.copiedText}
- assertVisible:
id: "history-row-0"
- assertVisible:
id: "history-row-0"
text: ".*, 2 plays"
- tapOn:
id: "history-row-0-check"
- assertVisible:
id: "snackbar"
- tapOn:
id: "snackbar-undo"
- assertVisible:
text: ${output.historyDay}
- assertVisible:
id: "history-row-0"
- tapOn: "Filter by title"
- inputText: "zzzznomatch"
- assertVisible: "No titles match."
- eraseText
- tapOn: "(?i)close|cancel"
- assertVisible:
id: "history-row-0"
- tapOn:
id: "history-jump"
- assertVisible:
id: "history-jump-sheet"
- tapOn:
id: "history-jump-month-8"
- assertVisible:
id: "screen-history"
- waitForAnimationToEnd
- takeScreenshot: history-month
8 changes: 5 additions & 3 deletions packages/core/src/hooks/useRemovePlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useOptimisticWrite } from "./useOptimisticWrite";

export interface RemovePlayController {
readonly removedIds: ReadonlySet<number>;
removePlay(entry: HistoryEntry): Promise<void>;
removePlay(entry: HistoryEntry, remaining?: number): Promise<void>;
}

const withoutId = (set: ReadonlySet<number>, id: number): Set<number> => {
Expand Down Expand Up @@ -59,7 +59,7 @@ export function useRemovePlay(): RemovePlayController {
);

const removePlay = useCallback(
async (entry: HistoryEntry) => {
async (entry: HistoryEntry, remaining = 0) => {
setRemovedIds((previous) => new Set(previous).add(entry.historyId));
const op = buildRemoveHistoryPlayOp({
opId: runtime.newId(),
Expand All @@ -76,7 +76,9 @@ export function useRemovePlay(): RemovePlayController {
});
removeOutcomes.current.set(entry.historyId, settled);
pending.current = entry;
showUndoable("Removed play", () => undoAction.current());
showUndoable(remaining > 0 ? `Removed 1 play · ${remaining} remain` : "Removed play", () =>
undoAction.current(),
);
if ((await settled) === "failed") {
if (pending.current?.historyId === entry.historyId) pending.current = null;
showError("Couldn't remove that play. Please try again.");
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/ci/render-counts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

function gate(base: Record<string, number>, head: Record<string, number>) {
const directory = mkdtempSync(join(tmpdir(), "render-counts-"));
const basePath = join(directory, "base");
const headPath = join(directory, "head");
for (const [file, counts] of [
[basePath, base],
[headPath, head],
] as const) {
writeFileSync(
file,
[
JSON.stringify({ metadata: {} }),
...Object.entries(counts).map(([name, meanCount]) => JSON.stringify({ name, meanCount })),
].join("\n"),
);
}
try {
return spawnSync(process.execPath, ["scripts/check-render-counts.mjs", basePath, headPath], {
encoding: "utf8",
});
} finally {
rmSync(directory, { recursive: true });
}
}

const history = "history row removes one play";

describe("render count gate", () => {
it("accepts the measured count of an introduced scenario", () => {
expect(gate({ existing: 3 }, { existing: 3, [history]: 2 }).status).toBe(0);
});
it("rejects an introduced scenario with a different count", () => {
expect(gate({}, { [history]: 3 }).status).toBe(1);
});
it("keeps measured base counts authoritative", () => {
expect(gate({ [history]: 3 }, { [history]: 2 }).status).toBe(1);
});
it("rejects missing and unknown scenarios", () => {
expect(gate({}, {}).status).toBe(1);
expect(gate({}, { [history]: 2, unknown: 1 }).status).toBe(1);
});
});
19 changes: 18 additions & 1 deletion packages/core/test/harness/mock-trakt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,12 @@ describe("the seeded account parses through the app's own contracts", () => {

it("serves history one page at a time, with the pagination headers the client walks", async () => {
const first = await getHistory(client(), "all", 1);
expect(ok(first).length).toBe(30);
const rows = ok(first);
expect(rows.length).toBe(30);
expect(first.ok && first.pagination?.pageCount).toBeGreaterThan(1);
const repeated = rows.filter((row) => row.episode?.ids.trakt === 880608);
expect(repeated).toHaveLength(2);
expect(new Set(repeated.map((row) => row.watched_at.slice(0, 10))).size).toBe(1);
expect(ok(await getHistory(client(), "movies", 1)).every((row) => row.type === "movie")).toBe(
true,
);
Expand Down Expand Up @@ -245,6 +249,19 @@ describe("the seeded account parses through the app's own contracts", () => {
]);
});

it("applies the history month window before pagination", async () => {
const first = ok(await getHistory(client(), "all", 1))[0];
expect(first).toBeDefined();
const start = first?.watched_at ?? "";
const query = new URLSearchParams({ start_at: start, end_at: start });
const response = await fetch(`${baseUrl}/users/me/history?${query}`);
const rows = await response.json();
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row: { watched_at: string }) => row.watched_at === start)).toBe(true);
const empty = await fetch(`${baseUrl}/users/me/history?start_at=2000-01-01&end_at=2000-02-01`);
expect(await empty.json()).toEqual([]);
});

it("answers a path it does not model with 404 rather than an empty success", async () => {
const result = await client().get("/shows/8801/people");
expect(result.ok ? null : result.error).toEqual({ kind: "not-found" });
Expand Down
11 changes: 11 additions & 0 deletions packages/core/test/ui/remove-play.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ function setup(): { readonly slot: RemovePlayController[]; readonly submitted: Q
beforeEach(dismissSnack);

describe("useRemovePlay", () => {
it("states the loaded remainder and keeps successful undo silent", async () => {
const { slot } = setup();
await act(async () => slot[0]?.removePlay(entry, 2));
expect(useSnackbar.getState().snack?.message).toBe("Removed 1 play · 2 remain");
await act(async () => {
useSnackbar.getState().snack?.actions?.[0]?.onPress();
await Promise.resolve();
});
expect(useSnackbar.getState().snack).toBeNull();
});

it("hides one exact play and restores it through the snackbar action", async () => {
const { slot, submitted } = setup();

Expand Down
36 changes: 36 additions & 0 deletions packages/native/__tests__/account-layout-options.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render } from "@testing-library/react-native";
import type { ReactElement, ReactNode } from "react";
import AccountLayout from "../app/(account)/_layout";

interface HeaderOptions {
readonly headerStyle?: { readonly backgroundColor?: unknown };
readonly headerLargeStyle?: { readonly backgroundColor?: unknown };
}

let mockStackOptions: HeaderOptions | undefined;

jest.mock("expo-router", () => {
const { createElement } = require("react") as typeof import("react");
const { View: MockView } = require("react-native") as typeof import("react-native");
const Stack = ({
children,
screenOptions,
}: {
readonly children: ReactNode;
readonly screenOptions?: HeaderOptions;
}): ReactElement => {
mockStackOptions = screenOptions;
return createElement(MockView, null, children);
};
Stack.Screen = (): null => null;
return { Stack, useRouter: () => ({ dismissAll: jest.fn() }) };
});

it("gives every account screen an opaque header in both appearances", async () => {
await render(<AccountLayout />);

expect(mockStackOptions?.headerStyle?.backgroundColor).toBeDefined();
expect(mockStackOptions?.headerLargeStyle?.backgroundColor).toEqual(
mockStackOptions?.headerStyle?.backgroundColor,
);
});
2 changes: 2 additions & 0 deletions packages/native/__tests__/account-layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const routes = {
"(account)/profile": (): ReactElement => <View testID="screen-profile" />,
"(account)/settings": (): ReactElement => <View testID="screen-settings" />,
"(account)/history": (): ReactElement => <View testID="screen-history" />,
"(account)/movie/[movieId]": (): ReactElement => <View />,
"(account)/show/[showId]/episode/[season]/[episode]": (): ReactElement => <View />,
};

const openAccount = async (): Promise<void> => {
Expand Down
40 changes: 40 additions & 0 deletions packages/native/__tests__/history-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { groupHistory } from "@cue/core/domain/history";
import { entryDetail, historySections } from "../src/screens/history/model";
import { historyEntry } from "./support/history";

test("local days retain separate rewatches and insert year boundaries", () => {
const newest = historyEntry({ watchedAt: "2026-01-01T20:00:00Z" });
const older = historyEntry({ historyId: 2, watchedAt: "2026-01-01T02:00:00Z" });
const days = groupHistory([older, newest], {
now: Date.parse(newest.watchedAt),
timeZone: "America/Los_Angeles",
});
const sections = historySections(days, "");
expect(sections.map((section) => section.key)).toEqual(["2026-01-01", "2025-12-31"]);
expect(sections.map((section) => section.yearHeading)).toEqual([null, "2025"]);
expect(sections.flatMap((section) => section.data).map((row) => row.plays)).toEqual([1, 1]);
});

test("different episodes stay separate and mixed day totals count plays", () => {
const episode = historyEntry({
type: "episode",
season: 1,
number: 2,
episodeTitle: "Night Signal",
});
const days = groupHistory(
[
episode,
{ ...episode, historyId: 4 },
{ ...episode, number: 3, historyId: 5 },
historyEntry(),
],
{ now: Date.now(), timeZone: "UTC" },
);
const section = historySections(days, "")[0];
expect(section?.data.map((row) => row.plays)).toEqual([2, 1, 1]);
expect(section?.label).toMatch(/3 episodes · 1 movie$/);
expect(entryDetail(episode)).toBe("S1 E2 Night Signal");
expect(entryDetail({ ...episode, episodeTitle: null })).toBe("S1 E2");
expect(entryDetail(historyEntry({ year: null }))).toBe("Movie");
});
23 changes: 23 additions & 0 deletions packages/native/__tests__/history.perf-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { fireEvent } from "@testing-library/react-native";
import { type ReactElement, useState } from "react";
import { configure, measureRenders } from "reassure";
import { HistoryRow } from "../src/screens/history/HistoryRow";
import { historyEntry } from "./support/history";

configure({ testingLibrary: "react-native" });
jest.setTimeout(15_000);
jest.mock("expo-router", () => require("./support/native-ui").expoRouterModule());
jest.mock("@expo/ui/community/menu", () => require("./support/native-ui").menuModule());

const entry = historyEntry();

function RewatchedRow(): ReactElement {
const [plays, setPlays] = useState(3);
return <HistoryRow entry={entry} plays={plays} index={0} onRemove={() => setPlays(2)} />;
}

test("history row removes one play", async () => {
await measureRenders(<RewatchedRow />, {
scenario: async (screen) => fireEvent.press(screen.getByTestId("history-row-0-check")),
});
});
Loading
Loading