Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/actions/common-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,19 @@ runs:
${{ runner.os }}-android-avd-${{ inputs.android_api_level }}-

# Setup Android SDK for emulator if needed and not cached
#
# NOTE: do not add the legacy 'tools' package here. Google removed it from
# the SDK repository (superseded by cmdline-tools), so sdkmanager exits 1
# with "Failed to find package 'tools'" and the whole setup step dies --
# which shows up downstream as "No test result XML files found" because the
# emulator never starts. Nothing in this repo uses the old tools/ binaries;
# avdmanager and emulator come from cmdline-tools and the emulator package,
# and reactivecircus/android-emulator-runner installs what else it needs.
- name: Setup Android SDK
if: inputs.run_emulator_setup == 'true' && steps.android-cache.outputs.cache-hit != 'true'
uses: android-actions/setup-android@v3
with:
packages: 'tools platform-tools system-images;android-${{ inputs.android_api_level }};${{ inputs.android_target }};${{ inputs.arch }}'
packages: 'platform-tools system-images;android-${{ inputs.android_api_level }};${{ inputs.android_target }};${{ inputs.arch }}'
accept-android-sdk-licenses: true
log-accepted-android-sdk-licenses: false

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -966,8 +966,11 @@ jobs:
android/app/build/test-results/
retention-days: 90

# v1.15+ is required: earlier images lack findutils, so the action's own
# script dies with "xargs is not available" and produces no report.
# See https://github.com/simple-elf/allure-report-action/pull/78
- name: Generate Allure HTML Report
uses: simple-elf/allure-report-action@v1.13
uses: simple-elf/allure-report-action@v1.15
if: always()
with:
allure_results: android/app/build/outputs/allure-results
Expand Down
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ Use the `plan-making` skill in `.skills/plan-making/` when a task warrants a wri

**Do not use an editor's built-in Plan Mode** or create `.cursor/plans/*.plan.md` files — that directory is deprecated and retained for historical reference only.

# Branching and PRs

**The default branch is `master`.** There is no `main`.

Branches are **long-lived and named after the plan** they implement, not per-commit or per-task. Work accumulates on the branch; when it reaches a coherent stopping point the PR gets merged and a new branch starts for the next stage. A small plan lands on one branch; a large one spans several sequential branches.

**How to size a PR:** if it is getting onerous to review in the GitHub UI, it is too large. That is the signal — there is no commit or line-count rule. Suggest merging when a branch reaches a natural stopping point, rather than letting it grow until review is painful.

**Never merge a PR yourself.** Say when something is ready and let the maintainer decide.

## Never push to `master`

Always push with an explicit refspec:

```bash
git push origin HEAD:refs/heads/<branch>
```

This is not hypothetical. `git checkout -b <new> origin/master` sets the new branch's upstream to **`master`**, so a subsequent `git push -u origin <new-branch>` resolves to the tracked ref and pushes to `master` instead of creating the branch. Branch protection reported failing checks but the push still landed.

After branching from `origin/master`, run `git branch --unset-upstream` before pushing, and verify afterwards with `git ls-remote origin master`.

# Don't try to boil the ocean. Dont try to make big sweeping changes when more focused ones will do

Always think of the minimum viable solution to a problem or change to make. make sure that works and then build on top of it. break things down into small testable pieces first. That said
Expand Down
28 changes: 28 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,34 @@ android {
includeNoLocationClasses = true
excludes = ['jdk.internal.*']
}

// Gradle's Test task prints nothing per-test by default, which makes a
// long run indistinguishable from a hung one -- you get the task name and
// then silence for ~15 minutes. Android Studio papers over this with its
// own test UI; plain Gradle and CI do not.
testLogging {
events 'failed', 'skipped'
exceptionFormat 'full' // full stack traces, not just the first line
showCauses true
showStackTraces true
// Only failures are logged per-test: at ~930 tests, logging passes too
// would bury the failures we actually want to read in CI.
}

