add unit tests for core game, bible, share, and stats utilities

146 tests covering evaluateGuess, grading, ordinals, bible data
integrity, section counts, share text generation, and stat
string helpers. Also fixes toOrdinal for 111-113 (was using
>= 11 && <= 13 instead of % 100 check).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-02-26 15:02:46 -05:00
parent 03429b17cc
commit e550965086
5 changed files with 974 additions and 1 deletions

View File

@@ -79,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
} }
export function toOrdinal(n: number): string { export function toOrdinal(n: number): string {
if (n >= 11 && n <= 13) { if (n % 100 >= 11 && n % 100 <= 13) {
return `${n}th`; return `${n}th`;
} }
const mod = n % 10; const mod = n % 10;

234
tests/bible.test.ts Normal file
View File

@@ -0,0 +1,234 @@
import { describe, test, expect } from "bun:test";
import {
getBookById,
getBookByNumber,
getBooksByTestament,
getBooksBySection,
isAdjacent,
bookNumberToId,
bookIdToNumber,
bibleBooks,
} from "$lib/server/bible";
describe("bibleBooks data integrity", () => {
test("contains exactly 66 books", () => {
expect(bibleBooks).toHaveLength(66);
});
test("order numbers are 1 through 66 with no gaps or duplicates", () => {
const orders = bibleBooks.map((b) => b.order).sort((a, b) => a - b);
for (let i = 0; i < 66; i++) {
expect(orders[i]).toBe(i + 1);
}
});
test("book IDs are unique", () => {
const ids = bibleBooks.map((b) => b.id);
const unique = new Set(ids);
expect(unique.size).toBe(66);
});
test("every book has a non-empty name", () => {
for (const book of bibleBooks) {
expect(book.name.length).toBeGreaterThan(0);
}
});
test("Old Testament has 39 books", () => {
expect(bibleBooks.filter((b) => b.testament === "old")).toHaveLength(39);
});
test("New Testament has 27 books", () => {
expect(bibleBooks.filter((b) => b.testament === "new")).toHaveLength(27);
});
test("Genesis is first and Revelation is last", () => {
const sorted = [...bibleBooks].sort((a, b) => a.order - b.order);
expect(sorted[0].id).toBe("GEN");
expect(sorted[65].id).toBe("REV");
});
test("Matthew is the first New Testament book", () => {
const nt = bibleBooks
.filter((b) => b.testament === "new")
.sort((a, b) => a.order - b.order);
expect(nt[0].id).toBe("MAT");
});
});
describe("section counts", () => {
test("Law: 5 books", () => {
expect(getBooksBySection("Law")).toHaveLength(5);
});
test("History: 13 books (12 OT + Acts)", () => {
expect(getBooksBySection("History")).toHaveLength(13);
});
test("Wisdom: 5 books", () => {
expect(getBooksBySection("Wisdom")).toHaveLength(5);
});
test("Major Prophets: 5 books", () => {
expect(getBooksBySection("Major Prophets")).toHaveLength(5);
});
test("Minor Prophets: 12 books", () => {
expect(getBooksBySection("Minor Prophets")).toHaveLength(12);
});
test("Gospels: 4 books", () => {
expect(getBooksBySection("Gospels")).toHaveLength(4);
});
test("Pauline Epistles: 13 books", () => {
expect(getBooksBySection("Pauline Epistles")).toHaveLength(13);
});
test("General Epistles: 8 books", () => {
expect(getBooksBySection("General Epistles")).toHaveLength(8);
});
test("Apocalyptic: 1 book (Revelation)", () => {
const books = getBooksBySection("Apocalyptic");
expect(books).toHaveLength(1);
expect(books[0].id).toBe("REV");
});
test("all sections sum to 66", () => {
const total =
getBooksBySection("Law").length +
getBooksBySection("History").length +
getBooksBySection("Wisdom").length +
getBooksBySection("Major Prophets").length +
getBooksBySection("Minor Prophets").length +
getBooksBySection("Gospels").length +
getBooksBySection("Pauline Epistles").length +
getBooksBySection("General Epistles").length +
getBooksBySection("Apocalyptic").length;
expect(total).toBe(66);
});
});
describe("getBookById", () => {
test("returns Genesis for GEN", () => {
const book = getBookById("GEN");
expect(book).toBeDefined();
expect(book!.name).toBe("Genesis");
});
test("returns Revelation for REV", () => {
const book = getBookById("REV");
expect(book!.name).toBe("Revelation");
});
test("returns undefined for unknown ID", () => {
expect(getBookById("UNKNOWN")).toBeUndefined();
});
});
describe("getBookByNumber", () => {
test("1 → Genesis", () => {
expect(getBookByNumber(1)!.id).toBe("GEN");
});
test("66 → Revelation", () => {
expect(getBookByNumber(66)!.id).toBe("REV");
});
test("40 → Matthew (first NT book)", () => {
expect(getBookByNumber(40)!.id).toBe("MAT");
});
test("0 → undefined", () => {
expect(getBookByNumber(0)).toBeUndefined();
});
test("67 → undefined", () => {
expect(getBookByNumber(67)).toBeUndefined();
});
});
describe("getBooksByTestament", () => {
test("old returns 39 books", () => {
expect(getBooksByTestament("old")).toHaveLength(39);
});
test("new returns 27 books", () => {
expect(getBooksByTestament("new")).toHaveLength(27);
});
test("all OT books have testament = old", () => {
for (const book of getBooksByTestament("old")) {
expect(book.testament).toBe("old");
}
});
test("all NT books have testament = new", () => {
for (const book of getBooksByTestament("new")) {
expect(book.testament).toBe("new");
}
});
});
describe("isAdjacent", () => {
test("Genesis and Exodus are adjacent", () => {
expect(isAdjacent("GEN", "EXO")).toBe(true);
});
test("adjacency is symmetric", () => {
expect(isAdjacent("EXO", "GEN")).toBe(true);
});
test("Malachi and Matthew are adjacent across testament boundary", () => {
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
});
test("Jude and Revelation are adjacent", () => {
expect(isAdjacent("JUD", "REV")).toBe(true); // 65, 66
});
test("same book is not adjacent to itself", () => {
expect(isAdjacent("GEN", "GEN")).toBe(false);
});
test("books two apart are not adjacent", () => {
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
});
test("returns false for invalid IDs", () => {
expect(isAdjacent("FAKE", "GEN")).toBe(false);
});
});
describe("bookNumberToId / bookIdToNumber lookup tables", () => {
test("bookNumberToId[1] is GEN", () => {
expect(bookNumberToId[1]).toBe("GEN");
});
test("bookNumberToId[66] is REV", () => {
expect(bookNumberToId[66]).toBe("REV");
});
test("bookIdToNumber['GEN'] is 1", () => {
expect(bookIdToNumber["GEN"]).toBe(1);
});
test("bookIdToNumber['REV'] is 66", () => {
expect(bookIdToNumber["REV"]).toBe(66);
});
test("round-trip: number → ID → number", () => {
for (let i = 1; i <= 66; i++) {
const id = bookNumberToId[i];
expect(bookIdToNumber[id]).toBe(i);
}
});
test("round-trip: ID → number → ID", () => {
for (const book of bibleBooks) {
const num = bookIdToNumber[book.id];
expect(bookNumberToId[num]).toBe(book.id);
}
});
});

271
tests/game.test.ts Normal file
View File

@@ -0,0 +1,271 @@
import { describe, test, expect } from "bun:test";
import {
evaluateGuess,
getBookById,
getFirstLetter,
getGrade,
getNextGradeMessage,
isAdjacent,
toOrdinal,
} from "$lib/utils/game";
describe("getBookById", () => {
test("returns correct book for a valid ID", () => {
const book = getBookById("GEN");
expect(book).toBeDefined();
expect(book!.name).toBe("Genesis");
expect(book!.order).toBe(1);
});
test("returns the last book by ID", () => {
const book = getBookById("REV");
expect(book).toBeDefined();
expect(book!.name).toBe("Revelation");
expect(book!.order).toBe(66);
});
test("returns undefined for an invalid ID", () => {
expect(getBookById("INVALID")).toBeUndefined();
});
test("returns undefined for an empty string", () => {
expect(getBookById("")).toBeUndefined();
});
test("is case-sensitive", () => {
expect(getBookById("gen")).toBeUndefined();
});
});
describe("isAdjacent", () => {
test("consecutive books are adjacent", () => {
expect(isAdjacent("GEN", "EXO")).toBe(true); // 1, 2
});
test("adjacency is symmetric", () => {
expect(isAdjacent("EXO", "GEN")).toBe(true);
});
test("books two apart are not adjacent", () => {
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
});
test("the same book is not adjacent to itself", () => {
expect(isAdjacent("GEN", "GEN")).toBe(false); // diff = 0
});
test("works across testament boundary (Malachi / Matthew)", () => {
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
});
test("far-apart books are not adjacent", () => {
expect(isAdjacent("GEN", "REV")).toBe(false);
});
test("returns false for unknown IDs", () => {
expect(isAdjacent("FAKE", "GEN")).toBe(false);
expect(isAdjacent("GEN", "FAKE")).toBe(false);
});
});
describe("getFirstLetter", () => {
test("returns first letter of a normal book name", () => {
expect(getFirstLetter("Genesis")).toBe("G");
expect(getFirstLetter("Revelation")).toBe("R");
});
test("skips leading digits and returns first letter", () => {
expect(getFirstLetter("1 Samuel")).toBe("S");
expect(getFirstLetter("2 Kings")).toBe("K");
expect(getFirstLetter("1 Corinthians")).toBe("C");
expect(getFirstLetter("3 John")).toBe("J");
});
test("returns first letter of multi-word names", () => {
expect(getFirstLetter("Song of Solomon")).toBe("S");
});
});
describe("evaluateGuess", () => {
test("returns null for an invalid guess ID", () => {
expect(evaluateGuess("INVALID", "GEN")).toBeNull();
});
test("returns null for an invalid correct ID", () => {
expect(evaluateGuess("GEN", "INVALID")).toBeNull();
});
test("exact book match: testamentMatch and sectionMatch are true, adjacent is false", () => {
const result = evaluateGuess("GEN", "GEN");
expect(result).not.toBeNull();
expect(result!.book.id).toBe("GEN");
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(true);
expect(result!.adjacent).toBe(false);
});
test("same section implies same testament", () => {
// Genesis and Exodus are both OT Law
const result = evaluateGuess("GEN", "EXO");
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(true);
expect(result!.adjacent).toBe(true);
});
test("same testament, different section", () => {
// Genesis (Law) vs Joshua (History) — both OT
const result = evaluateGuess("GEN", "JOS");
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(false);
expect(result!.adjacent).toBe(false);
});
test("different testament, no match", () => {
// Genesis (OT) vs Matthew (NT)
const result = evaluateGuess("GEN", "MAT");
expect(result!.testamentMatch).toBe(false);
expect(result!.sectionMatch).toBe(false);
expect(result!.adjacent).toBe(false);
});
test("adjacent books across testament boundary", () => {
// Malachi (OT, 39) and Matthew (NT, 40)
const result = evaluateGuess("MAL", "MAT");
expect(result!.adjacent).toBe(true);
expect(result!.testamentMatch).toBe(false);
expect(result!.sectionMatch).toBe(false);
});
test("adjacent books within same testament and section", () => {
// Hosea (28) and Joel (29), both Minor Prophets
const result = evaluateGuess("HOS", "JOL");
expect(result!.adjacent).toBe(true);
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(true);
});
test("firstLetterMatch: same first letter", () => {
// Genesis and Galatians both start with G
const result = evaluateGuess("GEN", "GAL");
expect(result!.firstLetterMatch).toBe(true);
});
test("firstLetterMatch: different first letter", () => {
// Genesis (G) vs Matthew (M)
const result = evaluateGuess("GEN", "MAT");
expect(result!.firstLetterMatch).toBe(false);
});
test("firstLetterMatch is case-insensitive", () => {
// Both start with J but from different contexts — Jeremiah vs Joel
const result = evaluateGuess("JER", "JOL");
expect(result!.firstLetterMatch).toBe(true);
});
test("special case: two Epistle '1' books always firstLetterMatch", () => {
// 1 Corinthians (Pauline) vs 1 John (General) — both Epistles starting with "1"
const result = evaluateGuess("1CO", "1JN");
expect(result!.firstLetterMatch).toBe(true);
});
test("special case: Epistle '1' book vs non-Epistle '1' book — no special treatment", () => {
// 1 Corinthians (Pauline Epistles) vs 1 Samuel (History)
// Correct is NOT Epistles, so special case doesn't apply
const result = evaluateGuess("1CO", "1SA");
// getFirstLetter("1 Corinthians") = "C", getFirstLetter("1 Samuel") = "S" → false
expect(result!.firstLetterMatch).toBe(false);
});
test("special case only triggers when BOTH are Epistle '1' books", () => {
// 2 Corinthians (Pauline, starts with "2") vs 1 John (General, starts with "1")
// guessIsEpistlesWithNumber requires name[0] === "1", so 2CO fails
const result = evaluateGuess("2CO", "1JN");
// getFirstLetter("2 Corinthians") = "C", getFirstLetter("1 John") = "J" → false
expect(result!.firstLetterMatch).toBe(false);
});
});
describe("getGrade", () => {
test("1 guess → S+", () => expect(getGrade(1)).toBe("S+"));
test("2 guesses → A+", () => expect(getGrade(2)).toBe("A+"));
test("3 guesses → A", () => expect(getGrade(3)).toBe("A"));
test("4 guesses → B+", () => expect(getGrade(4)).toBe("B+"));
test("6 guesses → B+", () => expect(getGrade(6)).toBe("B+"));
test("7 guesses → B", () => expect(getGrade(7)).toBe("B"));
test("10 guesses → B", () => expect(getGrade(10)).toBe("B"));
test("11 guesses → C+", () => expect(getGrade(11)).toBe("C+"));
test("15 guesses → C+", () => expect(getGrade(15)).toBe("C+"));
test("16 guesses → C", () => expect(getGrade(16)).toBe("C"));
test("100 guesses → C", () => expect(getGrade(100)).toBe("C"));
});
describe("getNextGradeMessage", () => {
test("returns empty string at top grade", () => {
expect(getNextGradeMessage(1)).toBe("");
});
test("grade A+ shows 1 guess threshold", () => {
expect(getNextGradeMessage(2)).toBe("Next grade: 1 guess or less");
});
test("grade A shows 2 guess threshold", () => {
expect(getNextGradeMessage(3)).toBe("Next grade: 2 guesses or less");
});
test("grade B+ shows 3 guess threshold", () => {
expect(getNextGradeMessage(4)).toBe("Next grade: 3 guesses or less");
expect(getNextGradeMessage(6)).toBe("Next grade: 3 guesses or less");
});
test("grade B shows 6 guess threshold", () => {
expect(getNextGradeMessage(7)).toBe("Next grade: 6 guesses or less");
expect(getNextGradeMessage(10)).toBe("Next grade: 6 guesses or less");
});
test("grade C+ shows 10 guess threshold", () => {
expect(getNextGradeMessage(11)).toBe("Next grade: 10 guesses or less");
expect(getNextGradeMessage(15)).toBe("Next grade: 10 guesses or less");
});
test("grade C shows 15 guess threshold", () => {
expect(getNextGradeMessage(16)).toBe("Next grade: 15 guesses or less");
expect(getNextGradeMessage(50)).toBe("Next grade: 15 guesses or less");
});
});
describe("toOrdinal", () => {
test("1st, 2nd, 3rd", () => {
expect(toOrdinal(1)).toBe("1st");
expect(toOrdinal(2)).toBe("2nd");
expect(toOrdinal(3)).toBe("3rd");
});
test("4-10 use th", () => {
expect(toOrdinal(4)).toBe("4th");
expect(toOrdinal(10)).toBe("10th");
});
test("11, 12, 13 use th (not st/nd/rd)", () => {
expect(toOrdinal(11)).toBe("11th");
expect(toOrdinal(12)).toBe("12th");
expect(toOrdinal(13)).toBe("13th");
});
test("21, 22, 23 use st/nd/rd", () => {
expect(toOrdinal(21)).toBe("21st");
expect(toOrdinal(22)).toBe("22nd");
expect(toOrdinal(23)).toBe("23rd");
});
test("101, 102, 103 use st/nd/rd", () => {
expect(toOrdinal(101)).toBe("101st");
expect(toOrdinal(102)).toBe("102nd");
expect(toOrdinal(103)).toBe("103rd");
});
test("111, 112, 113 use th", () => {
expect(toOrdinal(111)).toBe("111th");
expect(toOrdinal(112)).toBe("112th");
expect(toOrdinal(113)).toBe("113th");
});
});

339
tests/share.test.ts Normal file
View File

@@ -0,0 +1,339 @@
import { describe, test, expect } from "bun:test";
import { generateShareText, getVerseSnippet } from "$lib/utils/share";
import { getBookById } from "$lib/utils/game";
import type { Guess } from "$lib/utils/game";
// Helpers to build Guess objects without calling evaluateGuess
function makeGuess(bookId: string, overrides: Partial<Omit<Guess, "book">> = {}): Guess {
const book = getBookById(bookId)!;
return {
book,
testamentMatch: false,
sectionMatch: false,
adjacent: false,
firstLetterMatch: false,
...overrides,
};
}
const CORRECT_BOOK_ID = "GEN";
const exactGuess = makeGuess("GEN", {
testamentMatch: true,
sectionMatch: true,
});
const adjacentGuess = makeGuess("EXO", {
testamentMatch: true,
sectionMatch: true,
adjacent: true,
});
const sectionGuess = makeGuess("LEV", {
testamentMatch: true,
sectionMatch: true,
});
const testamentGuess = makeGuess("JOS", {
testamentMatch: true,
sectionMatch: false,
});
const noMatchGuess = makeGuess("MAT", {
testamentMatch: false,
sectionMatch: false,
});
describe("generateShareText — emoji mapping", () => {
test("exact match → ✅", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("✅");
});
test("adjacent book → ‼️", () => {
const text = generateShareText({
guesses: [adjacentGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("‼️");
});
test("section match → 🟩", () => {
// LEV matches section (Law) but is not adjacent to GEN (order 1 vs 3)
const text = generateShareText({
guesses: [sectionGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("🟩");
});
test("testament match only → 🟧", () => {
const text = generateShareText({
guesses: [testamentGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("🟧");
});
test("no match → 🟥", () => {
const text = generateShareText({
guesses: [noMatchGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("🟥");
});
});
describe("generateShareText — guess count wording", () => {
test("1 guess uses singular 'guess'", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("1 guess,");
});
test("multiple guesses uses plural 'guesses'", () => {
const text = generateShareText({
guesses: [noMatchGuess, testamentGuess, exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("3 guesses,");
});
});
describe("generateShareText — streak display", () => {
test("streak > 1 is shown with fire emoji", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
streak: 5,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("5 days 🔥");
});
test("streak of 1 is not shown", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
streak: 1,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("🔥");
});
test("undefined streak is not shown", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("🔥");
});
});
describe("generateShareText — chapter star", () => {
test("1 guess + chapterCorrect → ⭐ appended to emoji line", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: true,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("✅ ⭐");
});
test("multiple guesses + chapterCorrect → no star (only awarded for hole-in-one)", () => {
const text = generateShareText({
guesses: [noMatchGuess, exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: true,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("⭐");
});
test("1 guess + chapterCorrect false → no star", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("⭐");
});
});
describe("generateShareText — login book emoji", () => {
test("logged in uses 📜", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: true,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("📜");
expect(text).not.toContain("📖");
});
test("not logged in uses 📖", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("📖");
expect(text).not.toContain("📜");
});
});
describe("generateShareText — date formatting", () => {
test("date is formatted as 'Mon DD, YYYY'", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("Jan 15, 2025");
});
});
describe("generateShareText — guess order", () => {
test("guesses are reversed in the emoji line (first guess last)", () => {
// noMatchGuess first, then exactGuess — reversed output: ✅🟥
const text = generateShareText({
guesses: [noMatchGuess, exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
const lines = text.split("\n");
expect(lines[2]).toBe("✅🟥");
});
});
describe("getVerseSnippet", () => {
test("wraps output in curly double quotes", () => {
const result = getVerseSnippet("Hello world");
expect(result.startsWith("\u201C")).toBe(true);
expect(result.endsWith("\u201D")).toBe(true);
});
test("short verse (fewer than 10 words) returns full text", () => {
const result = getVerseSnippet("For God so loved");
// No punctuation search happens, returns all words
expect(result).toContain("For God so loved");
expect(result).toContain("...");
});
test("verse with no punctuation in range returns first 25 words", () => {
const words = Array.from({ length: 30 }, (_, i) => `word${i + 1}`);
const verse = words.join(" ");
const result = getVerseSnippet(verse);
// Should contain up to 25 words
expect(result).toContain("word25");
expect(result).not.toContain("word26");
});
test("truncates at punctuation between words 10 and 25", () => {
// 12 words before comma, rest after
const verse =
"one two three four five six seven eight nine ten eleven twelve, thirteen fourteen fifteen twenty";
const result = getVerseSnippet(verse);
// The comma is after word 12, which is between word 10 and 25
expect(result).toContain("twelve");
expect(result).not.toContain("thirteen");
});
test("punctuation before word 10 does not trigger truncation", () => {
// Comma is after word 5 — before the search window starts at word 10
const verse =
"one two three four five, six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen";
const result = getVerseSnippet(verse);
// The comma at word 5 is before start of search range, so we continue
// The snippet should contain word 10 at minimum
expect(result).toContain("ten");
});
test("does not include trailing whitespace before ellipsis", () => {
const verse =
"one two three four five six seven eight nine ten eleven twelve, rest of verse here";
const result = getVerseSnippet(verse);
// trimEnd is applied before adding ..., so no space before ...
expect(result).not.toMatch(/\s\.\.\./);
});
});

129
tests/stats.test.ts Normal file
View File

@@ -0,0 +1,129 @@
import { describe, test, expect } from "bun:test";
import {
formatDate,
getGradeColor,
getPerformanceMessage,
getStreakMessage,
} from "$lib/utils/stats";
describe("getGradeColor", () => {
test("S++ → purple", () => {
expect(getGradeColor("S++")).toBe("text-purple-600 bg-purple-100");
});
test("S+ → yellow", () => {
expect(getGradeColor("S+")).toBe("text-yellow-600 bg-yellow-100");
});
test("A+ → green", () => {
expect(getGradeColor("A+")).toBe("text-green-600 bg-green-100");
});
test("A → light green", () => {
expect(getGradeColor("A")).toBe("text-green-500 bg-green-50");
});
test("B+ → blue", () => {
expect(getGradeColor("B+")).toBe("text-blue-600 bg-blue-100");
});
test("B → light blue", () => {
expect(getGradeColor("B")).toBe("text-blue-500 bg-blue-50");
});
test("C+ → orange", () => {
expect(getGradeColor("C+")).toBe("text-orange-600 bg-orange-100");
});
test("C → red", () => {
expect(getGradeColor("C")).toBe("text-red-600 bg-red-100");
});
test("unknown grade → gray fallback", () => {
expect(getGradeColor("X")).toBe("text-gray-600 bg-gray-100");
expect(getGradeColor("")).toBe("text-gray-600 bg-gray-100");
});
});
describe("formatDate", () => {
test("formats a mid-year date", () => {
expect(formatDate("2024-07-04")).toBe("Jul 4");
});
test("formats a January date", () => {
expect(formatDate("2024-01-15")).toBe("Jan 15");
});
test("formats the last day of the year", () => {
expect(formatDate("2023-12-31")).toBe("Dec 31");
});
test("formats a single-digit day without leading zero", () => {
expect(formatDate("2025-03-01")).toBe("Mar 1");
});
test("year in input does not appear in output", () => {
expect(formatDate("2024-06-20")).not.toContain("2024");
});
});
describe("getStreakMessage", () => {
test("0 → prompt to start", () => {
expect(getStreakMessage(0)).toBe("Start your streak today!");
});
test("1 → encouragement", () => {
expect(getStreakMessage(1)).toBe("Keep it going!");
});
test("2 → X days strong", () => {
expect(getStreakMessage(2)).toBe("2 days strong!");
});
test("6 → X days strong (upper bound of that range)", () => {
expect(getStreakMessage(6)).toBe("6 days strong!");
});
test("7 → week streak message", () => {
expect(getStreakMessage(7)).toBe("7 day streak - amazing!");
});
test("29 → upper bound of week-streak range", () => {
expect(getStreakMessage(29)).toBe("29 day streak - amazing!");
});
test("30 → unstoppable message", () => {
expect(getStreakMessage(30)).toBe("30 days - you're unstoppable!");
});
test("100 → unstoppable message", () => {
expect(getStreakMessage(100)).toBe("100 days - you're unstoppable!");
});
});
describe("getPerformanceMessage", () => {
test("≤ 2 guesses → exceptional", () => {
expect(getPerformanceMessage(1)).toBe("Exceptional performance!");
expect(getPerformanceMessage(2)).toBe("Exceptional performance!");
});
test("≤ 4 guesses → great", () => {
expect(getPerformanceMessage(2.1)).toBe("Great performance!");
expect(getPerformanceMessage(4)).toBe("Great performance!");
});
test("≤ 6 guesses → good", () => {
expect(getPerformanceMessage(4.1)).toBe("Good performance!");
expect(getPerformanceMessage(6)).toBe("Good performance!");
});
test("≤ 8 guesses → room for improvement", () => {
expect(getPerformanceMessage(6.1)).toBe("Room for improvement!");
expect(getPerformanceMessage(8)).toBe("Room for improvement!");
});
test("> 8 guesses → keep practicing", () => {
expect(getPerformanceMessage(8.1)).toBe("Keep practicing!");
expect(getPerformanceMessage(20)).toBe("Keep practicing!");
});
});