diff --git a/src/components/layout/Navbar.test.tsx b/src/components/layout/Navbar.test.tsx index 88437ab..2013e14 100644 --- a/src/components/layout/Navbar.test.tsx +++ b/src/components/layout/Navbar.test.tsx @@ -34,7 +34,7 @@ jest.mock("@/components/ui/Button", () => ({ const { useAuth } = require("@/context/AuthContext"); -function mockAuth(overrides: Partial<{ user: any; loading: boolean; logout: jest.fn }>) { +function mockAuth(overrides: Partial<{ user: any; loading: boolean; logout: jest.Mock }>) { useAuth.mockReturnValue({ user: null, loading: false, diff --git a/src/lib/adapters.test.ts b/src/lib/adapters.test.ts index 7864304..de103a8 100644 --- a/src/lib/adapters.test.ts +++ b/src/lib/adapters.test.ts @@ -312,3 +312,71 @@ describe("adaptReputation", () => { } }); }); + +describe("adaptReputation — completionRate and onTimeDeliveryRate clamping (#91)", () => { + const user = { username: "bob", avatarUrl: null }; + + function rawSnapshot( + overrides: Partial = {}, + ): import("./adapters").RawReputationSnapshot { + return { + totalEarnings: "1000", + mergedPrCount: 10, + completionRate: "94", + avgReviewTimeHours: "4", + onTimeDeliveryPercentage: "88", + languages: { TypeScript: 50, Rust: 50 }, + orgsContributedTo: ["MergeFi"], + ...overrides, + }; + } + + it("converts in-range percentages into exact fractions", () => { + const rep = adaptReputation(user, rawSnapshot({ completionRate: "94", onTimeDeliveryPercentage: "88" })); + expect(rep.completionRate).toBe(0.94); + expect(rep.onTimeDeliveryRate).toBe(0.88); + }); + + it("handles boundary values 0 and 100", () => { + const repZero = adaptReputation(user, rawSnapshot({ completionRate: "0", onTimeDeliveryPercentage: "0" })); + expect(repZero.completionRate).toBe(0); + expect(repZero.onTimeDeliveryRate).toBe(0); + + const repHundred = adaptReputation(user, rawSnapshot({ completionRate: "100", onTimeDeliveryPercentage: "100" })); + expect(repHundred.completionRate).toBe(1); + expect(repHundred.onTimeDeliveryRate).toBe(1); + }); + + it("clamps out-of-range values above 100% to exactly 1.0", () => { + const rep = adaptReputation(user, rawSnapshot({ completionRate: "150", onTimeDeliveryPercentage: "200" })); + expect(rep.completionRate).toBe(1); + expect(rep.onTimeDeliveryRate).toBe(1); + }); + + it("clamps negative values below 0% to exactly 0.0", () => { + const rep = adaptReputation(user, rawSnapshot({ completionRate: "-20", onTimeDeliveryPercentage: "-50" })); + expect(rep.completionRate).toBe(0); + expect(rep.onTimeDeliveryRate).toBe(0); + }); + + it("handles null, undefined, and non-numeric snapshot values gracefully", () => { + const rep = adaptReputation( + user, + rawSnapshot({ + completionRate: "corrupted_value", + onTimeDeliveryPercentage: "invalid", + }), + ); + expect(rep.completionRate).toBe(0); + expect(rep.onTimeDeliveryRate).toBe(0); + }); + + it("returns default zero rates when snapshot is null", () => { + const rep = adaptReputation(user, null); + expect(rep.completionRate).toBe(0); + expect(rep.onTimeDeliveryRate).toBe(0); + expect(rep.handle).toBe("bob"); + expect(rep.lifetimeEarnings).toBe(0); + expect(rep.mergedPRs).toBe(0); + }); +}); diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 5f8b2b1..273f0d2 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -8,6 +8,7 @@ import type { } from "@/types"; import { coerceDecimal, + coerceFraction, coerceNonNegative, coercePercentage, coerceStatus, @@ -196,9 +197,15 @@ export function adaptReputation( `https://api.dicebear.com/9.x/identicon/svg?seed=${encodeURIComponent(user.username)}`, lifetimeEarnings: snapshot ? coerceNonNegative(snapshot.totalEarnings) : 0, mergedPRs: snapshot?.mergedPrCount ?? 0, - completionRate: snapshot ? coerceDecimal(snapshot.completionRate) / 100 : 0, - avgReviewTimeHours: snapshot ? coerceNonNegative(snapshot.avgReviewTimeHours) : 0, - onTimeDeliveryRate: snapshot ? coerceDecimal(snapshot.onTimeDeliveryPercentage) / 100 : 0, + completionRate: snapshot + ? coerceFraction(snapshot.completionRate, 0, 100) + : 0, + avgReviewTimeHours: snapshot + ? coerceNonNegative(snapshot.avgReviewTimeHours) + : 0, + onTimeDeliveryRate: snapshot + ? coerceFraction(snapshot.onTimeDeliveryPercentage, 0, 100) + : 0, // Object.keys() alone discards the usage weight and returns keys in // insertion order, not "most used first" — sort by value descending so // the rendered badge order actually means something (#196). diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 25ffde7..0c480e8 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -186,12 +186,32 @@ describe("coerceFraction", () => { it("clamps values below 0 to 0", () => { expect(coerceFraction("-0.5")).toBe(0); + expect(coerceFraction("-100")).toBe(0); }); it("clamps values above 1 to 1", () => { expect(coerceFraction("1.5")).toBe(1); expect(coerceFraction("100")).toBe(1); }); + + it("handles custom divisor for percentage strings (e.g. divisor = 100)", () => { + expect(coerceFraction("94", 0, 100)).toBe(0.94); + expect(coerceFraction("100", 0, 100)).toBe(1); + expect(coerceFraction("0", 0, 100)).toBe(0); + expect(coerceFraction("50", 0, 100)).toBe(0.5); + expect(coerceFraction("150", 0, 100)).toBe(1); + expect(coerceFraction("-20", 0, 100)).toBe(0); + }); + + it("handles null, undefined, and non-numeric inputs using clamped fallback", () => { + expect(coerceFraction(null)).toBe(0); + expect(coerceFraction(undefined)).toBe(0); + expect(coerceFraction("not-a-number")).toBe(0); + expect(coerceFraction(null, 0.5)).toBe(0.5); + expect(coerceFraction("invalid", 0.8)).toBe(0.8); + expect(coerceFraction(null, 1.5)).toBe(1); + expect(coerceFraction(null, -0.5)).toBe(0); + }); }); // ─── coercePercentage ──────────────────────────────────────────────────────── diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6c1a24d..285452d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -18,8 +18,24 @@ export function coerceNonNegative(value: string | null | undefined, fallback = 0 return n < 0 ? fallback : n; } -export function coerceFraction(value: string | null | undefined, fallback = 0): number { - const n = coerceDecimal(value, fallback); +/** + * Coerces an input string to a clamped fraction in the range [0, 1]. + * + * @param value - The input string representing a fraction or percentage value. + * @param fallback - The fallback value if input is null, undefined, or non-numeric (default 0). + * @param divisor - Optional divisor to scale inputs (e.g. 100 for percentage strings like "94" -> 0.94). Defaults to 1. + * @returns A number strictly clamped to [0, 1]. + */ +export function coerceFraction( + value: string | null | undefined, + fallback = 0, + divisor = 1, +): number { + if (value == null) { + return Math.min(1, Math.max(0, fallback)); + } + const raw = coerceDecimal(value, fallback); + const n = divisor !== 0 ? raw / divisor : raw; if (n < 0) return 0; if (n > 1) return 1; return n;