// Per-class progress, so it is obvious the build is alive. One line per
// test class rather than per test keeps this readable.
afterSuite { desc, result ->
if (desc.parent == null) { // root suite: the run-wide summary
println "\nTest result: ${result.resultType} — " +
"${result.testCount} tests, ${result.successfulTestCount} passed, " +
"${result.failedTestCount} failed, ${result.skippedTestCount} skipped"
} else if (desc.className != null) {
// A class-level suite. Note the worker process adds its own nesting
// level, so checking parent.parent == null silently matches nothing.
def name = desc.className.tokenize('.').last()
println " ${result.resultType} ${name} (${result.testCount} tests)"
}
}
}
// Add Robolectric configuration
unitTests {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//
// Calendar Notifications Plus
// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com)
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software Foundation,
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
//

package com.github.quarck.calnotify.identitystorage

import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.github.quarck.calnotify.calendar.CalendarBackupInfo
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
* Exercises [EventIdentityDao] against real SQLite.
*
* The Robolectric test fakes the DAO, because cr-sqlite is a native library
* that cannot load under Robolectric (see
* docs/dev_completed/sqlite-mocking-robolectric.md). That leaves the actual
* Room queries unverified, which is what this covers: the schema builds, the
* primary key behaves, and the hand-written UPDATE statements do what their
* names claim.
*
* Uses a throwaway database name so it never touches the real one.
*/
@RunWith(AndroidJUnit4::class)
class EventIdentityStorageTest {

private lateinit var context: Context
private lateinit var database: EventIdentityDatabase
private lateinit var storage: EventIdentityStorage

private val backupInfo = CalendarBackupInfo(
calendarId = 6L,
accountName = "user@example.com",
accountType = "com.google",
ownerAccount = "user@example.com",
displayName = "Work",
name = "user@example.com"
)

@Before
fun setup() {
context = InstrumentationRegistry.getInstrumentation().targetContext
context.deleteDatabase(TEST_DATABASE_NAME)

database = EventIdentityDatabase.buildDatabase(context, TEST_DATABASE_NAME)
storage = EventIdentityStorage(database.eventIdentityDao())
}

@After
fun teardown() {
database.close()
context.deleteDatabase(TEST_DATABASE_NAME)
}

private fun identity(
eventId: Long = 100L,
instanceStartTime: Long = INSTANCE_START,
syncId: String? = "sync-abc",
uid: String? = null
) = EventIdentityEntity.create(
eventId = eventId,
instanceStartTime = instanceStartTime,
calendarId = 6L,
backupInfo = backupInfo,
eventSyncId = syncId,
eventUid = uid,
capturedAtTime = CAPTURED_AT
)

@Test
fun schemaBuildsAndRoundTripsEveryColumn() {
storage.put(identity(uid = "uid-xyz"))

val loaded = storage.get(100L, INSTANCE_START)

assertNotNull(loaded)
assertEquals(100L, loaded!!.eventId)
assertEquals(INSTANCE_START, loaded.instanceStartTime)
assertEquals("user@example.com", loaded.calendarAccountName)
assertEquals("com.google", loaded.calendarAccountType)
assertEquals("user@example.com", loaded.calendarOwnerAccount)
assertEquals("Work", loaded.calendarDisplayName)
assertEquals("user@example.com", loaded.calendarName)
assertEquals("sync-abc", loaded.eventSyncId)
assertEquals("uid-xyz", loaded.eventUid)
assertEquals(6L, loaded.originalCalendarId)
assertEquals(100L, loaded.originalEventId)
assertEquals(CAPTURED_AT, loaded.capturedAtTime)
assertEquals(0, loaded.resolutionAttemptCount)
}

@Test
fun nullIdentifiersPersistAsNull() {
storage.put(identity(syncId = null, uid = null))

val loaded = storage.get(100L, INSTANCE_START)

assertNull(loaded?.eventSyncId)
assertNull(loaded?.eventUid)
assertFalse(
"a never-synced event has nothing to resolve against",
loaded!!.hasUsableIdentifier()
)
}

@Test
fun primaryKeyIsEventIdPlusInstanceStart() {
// Same event, two occurrences: both rows must coexist.
storage.put(identity(instanceStartTime = INSTANCE_START))
storage.put(identity(instanceStartTime = INSTANCE_START + 86_400_000L))

assertEquals(2, storage.count())
}

@Test
fun putReplacesRatherThanFailingOnConflict() {
storage.put(identity(syncId = "first"))
storage.put(identity(syncId = "second"))

assertEquals(1, storage.count())
assertEquals("second", storage.get(100L, INSTANCE_START)?.eventSyncId)
}

@Test
fun putAllInsertsEveryRow() {
storage.putAll((1L..5L).map { identity(eventId = it) })

assertEquals(5, storage.count())
}

// --- The hand-written UPDATE statements ---

@Test
fun reKeyMovesTheRowAndLeavesOriginalEventIdAlone() {
storage.put(identity(eventId = 100L))

assertTrue(storage.reKey(100L, INSTANCE_START, 555L))

assertNull(storage.get(100L, INSTANCE_START))

val moved = storage.get(555L, INSTANCE_START)
assertNotNull("row should exist under the new id", moved)
assertEquals(555L, moved!!.eventId)
assertEquals(
"originalEventId is the staleness marker and must not move",
100L, moved.originalEventId
)
assertEquals("sync-abc", moved.eventSyncId)
}

@Test
fun recordResolutionAttemptIncrements() {
storage.put(identity())

storage.recordResolutionAttempt(100L, INSTANCE_START, 999L)
storage.recordResolutionAttempt(100L, INSTANCE_START, 1000L)

val row = storage.get(100L, INSTANCE_START)
assertEquals(2, row?.resolutionAttemptCount)
assertEquals(1000L, row?.lastResolutionAttemptTime)
}

// --- getUnresolved drives the retry pass, so its WHERE clause matters ---

@Test
fun unresolvedIncludesFreshlyCapturedRows() {
storage.putAll((1L..3L).map { identity(eventId = it) })

assertEquals(3, storage.getUnresolved().size)
}

@Test
fun unresolvedExcludesReKeyedRows() {
storage.put(identity(eventId = 100L))
storage.put(identity(eventId = 200L))
storage.reKey(100L, INSTANCE_START, 555L)

val unresolved = storage.getUnresolved()

assertEquals(1, unresolved.size)
assertEquals(200L, unresolved.first().eventId)
}

@Test
fun unresolvedExcludesRowsAtTheAttemptCap() {
storage.put(identity())
repeat(EventIdentityStorage.DEFAULT_MAX_RESOLUTION_ATTEMPTS) {
storage.recordResolutionAttempt(100L, INSTANCE_START, it.toLong())
}

assertEquals(
"a permanently unmatchable event must stop being retried",
0, storage.getUnresolved().size
)
}

@Test
fun unresolvedStillIncludesRowsBelowTheCap() {
storage.put(identity())
storage.recordResolutionAttempt(100L, INSTANCE_START, 1L)

assertEquals(1, storage.getUnresolved().size)
}

@Test
fun deleteRemovesOnlyTheTargetedRow() {
storage.put(identity(eventId = 100L))
storage.put(identity(eventId = 200L))

assertTrue(storage.delete(100L, INSTANCE_START))

assertEquals(1, storage.count())
assertNotNull(storage.get(200L, INSTANCE_START))
}

@Test
fun emptyDatabaseReadsCleanly() {
assertEquals(0, storage.count())
assertNull(storage.get(1L, 1L))
assertTrue(storage.getAll().isEmpty())
assertTrue(storage.getUnresolved().isEmpty())
}

companion object {
private const val TEST_DATABASE_NAME = "RoomEventIdentity_Test"
private const val INSTANCE_START = 1_700_000_000_000L
private const val CAPTURED_AT = 1_700_000_500_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,7 @@ class UITestFixture {
override fun deleteEvent(context: Context, eventId: Long) = false
override fun getCalendarBackupInfo(context: Context, calendarId: Long) = null
override fun findMatchingCalendarId(context: Context, backupInfo: com.github.quarck.calnotify.calendar.CalendarBackupInfo) = -1L
override fun findEventIdBySyncId(context: Context, calendarId: Long, syncId: String?, uid2445: String?) = -1L
override fun getUpcomingEventCountsByCalendar(context: Context, daysAhead: Int) = mapOf<Long, Int>()
}

Expand Down
Loading
Loading