Merge remote-tracking branch 'origin/release/v2.16.0' into feat/recurrence-exdate
# Conflicts: # app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.calendula.data.appname
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Pure decision logic behind the app-name toggle (issue #44). The framework seam
|
||||
* ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered
|
||||
* on-device; these tests pin the read interpretation and the write ordering.
|
||||
*/
|
||||
class LauncherNameManagerTest {
|
||||
|
||||
@Test
|
||||
fun `enabled calendar alias reads as CALENDAR`() {
|
||||
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
|
||||
.isEqualTo(LauncherName.CALENDAR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default and disabled calendar alias read as CALENDULA`() {
|
||||
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
|
||||
.isEqualTo(LauncherName.CALENDULA)
|
||||
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
|
||||
.isEqualTo(LauncherName.CALENDULA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write plan enables the target alias before disabling the other`() {
|
||||
val toCalendar = aliasWritePlan(LauncherName.CALENDAR)
|
||||
assertThat(toCalendar).containsExactly(
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||
).inOrder()
|
||||
|
||||
val toCalendula = aliasWritePlan(LauncherName.CALENDULA)
|
||||
assertThat(toCalendula).containsExactly(
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write plan never disables both aliases in the same step`() {
|
||||
// The first step is always an enable, so the launcher can never observe a
|
||||
// zero-entry transient regardless of switch direction.
|
||||
for (target in LauncherName.entries) {
|
||||
assertThat(aliasWritePlan(target).first().enabled).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,8 +123,18 @@ class EventWriteMapperTest {
|
||||
|
||||
private val seriesStart = 1_700_000_000_000L
|
||||
|
||||
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
|
||||
buildEventUpdateValues(original, updated, seriesStart, berlin)
|
||||
private fun update(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
series: Long = seriesStart,
|
||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
||||
|
||||
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||
private fun instantAt(local: String, zoneId: String): Long =
|
||||
java.time.LocalDateTime.parse(local)
|
||||
.atZone(java.time.ZoneId.of(zoneId))
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
|
||||
@Test
|
||||
fun `pristine form produces no values`() {
|
||||
@@ -219,6 +229,77 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinning a recurring event to another zone keeps the series wall clock`() {
|
||||
// The regression this guards: the series DTSTART used to move by a
|
||||
// millisecond delta measured at the *edited occurrence*. Here the series
|
||||
// anchor sits in January (Berlin CET, +1) and the edited occurrence in
|
||||
// July (Berlin CEST, +2), so the July delta is an hour off for January —
|
||||
// the whole series would have drifted to 10:00 Tokyo.
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
|
||||
val values = update(original, original.copy(timezone = "Asia/Tokyo"), series)
|
||||
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo")
|
||||
// The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00.
|
||||
assertThat(values[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a time edit moves the series anchor in wall clock across a DST boundary`() {
|
||||
// Anchor in winter, edited occurrence in summer: pushing the occurrence
|
||||
// one hour later must leave the anchor at 10:00 winter time, not at an
|
||||
// instant that re-reads as 11:00 once the offset differs.
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a recurring occurrence to another day shifts the anchor by whole days`() {
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
|
||||
val values = update(original, original.copy(isAllDay = true), series)
|
||||
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||
val dtStart = values[CalendarContract.Events.DTSTART] as Long
|
||||
assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
||||
val original = form()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WidgetScaleTest {
|
||||
|
||||
@Test
|
||||
fun `the on-device calibration points map to their tiers`() {
|
||||
// The two sizes measured on-device: the compact widget stays COMPACT (the
|
||||
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
|
||||
// which read as too big on a phone (#51).
|
||||
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full-width widget at ordinary height still scales up`() {
|
||||
// The regression the height cap used to cause: widening the widget without
|
||||
// also making it unusually tall is *the* resize #51 reports, and it must
|
||||
// reach the tier its width earned. Three cells tall is about 270dp.
|
||||
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `width buckets into the four tiers`() {
|
||||
// Tall enough that the height cap never binds, isolating the width rule.
|
||||
val h = 500.dp
|
||||
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extra height never raises the tier`() {
|
||||
// Height decides how many rows are visible, not how big they are: a tall,
|
||||
// narrow widget wants more events, not bigger text.
|
||||
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a genuinely squashed widget is stepped back down`() {
|
||||
// Same (wide) width, shrinking height. The cap exists to stop oversized type
|
||||
// surviving in a one- or two-row sliver — it must not fire at normal heights.
|
||||
// Heights are gross; the cap works on height minus 60dp of chrome, so the
|
||||
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
|
||||
val wide = 378.dp
|
||||
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
|
||||
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tier is monotonic in both axes`() {
|
||||
// Growing a widget must never make its type smaller. Sweeps the whole
|
||||
// plausible range rather than spot-checking, so a future threshold edit
|
||||
// can't accidentally invert a step.
|
||||
val widths = (110..900 step 7).map { it.dp }
|
||||
val heights = (110..900 step 7).map { it.dp }
|
||||
widths.forEach { w ->
|
||||
heights.zipWithNext { shorter, taller ->
|
||||
assertThat(scaleFor(DpSize(w, taller)))
|
||||
.isAtLeast(scaleFor(DpSize(w, shorter)))
|
||||
}
|
||||
}
|
||||
heights.forEach { h ->
|
||||
widths.zipWithNext { narrower, wider ->
|
||||
assertThat(scaleFor(DpSize(wider, h)))
|
||||
.isAtLeast(scaleFor(DpSize(narrower, h)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AgendaScaleTest {
|
||||
|
||||
// --- the "default size is unchanged" regression guard ---------------------
|
||||
|
||||
@Test
|
||||
fun `every default placement width stays COMPACT`() {
|
||||
// Ties the guarantee to the provider XML's declared 3-cell default rather
|
||||
// than to one measured launcher: the whole band a default placement can
|
||||
// land in must bucket to COMPACT, or a freshly placed widget silently
|
||||
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
|
||||
val band = AGENDA_DEFAULT_WIDTH_BAND
|
||||
var w = band.start
|
||||
while (w <= band.endInclusive) {
|
||||
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
w += 1.dp
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `COMPACT metrics equal the widget's original constants`() {
|
||||
// If this fails, a default-sized agenda widget no longer looks as it did.
|
||||
val m = metricsFor(WidgetScale.COMPACT)
|
||||
assertThat(m.title).isEqualTo(16.sp)
|
||||
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||
assertThat(m.message).isEqualTo(14.sp)
|
||||
assertThat(m.stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.iconImage).isEqualTo(22.dp)
|
||||
assertThat(m.iconBox).isEqualTo(40.dp)
|
||||
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived text indent matches the original hardcoded inset`() {
|
||||
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
|
||||
// day headers and the placeholder line stop aligning with event titles.
|
||||
assertThat(TEXT_INDENT).isEqualTo(19.dp)
|
||||
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
|
||||
}
|
||||
|
||||
// --- the ramp ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `sizes are non-decreasing across the tiers`() {
|
||||
val tiers = WidgetScale.entries.map(::metricsFor)
|
||||
|
||||
tiers.zipWithNext { small, big ->
|
||||
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
||||
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the event title keeps its lead over the time line`() {
|
||||
// The secondary line steps more slowly on purpose; if it ever caught up the
|
||||
// row would lose its hierarchy.
|
||||
WidgetScale.entries.map(::metricsFor).forEach { m ->
|
||||
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no tier grows type more than half again over the baseline`() {
|
||||
// Guards against a future edit turning "more readable" into "absurd".
|
||||
val base = metricsFor(WidgetScale.COMPACT)
|
||||
val top = metricsFor(WidgetScale.XLARGE)
|
||||
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
||||
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||
}
|
||||
|
||||
// --- font scale ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the stripe tracks the system font scale`() {
|
||||
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
||||
// at large accessibility font settings and the stripe under-runs the row.
|
||||
val m = metricsFor(WidgetScale.COMPACT)
|
||||
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
||||
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaling for the default font scale changes nothing`() {
|
||||
val m = metricsFor(WidgetScale.LARGE)
|
||||
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `font scaling leaves the sp sizes alone`() {
|
||||
// Glance already applies the font scale to sp; scaling them here too would
|
||||
// double-count it.
|
||||
val m = metricsFor(WidgetScale.REGULAR)
|
||||
val scaled = m.scaledForFont(1.3f)
|
||||
assertThat(scaled.title).isEqualTo(m.title)
|
||||
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user