diff --git a/src/lib/utils/game.ts b/src/lib/utils/game.ts index ce7cf1b..979d64d 100644 --- a/src/lib/utils/game.ts +++ b/src/lib/utils/game.ts @@ -79,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string { } export function toOrdinal(n: number): string { - if (n >= 11 && n <= 13) { + if (n % 100 >= 11 && n % 100 <= 13) { return `${n}th`; } const mod = n % 10; diff --git a/tests/bible.test.ts b/tests/bible.test.ts new file mode 100644 index 0000000..759e9ed --- /dev/null +++ b/tests/bible.test.ts @@ -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); + } + }); +}); diff --git a/tests/game.test.ts b/tests/game.test.ts new file mode 100644 index 0000000..24f3f15 --- /dev/null +++ b/tests/game.test.ts @@ -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"); + }); +}); diff --git a/tests/share.test.ts b/tests/share.test.ts new file mode 100644 index 0000000..29bc50c --- /dev/null +++ b/tests/share.test.ts @@ -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> = {}): 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\.\.\./); + }); +}); diff --git a/tests/stats.test.ts b/tests/stats.test.ts new file mode 100644 index 0000000..4d89ae1 --- /dev/null +++ b/tests/stats.test.ts @@ -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!"); + }); +});