diff --git a/src/date-utils.ts b/src/date-utils.ts index 37272a7..c4a817c 100644 --- a/src/date-utils.ts +++ b/src/date-utils.ts @@ -5,16 +5,13 @@ /** * Format a date as a human-readable relative string. * e.g. "2 days ago", "just now", "in 3 hours" - * - * BUG: off-by-one — uses Math.floor where Math.round is needed for days, - * causing "1 day ago" to appear for anything from 12h to 47h. */ export function formatRelative(date: Date, now: Date = new Date()): string { const diffMs = now.getTime() - date.getTime() const diffSec = diffMs / 1000 const diffMin = diffSec / 60 const diffHours = diffMin / 60 - const diffDays = Math.floor(diffHours / 24) // BUG: should be Math.round + const diffDays = Math.round(Math.abs(diffHours) / 24) if (Math.abs(diffSec) < 60) return "just now" if (Math.abs(diffMin) < 60) { @@ -25,7 +22,7 @@ export function formatRelative(date: Date, now: Date = new Date()): string { const h = Math.round(Math.abs(diffHours)) return diffMs > 0 ? `${h} hour${h !== 1 ? "s" : ""} ago` : `in ${h} hour${h !== 1 ? "s" : ""}` } - const d = Math.abs(diffDays) + const d = diffDays return diffMs > 0 ? `${d} day${d !== 1 ? "s" : ""} ago` : `in ${d} day${d !== 1 ? "s" : ""}` } diff --git a/src/string-utils.ts b/src/string-utils.ts index 63fba18..b0518e2 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -11,10 +11,18 @@ export function reverse(str: string): string { return str.split("").reverse().join("") } -// TODO: implement truncate — should truncate at a word boundary, with "..." -// counting toward maxLength. Return unchanged if str.length <= maxLength. export function truncate(str: string, maxLength: number): string { - throw new Error("not implemented") + if (str.length <= maxLength) return str + const ellipsis = "..." + const available = maxLength - ellipsis.length + if (available <= 0) return ellipsis.slice(0, Math.max(0, maxLength)) + let cut = str.slice(0, available) + if (!/\s/.test(str.charAt(available))) { + const lastSpace = cut.search(/\s\S*$/) + if (lastSpace > 0) cut = cut.slice(0, lastSpace) + } + const trimmed = cut.trimEnd() + return (trimmed || str.slice(0, available)) + ellipsis } export function slugify(str: string): string { @@ -24,8 +32,8 @@ export function slugify(str: string): string { .replace(/^-|-$/g, "") } -// BUG: This doesn't handle multiple consecutive spaces export function wordCount(str: string): number { - if (!str.trim()) return 0 - return str.split(" ").length + const trimmed = str.trim() + if (!trimmed) return 0 + return trimmed.split(/\s+/).length } diff --git a/test/date-utils.test.ts b/test/date-utils.test.ts index 54b1927..47b914d 100644 --- a/test/date-utils.test.ts +++ b/test/date-utils.test.ts @@ -25,8 +25,7 @@ describe("date-utils", () => { expect(formatRelative(d, base)).toBe("3 hours ago") }) - test("1 day ago at exactly 36 hours", () => { - // 36 hours ago should be "2 days ago" with Math.round, "1 day ago" with Math.floor + test("rounds 36 hours up to 2 days ago", () => { const d = new Date(base.getTime() - 36 * 3600_000) expect(formatRelative(d, base)).toBe("2 days ago") }) @@ -42,24 +41,95 @@ describe("date-utils", () => { }) }) + describe("formatRelative extras", () => { + test("just now for identical dates", () => { + expect(formatRelative(base, base)).toBe("just now") + }) + test("just now for small future offset", () => { + expect(formatRelative(new Date(base.getTime() + 30_000), base)).toBe("just now") + }) + test("in N minutes (future)", () => { + expect(formatRelative(new Date(base.getTime() + 5 * 60_000), base)).toBe("in 5 minutes") + }) + test("in 1 minute (singular, future)", () => { + expect(formatRelative(new Date(base.getTime() + 61_000), base)).toBe("in 1 minute") + }) + test("1 hour ago (singular)", () => { + expect(formatRelative(new Date(base.getTime() - 3600_000), base)).toBe("1 hour ago") + }) + test("in 1 hour (singular, future)", () => { + expect(formatRelative(new Date(base.getTime() + 3600_000), base)).toBe("in 1 hour") + }) + test("1 day ago at exactly 24 hours", () => { + expect(formatRelative(new Date(base.getTime() - 86400_000), base)).toBe("1 day ago") + }) + test("in 2 days (future)", () => { + expect(formatRelative(new Date(base.getTime() + 2 * 86400_000), base)).toBe("in 2 days") + }) + test("defaults now to current time", () => { + expect(formatRelative(new Date())).toBe("just now") + }) + }) + describe("isSameDay", () => { test("same day returns true", () => { expect(isSameDay(new Date("2024-06-15T09:00:00"), new Date("2024-06-15T23:59:59"))).toBe(true) }) test("different day returns false", () => { - expect(isSameDay(new Date("2024-06-15"), new Date("2024-06-16"))).toBe(false) + expect(isSameDay(new Date(2024, 5, 15), new Date(2024, 5, 16))).toBe(false) + }) + }) + + describe("isSameDay extras", () => { + test("same day-of-month in different months is false", () => { + expect(isSameDay(new Date(2024, 5, 15), new Date(2024, 6, 15))).toBe(false) + }) + test("same day-of-month in different years is false", () => { + expect(isSameDay(new Date(2024, 5, 15), new Date(2025, 5, 15))).toBe(false) + }) + test("midnight boundary is a different day", () => { + expect(isSameDay(new Date(2024, 5, 15, 23, 59, 59), new Date(2024, 5, 16, 0, 0, 0))).toBe(false) + }) + test("a date is the same day as itself", () => { + const d = new Date(2024, 5, 15) + expect(isSameDay(d, d)).toBe(true) }) }) describe("addDays", () => { test("adds days", () => { - const result = addDays(new Date("2024-06-15"), 3) + const result = addDays(new Date(2024, 5, 15), 3) expect(result.getDate()).toBe(18) }) test("does not mutate original", () => { - const d = new Date("2024-06-15") + const d = new Date(2024, 5, 15) addDays(d, 5) expect(d.getDate()).toBe(15) }) + test("subtracts days with negative value", () => { + expect(addDays(new Date(2024, 5, 15), -5).getDate()).toBe(10) + }) + test("zero days returns equal date as a new object", () => { + const d = new Date(2024, 5, 15) + const r = addDays(d, 0) + expect(r.getTime()).toBe(d.getTime()) + expect(r).not.toBe(d) + }) + test("rolls over month boundary", () => { + const r = addDays(new Date(2024, 0, 30), 3) + expect(r.getMonth()).toBe(1) + expect(r.getDate()).toBe(2) + }) + test("rolls over year boundary", () => { + const r = addDays(new Date(2024, 11, 30), 3) + expect(r.getFullYear()).toBe(2025) + expect(r.getMonth()).toBe(0) + expect(r.getDate()).toBe(2) + }) + test("handles leap day", () => { + const r = addDays(new Date(2024, 1, 28), 1) + expect(r.getMonth()).toBe(1) + expect(r.getDate()).toBe(29) + }) }) }) diff --git a/test/string-utils.test.ts b/test/string-utils.test.ts index 89cc443..f277c99 100644 --- a/test/string-utils.test.ts +++ b/test/string-utils.test.ts @@ -9,12 +9,36 @@ describe("string-utils", () => { test("handles empty string", () => { expect(capitalize("")).toBe("") }) + test("leaves already-capitalized string unchanged", () => { + expect(capitalize("Hello")).toBe("Hello") + }) + test("only changes the first character", () => { + expect(capitalize("hELLO")).toBe("HELLO") + }) + test("handles single character", () => { + expect(capitalize("a")).toBe("A") + }) + test("leaves non-letter first character unchanged", () => { + expect(capitalize("1abc")).toBe("1abc") + }) }) describe("reverse", () => { test("reverses a string", () => { expect(reverse("hello")).toBe("olleh") }) + test("handles empty string", () => { + expect(reverse("")).toBe("") + }) + test("handles single character", () => { + expect(reverse("a")).toBe("a") + }) + test("palindrome is unchanged", () => { + expect(reverse("racecar")).toBe("racecar") + }) + test("preserves spaces and punctuation", () => { + expect(reverse("a b!")).toBe("!b a") + }) }) describe("slugify", () => { @@ -24,12 +48,33 @@ describe("string-utils", () => { test("strips leading/trailing hyphens", () => { expect(slugify(" hello ")).toBe("hello") }) + test("collapses runs of special characters into one hyphen", () => { + expect(slugify("Hello, World!!")).toBe("hello-world") + }) + test("handles empty string", () => { + expect(slugify("")).toBe("") + }) + test("returns empty string when no alphanumerics", () => { + expect(slugify("!!!")).toBe("") + }) + test("keeps digits", () => { + expect(slugify("Top 10 Tips")).toBe("top-10-tips") + }) }) describe("wordCount", () => { test("counts words", () => { expect(wordCount("hello world")).toBe(2) }) + test("splits on tabs and newlines", () => { + expect(wordCount("a\tb\nc")).toBe(3) + }) + test("counts a single word", () => { + expect(wordCount("hello")).toBe(1) + }) + test("ignores leading and trailing single space", () => { + expect(wordCount(" hello world ")).toBe(2) + }) test("handles multiple consecutive spaces", () => { expect(wordCount("hello world")).toBe(2) }) @@ -49,11 +94,37 @@ describe("string-utils", () => { expect(truncate("hello world foo", 11)).toBe("hello...") }) test("ellipsis counts toward maxLength", () => { - // "hello..." is 8 chars — fits in maxLength 8 - expect(truncate("hello world", 8).length).toBeLessThanOrEqual(8) + expect(truncate("hello world", 8)).toBe("hello...") }) test("handles string shorter than ellipsis", () => { expect(truncate("hi", 5)).toBe("hi") }) + test("returns string unchanged at exactly maxLength", () => { + expect(truncate("hello", 5)).toBe("hello") + }) + test("hard-cuts a single long word", () => { + expect(truncate("abcdefghij", 8)).toBe("abcde...") + }) + test("result never exceeds maxLength", () => { + const input = "the quick brown fox jumps over the lazy dog" + for (let n = 0; n <= 30; n++) { + expect(truncate(input, n).length).toBeLessThanOrEqual(n) + } + }) + test("maxLength 1 yields truncated ellipsis", () => { + expect(truncate("hello", 1)).toBe(".") + }) + test("maxLength 2 yields truncated ellipsis", () => { + expect(truncate("hello", 2)).toBe("..") + }) + test("maxLength 3 yields just the ellipsis", () => { + expect(truncate("hello", 3)).toBe("...") + }) + test("falls back to hard cut when leading whitespace leaves empty word cut", () => { + expect(truncate(" hello world", 8)).toBe(" hel...") + }) + test("handles empty string", () => { + expect(truncate("", 5)).toBe("") + }) }) })