mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
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:
@@ -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;
|
||||
|
||||
234
tests/bible.test.ts
Normal file
234
tests/bible.test.ts
Normal 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
271
tests/game.test.ts
Normal 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
339
tests/share.test.ts
Normal 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
129
tests/stats.test.ts
Normal 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!");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user