package org.terst.doot.widget.ui import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.After import org.junit.Test import org.terst.doot.widget.data.WidgetItem import java.time.Instant import java.util.TimeZone class DootWidgetGridTest { private lateinit var originalTimeZone: TimeZone @Before fun setUp() { originalTimeZone = TimeZone.getDefault() TimeZone.setDefault(TimeZone.getTimeZone("UTC")) } @After fun tearDown() { TimeZone.setDefault(originalTimeZone) } private fun tomorrowEvent(startIso: String, endIso: String): WidgetItem = WidgetItem( id = "tmrw", title = "Tomorrow Event", source = "calendar", type = "event", start = startIso, end = endIso ) @Test fun `calcGridStart on an unfiltered mixed list is skewed by tomorrow's event hour`() { // Documents why WidgetRoot MUST pre-filter to today-only events before calling // this function: it has no date awareness, only hour-of-day. "now" is 22:00 // today (2026-07-12); the only item is tomorrow's (2026-07-13) 10:00 event. val mixedList = listOf(tomorrowEvent("2026-07-13T10:00:00Z", "2026-07-13T14:30:00Z")) assertEquals(9, calcGridStart(mixedList, nowHour = 22)) } @Test fun `calcGridStart on the correctly today-filtered (empty) list falls back to nowHour`() { val gridStart = calcGridStart(emptyList(), nowHour = 22) assertEquals(21, gridStart) } @Test fun `calcGridEnd falls back to nowHour plus eight when no today events, capped at 23`() { val gridEnd = calcGridEnd(emptyList(), nowHour = 22) assertEquals(23, gridEnd) } // isPastEvent: 2026-08-05 change pulling already-ended events out of the grid to // stack above it as list rows, same treatment past tasks already got. private fun event(endIso: String?) = WidgetItem( id = "e", title = "Event", source = "calendar", type = "event", start = "2026-07-12T09:00:00Z", end = endIso ) private fun task(startIso: String) = WidgetItem( id = "t", title = "Task", source = "doot", type = "task", start = startIso ) @Test fun `isPastEvent is true once end has passed`() { val now = Instant.parse("2026-07-12T10:00:00Z") assertTrue(isPastEvent(event(endIso = "2026-07-12T09:30:00Z"), now)) } @Test fun `isPastEvent is false while still ongoing`() { val now = Instant.parse("2026-07-12T10:00:00Z") assertFalse(isPastEvent(event(endIso = "2026-07-12T10:30:00Z"), now)) } @Test fun `isPastEvent is false with no end time -- never considered past`() { val now = Instant.parse("2026-07-12T23:00:00Z") assertFalse(isPastEvent(event(endIso = null), now)) } @Test fun `isPastEvent is always false for tasks -- SlotPacker already handles those`() { val now = Instant.parse("2026-07-12T10:00:00Z") assertFalse(isPastEvent(task(startIso = "2026-07-12T08:00:00Z"), now)) } // moonPhaseEmoji: values verified against direct computation before writing these // assertions (days-since-anchor / synodic-month arithmetic), not hand-computed. @Test fun `moonPhaseEmoji on the reference new moon date is new moon`() { assertEquals("🌑", moonPhaseEmoji(java.time.LocalDate.of(2000, 1, 6))) } @Test fun `moonPhaseEmoji about half a synodic month later is full moon`() { assertEquals("🌕", moonPhaseEmoji(java.time.LocalDate.of(2000, 1, 21))) } @Test fun `moonPhaseEmoji never indexes out of bounds across a full cycle`() { val anchor = java.time.LocalDate.of(2000, 1, 6) for (offset in 0..365) { // Must not throw for any date in a full year -- the real assertion here is // "doesn't crash," the emoji values themselves are checked in the two tests above. moonPhaseEmoji(anchor.plusDays(offset.toLong())) } } }