fix: keep tierlist saves consistent
Build and Deploy Next Preview / build-deploy (push) Successful in 2m52s

This commit is contained in:
2026-10-10 08:10:54 +00:00 Unverified
parent c8f9352fe9
commit 26a4331a08
15 changed files with 1051 additions and 76 deletions
+97 -43
View File
@@ -1,7 +1,7 @@
"use server";
import { fetch, randomUUIDv7 } from "bun";
import { and, eq, inArray, isNotNull, lt, not, or, sql } from "drizzle-orm";
import { and, asc, eq, inArray, isNotNull, lt, not, sql } from "drizzle-orm";
import type { PgDatabase } from "drizzle-orm/pg-core";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
@@ -23,6 +23,14 @@ import {
tierlistVersions,
} from "./db/schema";
import { sse, tlSse } from "./db/sse-endpoints";
import type { TierlistWriteResult } from "./tierlist-sync";
import {
finishTierlistWrite,
lockTierlistList,
normalizeTierlistPlacements,
resolveTierlistStateTarget,
validateTierlistStateInput,
} from "./tierlist-write";
import { b2s } from "./utils";
export async function getCharacters(chars: string[]) {
@@ -262,60 +270,106 @@ export async function revalidateCard(sub: string) {
export async function tlState(
data: Partial<typeof tierlistStates.$inferInsert>,
) {
): Promise<TierlistWriteResult<(typeof tierlistStates.$inferSelect)[]>> {
if (!(await adminCheck())) throw "Unauthorized";
const [existing] = await db
.select()
.from(tierlistStates)
.where(
or(
eq(tierlistStates.uuid, `${data.uuid}`),
and(
eq(tierlistStates.ref, `${data.ref}`),
eq(tierlistStates.list, `${data.list}`),
),
),
);
if (existing)
await db
.update(tierlistStates)
.set(data)
.where(eq(tierlistStates.uuid, existing.uuid));
else
await db
.insert(tierlistStates)
.values(data as typeof tierlistStates.$inferInsert);
const list = data.list || existing?.list;
const states = await db
.select()
.from(tierlistStates)
.where(eq(tierlistStates.list, list));
revalidatePath(`/api/tl/${list}/states`);
const { list, ref } = validateTierlistStateInput(data);
await actionLog(`Updated a state in tierlist ${list}`, data);
tlSse(list).pub("update_states", states);
const states = await db.transaction(async (tx) => {
await lockTierlistList(tx, list);
const [byUuid] = data.uuid
? await tx
.select()
.from(tierlistStates)
.where(eq(tierlistStates.uuid, data.uuid))
.limit(1)
: [];
// If legacy duplicate refs exist, update the lexicographically first UUID
// deterministically. No duplicate rows are deleted by this write.
const [byRef] = await tx
.select()
.from(tierlistStates)
.where(and(eq(tierlistStates.ref, ref), eq(tierlistStates.list, list)))
.orderBy(asc(tierlistStates.uuid))
.limit(1);
const existing = resolveTierlistStateTarget(byUuid, byRef, list);
if (existing) {
const { uuid: _uuid, ...updates } = data;
await tx
.update(tierlistStates)
.set(updates)
.where(
and(
eq(tierlistStates.uuid, existing.uuid),
eq(tierlistStates.list, list),
),
);
} else {
await tx
.insert(tierlistStates)
.values(data as typeof tierlistStates.$inferInsert);
}
return tx
.select()
.from(tierlistStates)
.where(eq(tierlistStates.list, list))
.orderBy(asc(tierlistStates.uuid));
});
return finishTierlistWrite({
list,
operation: "state",
data: states,
audit: () => actionLog(`Updated a state in tierlist ${list}`, data),
publish: () => tlSse(list).pub("update_states", states),
revalidate: () => revalidatePath(`/api/tl/${list}/states`),
});
}
export async function tlPlacements(
list: string,
placements: Record<string, string[]>,
) {
): Promise<TierlistWriteResult<Record<string, string[]>>> {
if (!(await adminCheck())) throw "Unauthorized";
const { untiered: _, ...placementObj } = placements;
if (typeof list !== "string" || !list.trim())
throw new Error("A tierlist version is required.");
if (
!placements ||
typeof placements !== "object" ||
Array.isArray(placements) ||
Object.entries(placements).some(
([tier, refs]) =>
!tier.trim() ||
!Array.isArray(refs) ||
refs.some((ref) => typeof ref !== "string"),
)
)
throw new Error("Invalid tierlist placements.");
await db
.update(tierlistVersions)
.set({
placements: placementObj,
})
.where(eq(tierlistVersions.id, list));
// Clone before removing the client-only untiered bucket; callers retain their
// original object and the legacy SSE event still receives its raw payload.
const placementObj = normalizeTierlistPlacements(placements);
const [version] = await db.transaction(async (tx) => {
await lockTierlistList(tx, list);
return tx
.update(tierlistVersions)
.set({ placements: placementObj })
.where(eq(tierlistVersions.id, list))
.returning({ placements: tierlistVersions.placements });
});
if (!version) throw new Error("Tierlist version not found.");
revalidatePath(`/api/tl/${list}`);
await actionLog(`Updated a placement in tierlist ${list}`);
tlSse(list).pub("update_placements", placements);
return finishTierlistWrite({
list,
operation: "placements",
data: version.placements,
audit: () => actionLog(`Updated a placement in tierlist ${list}`),
publish: () => tlSse(list).pub("update_placements", placements),
revalidate: () => revalidatePath(`/api/tl/${list}`),
});
}
export async function cdnDelete(ids: string[], force = false) {
+6 -1
View File
@@ -136,10 +136,15 @@ export class EventSourceEndpoint<T extends EventSourceEventMap> {
throw new Error(
"EventSourceEndpoint.pub(...) can only be called on the server.",
);
this.manager.publish(this.eventMap[event].parse(data), {
const publication = this.manager.publish(this.eventMap[event].parse(data), {
topic: this.endpoint,
event: String(event),
});
// Existing publishers intentionally fire-and-forget. Mark the rejection as
// observed for those callers while still returning the original promise to
// callers that need to await delivery.
void publication.catch(() => {});
return publication;
}
subMany(
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "bun:test";
import {
reconcileCanonicalStates,
reconcileScopedStateWrite,
TierlistCanonicalFetch,
} from "./tierlist-canonical-fetch";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flush() {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("TierlistCanonicalFetch", () => {
it("fetches the switched-to version while ignoring the older response", async () => {
const coordinator = new TierlistCanonicalFetch<string>();
const old = deferred<string>();
const newVersion = deferred<string>();
const applied: string[] = [];
coordinator.setVersion("A");
coordinator.fetch(
"A",
() => old.promise,
(value) => applied.push(value),
() => {},
);
coordinator.setVersion("B");
coordinator.fetch(
"B",
() => newVersion.promise,
(value) => applied.push(value),
() => {},
);
old.resolve("A");
await flush();
expect(applied).toEqual([]);
newVersion.resolve("B");
await flush();
expect(applied).toEqual(["B"]);
});
it("refetches after an SSE invalidation during the initial fetch", async () => {
const coordinator = new TierlistCanonicalFetch<string>();
const first = deferred<string>();
const applied: string[] = [];
let fetchCount = 0;
coordinator.setVersion("A");
const load = () =>
++fetchCount === 1 ? first.promise : Promise.resolve("latest");
const fetch = () =>
coordinator.fetch(
"A",
load,
(value) => applied.push(value),
() => {},
);
fetch();
fetch();
first.resolve("stale");
await flush();
expect(fetchCount).toBe(2);
expect(applied).toEqual(["latest"]);
});
it("does not start a replacement after disposal", async () => {
const coordinator = new TierlistCanonicalFetch<string>();
const first = deferred<string>();
let fetchCount = 0;
coordinator.setVersion("A");
const load = () =>
++fetchCount === 1 ? first.promise : Promise.resolve("next");
coordinator.fetch(
"A",
load,
() => {},
() => {},
);
coordinator.fetch(
"A",
load,
() => {},
() => {},
);
coordinator.dispose();
first.resolve("stale");
await flush();
expect(fetchCount).toBe(1);
});
it("preserves a local edit made during a fetch while reconciling other refs", () => {
const merged = reconcileCanonicalStates(
[
{ ref: "one", value: "stale server value" },
{ ref: "two", value: "fresh server value" },
],
[
{ ref: "one", value: "local edit" },
{ ref: "two", value: "old local value" },
],
new Set(["one"]),
);
expect(merged).toEqual([
{ ref: "two", value: "fresh server value" },
{ ref: "one", value: "local edit" },
]);
});
it("scopes out-of-order server write responses to their own ref", () => {
let states = [
{ ref: "one", value: "newer one" },
{ ref: "two", value: "newer two" },
];
states = reconcileScopedStateWrite(states, "two", [
{ ref: "one", value: "stale one" },
{ ref: "two", value: "saved two" },
]);
states = reconcileScopedStateWrite(states, "one", [
{ ref: "one", value: "saved one" },
{ ref: "two", value: "stale two" },
]);
expect(states).toEqual([
{ ref: "two", value: "saved two" },
{ ref: "one", value: "saved one" },
]);
});
});
+103
View File
@@ -0,0 +1,103 @@
/** Keeps canonical reads current across invalidations and version switches. */
export function reconcileCanonicalStates<T extends { ref: string }>(
canonical: T[],
current: T[],
protectedRefs: Set<string>,
): T[] {
return [
...canonical.filter((entry) => !protectedRefs.has(entry.ref)),
...current.filter((entry) => protectedRefs.has(entry.ref)),
];
}
export function reconcileScopedStateWrite<T extends { ref: string }>(
current: T[],
ref: string,
saved: T[],
): T[] {
return [
...current.filter((entry) => entry.ref !== ref),
...saved.filter((entry) => entry.ref === ref),
];
}
export class TierlistCanonicalFetch<T> {
private activeVersion: string | undefined;
private disposed = false;
private readonly requests = new Map<
string,
{ generation: number; invalidated: boolean; inFlight: boolean }
>();
activate(version: string) {
this.disposed = false;
this.activeVersion = version;
}
setVersion(version: string) {
if (this.activeVersion === version) return;
this.activeVersion = version;
const request = this.requests.get(version);
if (request?.inFlight) request.invalidated = true;
}
invalidate(version: string) {
const request = this.requests.get(version);
if (request?.inFlight) request.invalidated = true;
}
fetch(
version: string,
load: () => Promise<T>,
apply: (data: T) => void,
onError: (error: unknown) => void,
onSettled: () => void = () => {},
) {
if (this.disposed || this.activeVersion !== version) return;
let request = this.requests.get(version);
if (request?.inFlight) {
request.invalidated = true;
return;
}
request ??= { generation: 0, invalidated: false, inFlight: false };
request.inFlight = true;
request.invalidated = false;
const generation = ++request.generation;
this.requests.set(version, request);
void load()
.then(
(data) => {
if (
!this.disposed &&
this.activeVersion === version &&
request?.generation === generation &&
!request.invalidated
)
apply(data);
},
(error: unknown) => {
if (!this.disposed && this.activeVersion === version) onError(error);
},
)
.finally(() => {
if (request?.generation !== generation) return;
request.inFlight = false;
if (
this.disposed ||
this.activeVersion !== version ||
!request.invalidated
) {
if (this.activeVersion === version) onSettled();
return;
}
request.invalidated = false;
this.fetch(version, load, apply, onError, onSettled);
});
}
dispose() {
this.disposed = true;
this.requests.clear();
}
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "bun:test";
import { TierlistSaveQueue } from "./tierlist-save-queue";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flushMicrotasks() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
describe("TierlistSaveQueue", () => {
it("serializes writes and persists the latest coalesced value", async () => {
const writes: string[] = [];
const first = deferred<string>();
const second = deferred<string>();
const queue = new TierlistSaveQueue<string, string>(
(value) => {
writes.push(value);
return writes.length === 1 ? first.promise : second.promise;
},
() => {},
);
const a = queue.enqueue("a");
const b = queue.enqueue("b");
const c = queue.enqueue("c");
await flushMicrotasks();
expect(writes).toEqual(["a"]);
first.resolve("saved a");
await flushMicrotasks();
expect(writes).toEqual(["a", "c"]);
second.resolve("saved c");
await expect(Promise.all([a, b, c])).resolves.toEqual([
"saved a",
"saved c",
"saved c",
]);
});
it("retains failed intent for explicit retry", async () => {
const writes: string[] = [];
const retry = deferred<string>();
const queue = new TierlistSaveQueue<string, string>(
(value) => {
writes.push(value);
if (writes.length === 1) return Promise.reject(new Error("offline"));
return retry.promise;
},
() => {},
);
await expect(queue.enqueue("latest")).rejects.toThrow("offline");
const retried = queue.retry();
await flushMicrotasks();
expect(writes).toEqual(["latest", "latest"]);
retry.resolve("saved");
await expect(retried).resolves.toBe("saved");
});
it("continues with a newer queued value after an older write fails", async () => {
const writes: string[] = [];
const latest = deferred<string>();
const queue = new TierlistSaveQueue<string, string>(
(value) => {
writes.push(value);
return writes.length === 1
? Promise.reject(new Error("offline"))
: latest.promise;
},
() => {},
);
const oldIntent = queue.enqueue("old");
const latestIntent = queue.enqueue("latest");
await flushMicrotasks();
await expect(oldIntent).rejects.toThrow("offline");
await flushMicrotasks();
expect(writes).toEqual(["old", "latest"]);
latest.resolve("saved latest");
await expect(latestIntent).resolves.toBe("saved latest");
});
});
+101
View File
@@ -0,0 +1,101 @@
export type SaveQueueResult<T, R> = {
generation: number;
value: T;
result: R;
};
type Waiter<R> = {
generation: number;
resolve: (result: R) => void;
reject: (error: unknown) => void;
};
/** Serializes writes while coalescing edits made during an in-flight write. */
export class TierlistSaveQueue<T, R> {
private generation = 0;
private desired: { generation: number; value: T } | undefined;
private writing = false;
private disposed = false;
private waiters: Waiter<R>[] = [];
constructor(
private readonly write: (value: T) => Promise<R>,
private readonly onSuccess: (saved: SaveQueueResult<T, R>) => void,
private readonly onPendingChange: (pending: boolean) => void = () => {},
private readonly onFailure: (error: unknown, value: T) => void = () => {},
) {}
get isPending() {
return this.writing;
}
enqueue(value: T): Promise<R> {
if (this.disposed)
return Promise.reject(new Error("Save queue is disposed"));
const generation = ++this.generation;
this.desired = { generation, value };
this.onPendingChange(true);
const promise = new Promise<R>((resolve, reject) => {
this.waiters.push({ generation, resolve, reject });
});
this.flush();
return promise;
}
retry(): Promise<R> {
if (!this.desired)
return Promise.reject(new Error("No failed save to retry"));
return this.enqueue(this.desired.value);
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.desired = undefined;
for (const waiter of this.waiters)
waiter.reject(new Error("Save queue disposed"));
this.waiters = [];
this.onPendingChange(false);
}
private flush() {
if (this.writing || !this.desired) return;
const attempted = this.desired;
this.writing = true;
this.onPendingChange(true);
void Promise.resolve()
.then(() => this.write(attempted.value))
.then(
(result) => {
if (this.disposed) return;
this.onSuccess({ ...attempted, result });
this.waiters = this.waiters.filter((waiter) => {
if (waiter.generation > attempted.generation) return true;
waiter.resolve(result);
return false;
});
if (this.desired?.generation === attempted.generation)
this.desired = undefined;
this.writing = false;
this.onPendingChange(false);
this.flush();
},
(error: unknown) => {
if (this.disposed) return;
this.onFailure(error, attempted.value);
this.waiters = this.waiters.filter((waiter) => {
if (waiter.generation > attempted.generation) return true;
waiter.reject(error);
return false;
});
this.writing = false;
this.onPendingChange(
!!this.desired && this.desired.generation > attempted.generation,
);
// Continue newer work; otherwise keep failed intent for explicit retry.
if (this.desired && this.desired.generation > attempted.generation)
this.flush();
},
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { tierlistStates } from "./db/schema";
export type TierlistState = typeof tierlistStates.$inferSelect;
export type TierlistSyncData = {
states: TierlistState[];
placements: Record<string, string[]>;
};
export type TierlistWriteResult<T> = {
persisted: true;
data: T;
publication: "sent" | "failed";
};
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, spyOn, test } from "bun:test";
import { PgDialect } from "drizzle-orm/pg-core";
import type { tierlistStates } from "./db/schema";
import {
finishTierlistWrite,
lockTierlistList,
normalizeTierlistPlacements,
resolveTierlistStateTarget,
validateTierlistStateInput,
} from "./tierlist-write";
type StateRow = typeof tierlistStates.$inferSelect;
const stateRow = (uuid: string, list: string, ref = "char-1") =>
({ uuid, list, ref }) as StateRow;
describe("tierlist write consistency helpers", () => {
test("validates list, ref, and optional UUID before writing", () => {
expect(
validateTierlistStateInput({
list: "v1",
ref: "char-1",
uuid: undefined,
}),
).toEqual({ list: "v1", ref: "char-1" });
expect(() =>
validateTierlistStateInput({ list: "", ref: "char-1" }),
).toThrow();
expect(() => validateTierlistStateInput({ list: "v1", ref: "" })).toThrow();
expect(() =>
validateTierlistStateInput({ list: "v1", ref: "char-1", uuid: "bad" }),
).toThrow();
});
test("rejects cross-list UUIDs and canonicalizes duplicate refs", () => {
const uuidMatch = stateRow("uuid-1", "v1");
const canonicalRefMatch = stateRow("uuid-0", "v1");
expect(() =>
resolveTierlistStateTarget(stateRow("uuid-2", "v2"), uuidMatch, "v1"),
).toThrow("belongs to another version");
expect(resolveTierlistStateTarget(uuidMatch, undefined, "v1")).toBe(
uuidMatch,
);
expect(resolveTierlistStateTarget(uuidMatch, canonicalRefMatch, "v1")).toBe(
canonicalRefMatch,
);
expect(resolveTierlistStateTarget(undefined, uuidMatch, "v1")).toBe(
uuidMatch,
);
});
test("uses one transaction-scoped advisory lock per list before mutations", async () => {
const order: string[] = [];
const queries: unknown[] = [];
await lockTierlistList(
{
execute: async (query) => {
order.push("lock");
queries.push(query);
},
},
"v1",
);
order.push("write");
const compiled = new PgDialect().sqlToQuery(queries[0] as never);
expect(compiled.sql).toContain("pg_advisory_xact_lock");
expect(compiled.sql).toContain("hashtextextended");
expect(compiled.params).toContain("v1");
expect(order).toEqual(["lock", "write"]);
});
test("normalizes placements without mutating the caller's untiered data", () => {
const placements = { S: ["a"], untiered: ["b"] };
expect(normalizeTierlistPlacements(placements)).toEqual({ S: ["a"] });
expect(placements).toEqual({ S: ["a"], untiered: ["b"] });
});
test("returns persisted success when publication fails after commit", async () => {
const errorLog = spyOn(console, "error").mockImplementation(() => {});
try {
const result = await finishTierlistWrite({
list: "v1",
operation: "state",
data: [{ uuid: "state-1" }],
audit: async () => {},
revalidate: () => {},
publish: async () => {
throw new Error("redis unavailable");
},
});
expect(result).toEqual({
persisted: true,
data: [{ uuid: "state-1" }],
publication: "failed",
});
expect(errorLog).toHaveBeenCalledTimes(1);
} finally {
errorLog.mockRestore();
}
});
test("reports sent when post-commit publication succeeds", async () => {
const result = await finishTierlistWrite({
list: "v1",
operation: "placements",
data: { S: ["char-1"] },
audit: async () => {},
revalidate: () => {},
publish: async () => 1,
});
expect(result).toEqual({
persisted: true,
data: { S: ["char-1"] },
publication: "sent",
});
});
test("does not turn a precommit transaction failure into persisted success", async () => {
const failure = new Error("transaction failed");
await expect(
lockTierlistList({ execute: async () => Promise.reject(failure) }, "v1"),
).rejects.toBe(failure);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { sql } from "drizzle-orm";
import type { tierlistStates } from "./db/schema";
import type { TierlistWriteResult } from "./tierlist-sync";
type StateInput = Partial<typeof tierlistStates.$inferInsert>;
type StateRow = typeof tierlistStates.$inferSelect;
export function validateTierlistStateInput(data: StateInput) {
const { list, ref, uuid } = data;
if (typeof list !== "string" || !list.trim())
throw new Error("A tierlist version is required.");
if (typeof ref !== "string" || !ref.trim())
throw new Error("A tierlist state reference is required.");
if (
uuid !== undefined &&
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
uuid,
)
)
throw new Error("A valid tierlist state UUID is required.");
return { list, ref };
}
export function resolveTierlistStateTarget(
byUuid: StateRow | undefined,
byRef: StateRow | undefined,
list: string,
) {
if (byUuid && byUuid.list !== list)
throw new Error("Tierlist state UUID belongs to another version.");
if (byUuid && byRef && byUuid.ref === byRef.ref) return byRef;
return byUuid ?? byRef;
}
type TransactionLike = {
execute(query: ReturnType<typeof sql>): Promise<unknown>;
};
export async function lockTierlistList(tx: TransactionLike, list: string) {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${list}, 0))`,
);
}
export function normalizeTierlistPlacements(
placements: Record<string, string[]>,
) {
const { untiered: _untiered, ...persistedPlacements } =
structuredClone(placements);
return persistedPlacements;
}
export async function finishTierlistWrite<T>({
list,
operation,
data,
audit,
publish,
revalidate,
}: {
list: string;
operation: "state" | "placements";
data: T;
audit: () => Promise<unknown>;
publish: () => Promise<unknown>;
revalidate: () => void;
}): Promise<TierlistWriteResult<T>> {
try {
revalidate();
} catch (error) {
logTierlistPostCommitFailure(list, operation, "revalidation", error);
}
try {
await audit();
} catch (error) {
logTierlistPostCommitFailure(list, operation, "audit", error);
}
let publication: TierlistWriteResult<T>["publication"] = "sent";
try {
await publish();
} catch (error) {
publication = "failed";
logTierlistPostCommitFailure(list, operation, "publication", error);
}
return { persisted: true, data, publication };
}
function logTierlistPostCommitFailure(
list: string,
operation: string,
step: string,
error: unknown,
) {
console.error(
`Tierlist ${operation} post-commit ${step} failed for ${list} (${error instanceof Error ? error.name : "unknown error"}).`,
);
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import { shouldSnapshot, type TierlistResolvedConfig } from "./tierlist";
function config({
deprecates = "01/01/2000",
chars = [{ id: "char-1" }, { id: "char-2" }],
placements = { S: ["char-1#2026-01", "char-2"] },
}: {
deprecates?: string;
chars?: { id: string }[];
placements?: Record<string, string[]>;
} = {}) {
return {
version: { deprecates, placements },
chars,
} as unknown as TierlistResolvedConfig;
}
describe("shouldSnapshot", () => {
test("does not snapshot a fully tiered version before its deprecation date", () => {
expect(shouldSnapshot(config({ deprecates: "31/12/2999" }))).toBe(false);
});
test("does not snapshot an old version with unfinished placements", () => {
expect(shouldSnapshot(config({ placements: { S: ["char-1"] } }))).toBe(
false,
);
});
test("snapshots an old version only when every character is tiered", () => {
expect(shouldSnapshot(config())).toBe(true);
});
test("does not consider an empty character list fully tiered", () => {
expect(shouldSnapshot(config({ chars: [] }))).toBe(false);
});
test("does not snapshot when the deprecation date is invalid", () => {
expect(
shouldSnapshot(
config({
deprecates: "not-a-date",
placements: { S: ["char-1", "char-2"] },
}),
),
).toBe(false);
});
test("does not snapshot when the deprecation date is missing", () => {
expect(
shouldSnapshot(
config({ deprecates: "", placements: { S: ["char-1", "char-2"] } }),
),
).toBe(false);
});
});
+2 -2
View File
@@ -102,7 +102,7 @@ async function buildLiveTierlistConfig(
});
}
function shouldSnapshot(config: TierlistResolvedConfig) {
export function shouldSnapshot(config: TierlistResolvedConfig) {
const deprecatedAt = parseDate(config.version.deprecates);
const olderThanMonth =
Number.isFinite(deprecatedAt) &&
@@ -117,7 +117,7 @@ function shouldSnapshot(config: TierlistResolvedConfig) {
config.chars.length > 0 &&
config.chars.every((char) => placed.has(char.id));
return olderThanMonth || allTiered;
return olderThanMonth && allTiered;
}
function parseDate(value: string) {