Utils.spec.js
233 lines
| 1 | import { render, screen } from "@testing-library/react"; |
| 2 | import { |
| 3 | formatPublishDate, |
| 4 | getBadge, |
| 5 | renderTruncated, |
| 6 | TRUNCATE_LENGTH, |
| 7 | togglePageSelection, |
| 8 | isPageFullySelected, |
| 9 | isPagePartiallySelected, |
| 10 | } from "../Utils"; |
| 11 | |
| 12 | describe("Emails/Utils", () => { |
| 13 | describe("formatPublishDate", () => { |
| 14 | it('returns "Just Now" for empty / null / undefined input', () => { |
| 15 | expect(formatPublishDate("")).toBe("Just Now"); |
| 16 | expect(formatPublishDate(null)).toBe("Just Now"); |
| 17 | expect(formatPublishDate(undefined)).toBe("Just Now"); |
| 18 | }); |
| 19 | |
| 20 | it('returns "Just Now" for an unparseable date string', () => { |
| 21 | expect(formatPublishDate("not-a-date")).toBe("Just Now"); |
| 22 | }); |
| 23 | |
| 24 | it("formats a valid local date as YYYY/MM/DD at h:mm am/pm", () => { |
| 25 | // Construct an unambiguous local-time date so getHours/getMinutes |
| 26 | // are independent of the host timezone. |
| 27 | const local = new Date(2026, 4, 10, 13, 5).toString(); |
| 28 | expect(formatPublishDate(local)).toBe("2026/05/10 at 1:05 pm"); |
| 29 | }); |
| 30 | |
| 31 | it("renders midnight as 12:00 am", () => { |
| 32 | const local = new Date(2026, 0, 1, 0, 0).toString(); |
| 33 | expect(formatPublishDate(local)).toBe("2026/01/01 at 12:00 am"); |
| 34 | }); |
| 35 | |
| 36 | it("renders noon as 12:00 pm", () => { |
| 37 | const local = new Date(2026, 0, 1, 12, 0).toString(); |
| 38 | expect(formatPublishDate(local)).toBe("2026/01/01 at 12:00 pm"); |
| 39 | }); |
| 40 | |
| 41 | it("zero-pads single-digit months, days, and minutes", () => { |
| 42 | const local = new Date(2026, 1, 3, 9, 7).toString(); |
| 43 | expect(formatPublishDate(local)).toBe("2026/02/03 at 9:07 am"); |
| 44 | }); |
| 45 | }); |
| 46 | |
| 47 | describe("getBadge", () => { |
| 48 | const cases = [ |
| 49 | ["publish", "Published"], |
| 50 | ["draft", "Draft"], |
| 51 | ["trash", "Trashed"], |
| 52 | ["pending", "Pending Review"], |
| 53 | ["private", "Private"], |
| 54 | ["future", "Scheduled"], |
| 55 | ]; |
| 56 | |
| 57 | it.each(cases)('renders the "%s" status as label "%s"', (status, label) => { |
| 58 | render(<>{getBadge(status)}</>); |
| 59 | expect(screen.getByText(label)).toBeInTheDocument(); |
| 60 | }); |
| 61 | |
| 62 | it('falls back to "Unknown" for an unrecognized status', () => { |
| 63 | render(<>{getBadge("nonsense")}</>); |
| 64 | expect(screen.getByText("Unknown")).toBeInTheDocument(); |
| 65 | }); |
| 66 | }); |
| 67 | |
| 68 | describe("renderTruncated", () => { |
| 69 | it('returns "—" for empty input', () => { |
| 70 | expect(renderTruncated("")).toBe("—"); |
| 71 | expect(renderTruncated(null)).toBe("—"); |
| 72 | expect(renderTruncated(undefined)).toBe("—"); |
| 73 | }); |
| 74 | |
| 75 | it('returns "—" when input is already an em dash', () => { |
| 76 | expect(renderTruncated("—")).toBe("—"); |
| 77 | }); |
| 78 | |
| 79 | // Three branches in one table: |
| 80 | // - at-or-under threshold returns the input string verbatim |
| 81 | // - over threshold (default maxLen) renders prefix + ellipsis |
| 82 | // - over a custom maxLen behaves the same with maxLen wired through |
| 83 | it.each([ |
| 84 | [ |
| 85 | "returns the full string when at the threshold", |
| 86 | "x".repeat(TRUNCATE_LENGTH), |
| 87 | undefined, |
| 88 | { passthrough: "x".repeat(TRUNCATE_LENGTH) }, |
| 89 | ], |
| 90 | [ |
| 91 | "truncates at the default threshold with an ellipsis trigger", |
| 92 | "x".repeat(TRUNCATE_LENGTH + 5), |
| 93 | undefined, |
| 94 | { prefix: "x".repeat(TRUNCATE_LENGTH - 1) }, |
| 95 | ], |
| 96 | [ |
| 97 | "respects a custom maxLen", |
| 98 | "hello world", |
| 99 | 5, |
| 100 | { prefix: "hell", absent: "world" }, |
| 101 | ], |
| 102 | ])("renderTruncated: %s", (_label, input, maxLen, asserts) => { |
| 103 | const out = maxLen === undefined |
| 104 | ? renderTruncated(input) |
| 105 | : renderTruncated(input, maxLen); |
| 106 | |
| 107 | if (asserts.passthrough !== undefined) { |
| 108 | expect(out).toBe(asserts.passthrough); |
| 109 | return; |
| 110 | } |
| 111 | const { container } = render(<>{out}</>); |
| 112 | expect(container.textContent).toContain(asserts.prefix); |
| 113 | expect(container.textContent).toContain("…"); |
| 114 | if (asserts.absent) { |
| 115 | expect(container.textContent).not.toContain(asserts.absent); |
| 116 | } |
| 117 | }); |
| 118 | }); |
| 119 | |
| 120 | // Helpers for the table's bulk-select header. The behavior contract: |
| 121 | // - togglePageSelection: scopes select-all to the current page; never touches |
| 122 | // ids that belong to other pages, so selections survive pagination. |
| 123 | // - isPageFullySelected: "every visible row is selected" |
| 124 | // - isPagePartiallySelected: "some, but not all, visible rows are selected" |
| 125 | // An empty current page reports as neither full nor partial — the header |
| 126 | // checkbox must not render as checked when there's nothing to act on. |
| 127 | describe("togglePageSelection", () => { |
| 128 | const page = [{ id: 1 }, { id: 2 }, { id: 3 }]; |
| 129 | |
| 130 | it("adds current page ids to an empty selection on check", () => { |
| 131 | expect(togglePageSelection([], page, true)).toEqual([1, 2, 3]); |
| 132 | }); |
| 133 | |
| 134 | it("preserves ids from other pages when checking the current page", () => { |
| 135 | // 99 and 100 are off-page selections that must survive. |
| 136 | expect(togglePageSelection([99, 100], page, true)).toEqual([ |
| 137 | 99, |
| 138 | 100, |
| 139 | 1, |
| 140 | 2, |
| 141 | 3, |
| 142 | ]); |
| 143 | }); |
| 144 | |
| 145 | it("dedupes when a current page id is already selected", () => { |
| 146 | // Asserted by content + length, not order — the helper makes no |
| 147 | // ordering guarantee and callers don't rely on one. |
| 148 | const next = togglePageSelection([2, 99], page, true); |
| 149 | expect(next).toHaveLength(4); |
| 150 | expect(next).toEqual(expect.arrayContaining([1, 2, 3, 99])); |
| 151 | }); |
| 152 | |
| 153 | it("removes only current page ids on uncheck", () => { |
| 154 | // Off-page ids (99) stay; on-page ids (1, 3) are stripped. |
| 155 | expect(togglePageSelection([1, 3, 99], page, false)).toEqual([99]); |
| 156 | }); |
| 157 | |
| 158 | it("is a no-op uncheck when nothing on the current page was selected", () => { |
| 159 | expect(togglePageSelection([99], page, false)).toEqual([99]); |
| 160 | }); |
| 161 | |
| 162 | it("handles a null/undefined prev selection", () => { |
| 163 | expect(togglePageSelection(null, page, true)).toEqual([1, 2, 3]); |
| 164 | expect(togglePageSelection(undefined, page, false)).toEqual([]); |
| 165 | }); |
| 166 | |
| 167 | it("handles an empty / missing page", () => { |
| 168 | expect(togglePageSelection([99], [], true)).toEqual([99]); |
| 169 | expect(togglePageSelection([99], undefined, false)).toEqual([99]); |
| 170 | }); |
| 171 | }); |
| 172 | |
| 173 | describe("isPageFullySelected", () => { |
| 174 | const page = [{ id: 1 }, { id: 2 }, { id: 3 }]; |
| 175 | |
| 176 | it("is true when every page item is in the selection", () => { |
| 177 | expect(isPageFullySelected(page, [1, 2, 3])).toBe(true); |
| 178 | }); |
| 179 | |
| 180 | it("is true when the selection is a superset of the page", () => { |
| 181 | // Off-page selections (99) shouldn't block "fully selected" for this page. |
| 182 | expect(isPageFullySelected(page, [1, 2, 3, 99])).toBe(true); |
| 183 | }); |
| 184 | |
| 185 | it("is false when one page item is missing from the selection", () => { |
| 186 | expect(isPageFullySelected(page, [1, 3])).toBe(false); |
| 187 | }); |
| 188 | |
| 189 | it("is false when the selection is empty", () => { |
| 190 | expect(isPageFullySelected(page, [])).toBe(false); |
| 191 | }); |
| 192 | |
| 193 | it("is false for an empty / missing page (nothing on screen to be 'fully selected')", () => { |
| 194 | expect(isPageFullySelected([], [1, 2, 3])).toBe(false); |
| 195 | expect(isPageFullySelected(undefined, [1, 2, 3])).toBe(false); |
| 196 | }); |
| 197 | |
| 198 | it("tolerates a null/undefined selection", () => { |
| 199 | expect(isPageFullySelected(page, null)).toBe(false); |
| 200 | expect(isPageFullySelected(page, undefined)).toBe(false); |
| 201 | }); |
| 202 | }); |
| 203 | |
| 204 | describe("isPagePartiallySelected", () => { |
| 205 | const page = [{ id: 1 }, { id: 2 }, { id: 3 }]; |
| 206 | |
| 207 | it("is true when some — but not all — page items are selected", () => { |
| 208 | expect(isPagePartiallySelected(page, [1])).toBe(true); |
| 209 | expect(isPagePartiallySelected(page, [2, 3])).toBe(true); |
| 210 | }); |
| 211 | |
| 212 | it("is false when no page items are selected (even if off-page items are)", () => { |
| 213 | expect(isPagePartiallySelected(page, [])).toBe(false); |
| 214 | expect(isPagePartiallySelected(page, [99])).toBe(false); |
| 215 | }); |
| 216 | |
| 217 | it("is false when every page item is selected (that's full, not partial)", () => { |
| 218 | expect(isPagePartiallySelected(page, [1, 2, 3])).toBe(false); |
| 219 | expect(isPagePartiallySelected(page, [1, 2, 3, 99])).toBe(false); |
| 220 | }); |
| 221 | |
| 222 | it("is false for an empty / missing page", () => { |
| 223 | expect(isPagePartiallySelected([], [1])).toBe(false); |
| 224 | expect(isPagePartiallySelected(undefined, [1])).toBe(false); |
| 225 | }); |
| 226 | |
| 227 | it("tolerates a null/undefined selection", () => { |
| 228 | expect(isPagePartiallySelected(page, null)).toBe(false); |
| 229 | expect(isPagePartiallySelected(page, undefined)).toBe(false); |
| 230 | }); |
| 231 | }); |
| 232 | }); |
| 233 |