🧪 test: Add unit tests

This commit is contained in:
2026-07-01 12:20:56 +07:00
parent 25cf1df334
commit f5a7bdc778
8 changed files with 723 additions and 3 deletions
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TtlCache } from "../src/main/cache/cache";
const policy = { ttl: 1000, staleWhileRevalidate: 5000 };
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("TtlCache.get — fresh vs miss", () => {
it("calls the loader once and serves the cached value while fresh", async () => {
const cache = new TtlCache();
const loader = vi.fn().mockResolvedValue("v1");
expect(await cache.get("k", policy, loader)).toBe("v1");
expect(await cache.get("k", policy, loader)).toBe("v1");
expect(loader).toHaveBeenCalledTimes(1);
expect(cache.stats().hits).toBe(1);
expect(cache.stats().misses).toBe(1);
});
});
describe("TtlCache.get — single-flight de-dup", () => {
it("shares one in-flight loader across concurrent gets of the same key", async () => {
const cache = new TtlCache();
let resolve!: (v: string) => void;
const loader = vi.fn(() => new Promise<string>((r) => (resolve = r)));
const a = cache.get("k", policy, loader);
const b = cache.get("k", policy, loader);
resolve("shared");
expect(await a).toBe("shared");
expect(await b).toBe("shared");
expect(loader).toHaveBeenCalledTimes(1);
});
});
describe("TtlCache.get — stale-while-revalidate", () => {
it("returns the stale value and revalidates in the background past ttl", async () => {
const cache = new TtlCache();
const loader = vi.fn().mockResolvedValueOnce("v1").mockResolvedValueOnce("v2");
expect(await cache.get("k", policy, loader)).toBe("v1");
vi.setSystemTime(Date.now() + 2000);
expect(await cache.get("k", policy, loader)).toBe("v1");
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2));
expect(await cache.get("k", policy, loader)).toBe("v2");
});
it("reloads synchronously once past the hard expiry", async () => {
const cache = new TtlCache();
const loader = vi.fn().mockResolvedValueOnce("v1").mockResolvedValueOnce("v2");
expect(await cache.get("k", policy, loader)).toBe("v1");
vi.setSystemTime(Date.now() + 7000);
expect(await cache.get("k", policy, loader)).toBe("v2");
});
});
describe("TtlCache.get — rate-limit backoff", () => {
it("after a 429 loader failure, serves stale and suppresses revalidation", async () => {
const cache = new TtlCache();
const loader = vi
.fn()
.mockResolvedValueOnce("v1")
.mockRejectedValueOnce({ status: 429, response: { headers: { "retry-after": "8" } } });
expect(await cache.get("k", policy, loader)).toBe("v1");
vi.setSystemTime(Date.now() + 2000);
await cache.get("k", policy, loader);
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2));
await cache.get("k", policy, loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it("throws 429 on a cold miss while rate limited", async () => {
const cache = new TtlCache();
const failing = vi.fn().mockRejectedValue({ status: 429 });
await expect(cache.get("a", policy, failing)).rejects.toMatchObject({ status: 429 });
await expect(cache.get("b", policy, vi.fn())).rejects.toMatchObject({ status: 429 });
});
});
describe("TtlCache.set / patch / invalidate / clear", () => {
it("patch merges into an existing entry", async () => {
const cache = new TtlCache();
cache.set("k", { a: 1, b: 2 }, policy);
cache.patch("k", { b: 3 });
const val = await cache.get("k", policy, async () => ({ a: 0, b: 0 }));
expect(val).toEqual({ a: 1, b: 3 });
});
it("patch is a no-op on a missing key", () => {
const cache = new TtlCache();
cache.patch("missing", { x: 1 });
expect(cache.stats().entries).toBe(0);
});
it("invalidate forces the next get to reload", async () => {
const cache = new TtlCache();
const loader = vi.fn().mockResolvedValueOnce("v1").mockResolvedValueOnce("v2");
await cache.get("k", policy, loader);
cache.invalidate("k");
expect(await cache.get("k", policy, loader)).toBe("v2");
});
it("clear empties the store", async () => {
const cache = new TtlCache();
cache.set("k", "v", policy);
cache.clear();
expect(cache.stats().entries).toBe(0);
});
});
describe("TtlCache — LRU eviction", () => {
it("evicts least-recently-accessed entries past maxEntries", async () => {
const cache = new TtlCache(2);
cache.set("a", 1, policy);
vi.setSystemTime(Date.now() + 10);
cache.set("b", 2, policy);
vi.setSystemTime(Date.now() + 10);
await cache.get("a", policy, async () => 1);
vi.setSystemTime(Date.now() + 10);
cache.set("c", 3, policy);
expect(cache.stats().entries).toBe(2);
const loader = vi.fn().mockResolvedValue(99);
await cache.get("b", policy, loader);
expect(loader).toHaveBeenCalledTimes(1);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { httpStatusOf, isTransientError, toApiError } from "../src/main/vrchat/errors";
describe("httpStatusOf", () => {
it("reads status from the many shapes vrchat/SDK errors take", () => {
expect(httpStatusOf({ status: 404 })).toBe(404);
expect(httpStatusOf({ statusCode: 401 })).toBe(401);
expect(httpStatusOf({ status_code: 500 })).toBe(500);
expect(httpStatusOf({ response: { status: 429 } })).toBe(429);
expect(httpStatusOf({ error: { status_code: 403 } })).toBe(403);
});
it("returns undefined for a shapeless error", () => {
expect(httpStatusOf(null)).toBeUndefined();
expect(httpStatusOf({})).toBeUndefined();
expect(httpStatusOf("boom")).toBeUndefined();
});
});
describe("isTransientError", () => {
it("treats 429 and 5xx as transient", () => {
expect(isTransientError({ status: 429 })).toBe(true);
expect(isTransientError({ status: 500 })).toBe(true);
expect(isTransientError({ status: 503 })).toBe(true);
});
it("treats 4xx (other than 429) as permanent", () => {
expect(isTransientError({ status: 404 })).toBe(false);
expect(isTransientError({ status: 401 })).toBe(false);
});
it("treats a status-less error (network failure) as transient", () => {
expect(isTransientError(new Error("ECONNRESET"))).toBe(true);
});
});
describe("toApiError", () => {
it("maps well-known statuses to codes", () => {
expect(toApiError({ status: 401 }).code).toBe("unauthorized");
expect(toApiError({ status: 404 }).code).toBe("not_found");
expect(toApiError({ status: 429 }).code).toBe("rate_limited");
});
it("classifies status-less network-ish messages as network", () => {
expect(toApiError({ message: "fetch failed: ENOTFOUND" }).code).toBe("network");
});
it("passes through a pre-coded error and its 2FA methods", () => {
const e = toApiError({ code: "two_factor_required", methods: ["totp"] });
expect(e.code).toBe("two_factor_required");
expect(e.methods).toEqual(["totp"]);
});
it("uses a friendly rate-limit message and parses retry-after", () => {
const e = toApiError({ status: 429, response: { headers: { "retry-after": "12" } } });
expect(e.code).toBe("rate_limited");
expect(e.retryAfter).toBe(12);
expect(e.message).toMatch(/rate limit/i);
});
it("gives a temporary-unavailable message for 5xx", () => {
expect(toApiError({ status: 502 }).message).toMatch(/temporarily unavailable/i);
});
it("falls back to unknown with the original message", () => {
const e = toApiError({ message: "weird" });
expect(e.code).toBe("unknown");
expect(e.message).toBe("weird");
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect } from "vitest";
import {
trustRankFromTags,
toIso,
toUserProfile,
toWorld,
toAvatar,
} from "../src/main/vrchat/mappers";
describe("trustRankFromTags", () => {
it("maps each trust tag to the rank one level below its label", () => {
expect(trustRankFromTags([])).toBe("visitor");
expect(trustRankFromTags(["system_trust_basic"])).toBe("new");
expect(trustRankFromTags(["system_trust_known"])).toBe("user");
expect(trustRankFromTags(["system_trust_trusted"])).toBe("known");
expect(trustRankFromTags(["system_trust_veteran"])).toBe("trusted");
expect(trustRankFromTags(["system_trust_legend"])).toBe("veteran");
});
it("defaults to visitor when no trust tag present", () => {
expect(trustRankFromTags(["language_eng", "something_else"])).toBe("visitor");
expect(trustRankFromTags()).toBe("visitor");
});
it("picks the highest rank when several trust tags are present", () => {
expect(trustRankFromTags(["system_trust_basic", "system_trust_legend"])).toBe("veteran");
});
it("troll tags override everything", () => {
expect(trustRankFromTags(["system_trust_legend", "system_troll"])).toBe("troll");
expect(trustRankFromTags(["system_probable_troll"])).toBe("troll");
});
});
describe("toIso", () => {
it("returns undefined for empty/null values", () => {
expect(toIso(undefined)).toBeUndefined();
expect(toIso(null)).toBeUndefined();
expect(toIso("")).toBeUndefined();
});
it("passes Date objects through to ISO", () => {
const d = new Date("2024-01-02T03:04:05.000Z");
expect(toIso(d)).toBe("2024-01-02T03:04:05.000Z");
});
it("normalizes parseable date strings to ISO", () => {
expect(toIso("2024-01-02T03:04:05Z")).toBe("2024-01-02T03:04:05.000Z");
});
it("returns undefined for unparseable strings", () => {
expect(toIso("not a date")).toBeUndefined();
});
});
describe("toUserProfile", () => {
const base = { id: "usr_1", displayName: "Alice" };
it("fills defaults for missing fields", () => {
const p = toUserProfile({ ...base }, "self");
expect(p.bio).toBe("");
expect(p.tags).toEqual([]);
expect(p.badges).toEqual([]);
expect(p.isFriend).toBe(false);
expect(p.status).toBe("offline");
expect(p.trustRank).toBe("visitor");
});
it("sets isSelf when the id matches selfId", () => {
expect(toUserProfile({ ...base, id: "self" }, "self").isSelf).toBe(true);
expect(toUserProfile({ ...base, id: "other" }, "self").isSelf).toBe(false);
});
it("accepts both camelCase and snake_case timestamp aliases", () => {
const camel = toUserProfile({ ...base, lastLogin: "2024-01-01T00:00:00Z" }, "self");
const snake = toUserProfile({ ...base, last_login: "2024-01-01T00:00:00Z" }, "self");
expect(camel.lastLogin).toBe("2024-01-01T00:00:00.000Z");
expect(snake.lastLogin).toBe("2024-01-01T00:00:00.000Z");
});
it("extracts languages from language_ tags", () => {
const p = toUserProfile(
{ ...base, tags: ["language_eng", "language_jpn", "system_trust_known"] },
"self",
);
expect(p.languages).toEqual(["eng", "jpn"]);
});
it("normalizes an unknown status to offline", () => {
expect(toUserProfile({ ...base, status: "bogus" }, "self").status).toBe("offline");
expect(toUserProfile({ ...base, status: "join me" }, "self").status).toBe("join me");
});
it("blanks a falsy note to undefined", () => {
expect(toUserProfile({ ...base, note: "" }, "self").note).toBeUndefined();
expect(toUserProfile({ ...base, note: "hi" }, "self").note).toBe("hi");
});
});
describe("toWorld", () => {
const base = { id: "wrld_1", authorId: "usr_1" };
it("blanks vrchat's '???' placeholder for hidden name/author", () => {
const w = toWorld({ ...base, name: "???", authorName: "???" } as never);
expect(w.name).toBe("");
expect(w.authorName).toBe("");
});
it("marks a world detailed only when it carries visit counts", () => {
expect(toWorld({ ...base, name: "W", visits: 10 } as never).detailed).toBe(true);
expect(toWorld({ ...base, name: "W" } as never).detailed).toBe(false);
});
it("derives platforms from unity packages", () => {
const w = toWorld({
...base,
name: "W",
unityPackages: [{ platform: "standalonewindows" }, { platform: "android" }],
} as never);
expect(w.platforms).toEqual({ pc: true, android: true });
});
it("defaults releaseStatus to private", () => {
expect(toWorld({ ...base, name: "W" } as never).releaseStatus).toBe("private");
});
});
describe("toAvatar", () => {
const base = { id: "avtr_1", name: "Av" };
it("treats a 'None' performance rating as no build for that platform", () => {
const a = toAvatar({
...base,
performance: { standalonewindows: "Excellent", android: "None" },
});
expect(a.platforms).toEqual({ pc: true, android: false });
expect(a.performance.pc).toBe("Excellent");
expect(a.performance.android).toBeUndefined();
});
it("defaults releaseStatus and empty collections", () => {
const a = toAvatar({ ...base });
expect(a.releaseStatus).toBe("private");
expect(a.tags).toEqual([]);
expect(a.favorites).toBe(0);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import {
isOnline,
locationLabel,
presenceOf,
avatarOf,
bannerOf,
regionFlag,
languageLabel,
} from "../src/renderer/src/lib/vrchat";
describe("isOnline", () => {
it("counts self as always online", () => {
expect(isOnline({ isSelf: true, state: "offline" })).toBe(true);
});
it("counts online/active state as online", () => {
expect(isOnline({ state: "online" })).toBe(true);
expect(isOnline({ state: "active" })).toBe(true);
});
it("counts a real location as online even without state", () => {
expect(isOnline({ location: "wrld_1:12345" })).toBe(true);
});
it("treats the 'offline' location sentinel and empty location as offline", () => {
expect(isOnline({ state: "offline", location: "offline" })).toBe(false);
expect(isOnline({ state: "offline" })).toBe(false);
});
});
describe("locationLabel", () => {
it("returns undefined for null/empty/offline sentinels", () => {
expect(locationLabel(undefined)).toBeUndefined();
expect(locationLabel("")).toBeUndefined();
expect(locationLabel("offline")).toBeUndefined();
});
it("labels the private and traveling sentinels", () => {
expect(locationLabel("private")).toBe("In a private world");
expect(locationLabel("traveling")).toBe("Traveling…");
});
it("labels a real location generically", () => {
expect(locationLabel("wrld_1:12345~region(us)")).toBe("In a world");
});
});
describe("presenceOf", () => {
it("shows the real status color when online", () => {
const p = presenceOf({ status: "join me", state: "online" });
expect(p.online).toBe(true);
expect(p.effective.label).toBe("Join Me");
});
it("falls back to the offline swatch when offline, keeping the raw status", () => {
const p = presenceOf({ status: "join me", state: "offline" });
expect(p.online).toBe(false);
expect(p.effective.label).toBe("Offline");
expect(p.status.label).toBe("Join Me");
});
});
describe("avatar/banner fallback chains", () => {
it("prefers userIcon, then thumbnail, then full image", () => {
expect(
avatarOf({
userIcon: "icon",
currentAvatarThumbnailImageUrl: "thumb",
currentAvatarImageUrl: "full",
}),
).toBe("icon");
expect(
avatarOf({
userIcon: "",
currentAvatarThumbnailImageUrl: "thumb",
currentAvatarImageUrl: "full",
}),
).toBe("thumb");
expect(
avatarOf({ userIcon: "", currentAvatarThumbnailImageUrl: "", currentAvatarImageUrl: "full" }),
).toBe("full");
});
it("banner prefers the profile override, then full image", () => {
expect(
bannerOf({
profilePicOverride: "ovr",
currentAvatarImageUrl: "full",
currentAvatarThumbnailImageUrl: "thumb",
}),
).toBe("ovr");
expect(
bannerOf({
profilePicOverride: "",
currentAvatarImageUrl: "full",
currentAvatarThumbnailImageUrl: "thumb",
}),
).toBe("full");
});
});
describe("regionFlag / languageLabel", () => {
it("maps known regions case-insensitively and unknown to undefined", () => {
expect(regionFlag("US")).toBe("🇺🇸");
expect(regionFlag("jp")).toBe("🇯🇵");
expect(regionFlag("xx")).toBeUndefined();
expect(regionFlag(undefined)).toBeUndefined();
});
it("maps known language codes and falls back to uppercased code", () => {
expect(languageLabel("eng")).toBe("English");
expect(languageLabel("zzz")).toBe("ZZZ");
});
});