Add phase 5: local cache, ETag tracking, three way merge, conflict siblings

The editor now always reads and writes a local working copy, so the app
opens instantly and works with no signal. Beside it sits the pristine text
from the last agreement with the server and its ETag, which exists so a
later divergence can be merged rather than guessed at.

The merge is line based and three way, and is kept free of Android types
so it runs under JVM tests: a merge that is wrong loses writing rather
than merely looking wrong. 16 tests cover it, including the case the whole
thing exists for, a box ticked on the phone while a different box was
ticked on the laptop, and the harder ones, an insertion on one side that
must not desynchronise a later edit on the other, and repeated identical
lines that must not confuse the alignment.

Alignment is by longest common subsequence rather than by line number, so
an inserted or deleted line shifts what follows instead of mismatching
everything after it.

On a real conflict the server's copy stays as the note and the local one
is written beside it as a .sync-conflict- sibling, which is the convention
already in that directory. Nothing is adopted until that copy is known to
have been written, so local text is never replaced by something that
cannot be recovered.

Since SFTPGo ignores If-Match, the precondition is enforced by comparing
the stored ETag against the one a GET returns. The ETag a PUT reports is
not the file's own, so it is read back with a HEAD rather than believed.

Sync happens on foreground and on the button, never on a timer. The date
is recomputed on each sync, so an app left open across midnight moves to
the new day's note by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
This commit is contained in:
Connor Johnstone
2026-09-09 20:42:32 -04:00
co-authored by Claude Opus 5
parent 0812b7b2e7
commit dd23ca67cd
8 changed files with 450 additions and 135 deletions
@@ -42,7 +42,7 @@ class MainActivity : ComponentActivity() {
// Sync when the user comes back to the app, and at no other time. A
// background poll would look like http-crawl-non_statics to CrowdSec.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.refresh() }
repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.sync() }
}
}
}
@@ -8,81 +8,80 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/** What a sync attempt did, so the UI can say something true about it. */
sealed interface SyncResult {
data class Loaded(val text: String, val etag: String?) : SyncResult
data class Saved(val etag: String?) : SyncResult
/** The server has no note for today yet, so the 00:01 timer has not run. */
data object Absent : SyncResult
/** Credentials were refused. Never retried: see PLAN.md section 5. */
data object Unauthorized : SyncResult
data class Failed(val reason: String) : SyncResult
sealed interface Fetched {
data class Ok(val text: String, val etag: String?) : Fetched
/** No note on the server yet, so the 00:01 rollover has not run. */
data object Absent : Fetched
/** Refused. Never retried: see PLAN.md section 5. */
data object Unauthorized : Fetched
data class Failed(val reason: String) : Fetched
}
sealed interface Pushed {
data class Ok(val etag: String?) : Pushed
data object Unauthorized : Pushed
data class Failed(val reason: String) : Pushed
}
class NoteRepository(private val store: CredentialStore) {
private val client = OkHttpClient.Builder()
// Deliberately no `authenticator`: OkHttp implements that by retrying
// after a 401, which is exactly the pattern that trips http-generic-bf
// and earns a four hour ban. Credentials go on every request instead.
.build()
// Deliberately no `authenticator`: OkHttp implements that by retrying after
// a 401, which is exactly the pattern that trips http-generic-bf and earns
// a four hour ban. Credentials go on every request instead.
private val client = OkHttpClient.Builder().build()
private fun request(date: LocalDate, c: Credentials) = Request.Builder()
.url("${c.baseUrl}/$date.md")
private fun request(name: String, c: Credentials) = Request.Builder()
.url("${c.baseUrl}/$name")
.header("Authorization", BasicCredentials.basic(c.user, c.password))
.header("User-Agent", USER_AGENT)
fun fetch(date: LocalDate): SyncResult {
fun fetch(date: LocalDate): Fetched {
val c = store.load()
if (!c.configured) return SyncResult.Failed("not configured")
if (!c.configured) return Fetched.Failed("not configured")
return try {
client.newCall(request(date, c).get().build()).execute().use { r ->
client.newCall(request("$date.md", c).get().build()).execute().use { r ->
when {
r.isSuccessful -> SyncResult.Loaded(r.body?.string().orEmpty(), r.header("ETag"))
r.code == 401 -> SyncResult.Unauthorized
r.isSuccessful -> Fetched.Ok(r.body?.string().orEmpty(), r.header("ETag"))
r.code == 401 -> Fetched.Unauthorized
// One create, never a poll loop: repeated 404s trip http-probing.
r.code == 404 -> SyncResult.Absent
else -> SyncResult.Failed("server said ${r.code}")
r.code == 404 -> Fetched.Absent
else -> Fetched.Failed("server said ${r.code}")
}
}
} catch (e: IOException) {
SyncResult.Failed(e.message ?: "offline")
Fetched.Failed(e.message ?: "offline")
}
}
/**
* Writes the note. SFTPGo returns ETags but ignores If-Match on PUT, so the
* precondition cannot be delegated to the server; [expectedEtag] is checked
* with a HEAD first. See FINDINGS.md.
* Writes [name] and reports the ETag the file actually ends up with.
*
* The ETag a PUT returns is not the file's own, so it is read back with a
* HEAD rather than believed. See FINDINGS.md.
*/
fun save(date: LocalDate, text: String, expectedEtag: String?): SyncResult {
fun push(name: String, text: String): Pushed {
val c = store.load()
if (!c.configured) return SyncResult.Failed("not configured")
if (!c.configured) return Pushed.Failed("not configured")
return try {
if (expectedEtag != null) {
client.newCall(request(date, c).head().build()).execute().use { r ->
if (r.code == 401) return SyncResult.Unauthorized
val current = r.header("ETag")
if (r.isSuccessful && current != null && current != expectedEtag) {
return SyncResult.Failed("changed on the server, not overwritten")
}
}
}
val body = text.toRequestBody("text/markdown".toMediaType())
client.newCall(request(date, c).put(body).build()).execute().use { r ->
client.newCall(request(name, c).put(body).build()).execute().use { r ->
when {
// The ETag a PUT returns is not the file's, so it is not
// recorded here; the next fetch reads the real one.
r.isSuccessful -> SyncResult.Saved(null)
r.code == 401 -> SyncResult.Unauthorized
else -> SyncResult.Failed("server said ${r.code}")
r.isSuccessful -> Pushed.Ok(head(name, c))
r.code == 401 -> Pushed.Unauthorized
else -> Pushed.Failed("server said ${r.code}")
}
}
} catch (e: IOException) {
SyncResult.Failed(e.message ?: "offline")
Pushed.Failed(e.message ?: "offline")
}
}
private fun head(name: String, c: Credentials): String? = try {
client.newCall(request(name, c).head().build()).execute().use { it.header("ETag") }
} catch (e: IOException) {
null
}
private companion object {
// A named agent makes these requests legible in the CrowdSec access log.
const val USER_AGENT = "todo-android/0.1"
@@ -0,0 +1,45 @@
package com.example.todo.data
import android.content.Context
import java.io.File
import java.time.LocalDate
/**
* The local copy of a note, and the copy it was last in agreement with.
*
* The editor always reads and writes [working], so the app opens instantly and
* works with no signal. [base] is the pristine text from the last successful
* sync together with its ETag, and exists purely so a later divergence can be
* merged three ways rather than guessed at.
*
* App private internal storage, so no storage permission is involved and
* scoped storage never enters the picture.
*/
class NoteStore(context: Context) {
private val working = File(context.filesDir, "notes").apply { mkdirs() }
private val bases = File(context.filesDir, "base").apply { mkdirs() }
fun readWorking(date: LocalDate): String? =
File(working, "$date.md").takeIf { it.isFile }?.readText()
fun writeWorking(date: LocalDate, text: String) {
runCatching { File(working, "$date.md").writeText(text) }
}
fun readBase(date: LocalDate): Base? {
val text = File(bases, "$date.md").takeIf { it.isFile }?.readText() ?: return null
val etag = File(bases, "$date.etag").takeIf { it.isFile }?.readText()
return Base(text, etag)
}
fun writeBase(date: LocalDate, text: String, etag: String?) {
runCatching {
File(bases, "$date.md").writeText(text)
if (etag == null) File(bases, "$date.etag").delete()
else File(bases, "$date.etag").writeText(etag)
}
}
data class Base(val text: String, val etag: String?)
}
@@ -0,0 +1,105 @@
package com.example.todo.editor
/**
* Line based three way merge.
*
* The case this exists for is mundane: a box ticked on the phone while a
* different box was ticked on the laptop. Those are edits to different lines,
* and a three way merge takes both without asking anyone anything. Only an
* edit to the same line on both sides is a real conflict.
*
* Kept free of Android types so it can be tested on the JVM, because a merge
* that is wrong loses writing rather than merely looking wrong.
*/
sealed interface MergeResult {
data class Clean(val text: String) : MergeResult
/** Both sides changed the same lines differently. */
data object Conflict : MergeResult
}
fun merge3(base: String, mine: String, theirs: String): MergeResult {
if (mine == theirs) return MergeResult.Clean(mine)
if (base == mine) return MergeResult.Clean(theirs)
if (base == theirs) return MergeResult.Clean(mine)
val b = base.split("\n")
val m = mine.split("\n")
val t = theirs.split("\n")
val toMine = alignment(b, m)
val toTheirs = alignment(b, t)
val out = mutableListOf<String>()
var pb = 0
var pm = 0
var pt = 0
for (i in b.indices) {
val mi = toMine[i]
val ti = toTheirs[i]
// A usable sync point is a base line both sides still have, positioned
// after everything already emitted.
if (mi < pm || ti < pt) continue
if (!resolve(b.subList(pb, i), m.subList(pm, mi), t.subList(pt, ti), out)) {
return MergeResult.Conflict
}
out += b[i]
pb = i + 1
pm = mi + 1
pt = ti + 1
}
if (!resolve(b.subList(pb, b.size), m.subList(pm, m.size), t.subList(pt, t.size), out)) {
return MergeResult.Conflict
}
return MergeResult.Clean(out.joinToString("\n"))
}
/** Appends the winning version of one contested region, or reports a clash. */
private fun resolve(
base: List<String>,
mine: List<String>,
theirs: List<String>,
out: MutableList<String>,
): Boolean {
when {
mine == theirs -> out += mine
mine == base -> out += theirs
theirs == base -> out += mine
else -> return false
}
return true
}
/**
* For each line of [a], the index of the line in [b] it survives as, or -1.
*
* Built from a longest common subsequence, so the pairing keeps its order and
* an inserted or deleted line shifts the ones after it rather than mismatching
* everything that follows.
*/
private fun alignment(a: List<String>, b: List<String>): IntArray {
val n = a.size
val k = b.size
val lcs = Array(n + 1) { IntArray(k + 1) }
for (i in n - 1 downTo 0) {
for (j in k - 1 downTo 0) {
lcs[i][j] = if (a[i] == b[j]) {
lcs[i + 1][j + 1] + 1
} else {
maxOf(lcs[i + 1][j], lcs[i][j + 1])
}
}
}
val map = IntArray(n) { -1 }
var i = 0
var j = 0
while (i < n && j < k) {
when {
a[i] == b[j] -> { map[i] = j; i++; j++ }
lcs[i + 1][j] >= lcs[i][j + 1] -> i++
else -> j++
}
}
return map
}
@@ -1,24 +0,0 @@
package com.example.todo.ui.note
import android.content.Context
import java.io.File
import java.time.LocalDate
/**
* Unsaved text, kept in app-private internal storage.
*
* Not the offline cache from PLAN.md phase 5, just enough that Android killing
* the process does not discard what was typed. Internal storage means no
* storage permission and no interaction with scoped storage.
*/
class DraftStore(context: Context) {
private val dir = File(context.filesDir, "drafts").apply { mkdirs() }
private fun file(date: LocalDate) = File(dir, "$date.md")
fun read(date: LocalDate): String? = file(date).takeIf { it.isFile }?.readText()
fun write(date: LocalDate, text: String) = runCatching { file(date).writeText(text) }.let {}
fun clear(date: LocalDate) = runCatching { file(date).delete() }.let {}
}
@@ -107,7 +107,7 @@ fun NoteScreen(viewModel: NoteViewModel = viewModel(), modifier: Modifier = Modi
style = MaterialTheme.typography.bodySmall,
)
}
Button(onClick = viewModel::save, enabled = !state.busy && state.dirty) { Text("Save") }
Button(onClick = viewModel::sync, enabled = !state.busy && state.dirty) { Text("Save") }
}
BasicTextField(
@@ -1,13 +1,19 @@
package com.example.todo.ui.note
import android.app.Application
import android.os.Build
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.todo.data.CredentialStore
import com.example.todo.data.Credentials
import com.example.todo.data.Fetched
import com.example.todo.data.NoteRepository
import com.example.todo.data.SyncResult
import com.example.todo.data.NoteStore
import com.example.todo.data.Pushed
import com.example.todo.editor.MergeResult
import com.example.todo.editor.merge3
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -26,97 +32,160 @@ data class NoteUiState(
class NoteViewModel(app: Application) : AndroidViewModel(app) {
private val store = CredentialStore(app)
private val repo = NoteRepository(store)
private val drafts = DraftStore(app)
private val credentials = CredentialStore(app)
private val repo = NoteRepository(credentials)
private val notes = NoteStore(app)
private val _state = MutableStateFlow(NoteUiState())
val state = _state.asStateFlow()
private var etag: String? = null
init {
val today = LocalDate.now()
// A draft from a previous process is shown immediately, so an edit is
// never waiting on the network to become visible again.
val draft = drafts.read(today)
load(LocalDate.now())
if (!_state.value.needsSetup) sync()
}
/** Shows what is on disk at once, so the editor never waits on the network. */
private fun load(date: LocalDate) {
val base = notes.readBase(date)
val text = notes.readWorking(date) ?: base?.text.orEmpty()
_state.update {
it.copy(
text = draft.orEmpty(),
date = today,
dirty = draft != null,
needsSetup = !store.load().configured,
text = text,
date = date,
dirty = text != base?.text,
needsSetup = !credentials.load().configured,
status = "",
)
}
if (!_state.value.needsSetup) refresh()
}
fun onTextChange(new: String) {
_state.update { it.copy(text = new, dirty = true) }
// The process can be killed at any moment, so typing is persisted as it
// happens rather than held only in memory.
drafts.write(_state.value.date, new)
val date = _state.value.date
// The process can be killed at any moment, so typing goes to disk as it
// happens rather than living only in memory.
notes.writeWorking(date, new)
_state.update { it.copy(text = new, dirty = new != notes.readBase(date)?.text) }
}
/** Called on foreground. Never on a timer: no background polling. */
fun refresh() {
if (_state.value.busy) return
/**
* Reconciles with the server. Called when the app comes to the foreground
* and from the save button, and at no other time: a background poll would
* read as http-crawl-non_statics.
*/
fun sync() {
if (_state.value.busy || _state.value.needsSetup) return
// The app may have been open across midnight, and the note it should be
// showing is whatever today is now.
val today = LocalDate.now()
if (today != _state.value.date) load(today)
_state.update { it.copy(busy = true, status = "syncing") }
viewModelScope.launch {
val date = _state.value.date
val r = withContext(Dispatchers.IO) { repo.fetch(date) }
_state.update { s ->
when (r) {
is SyncResult.Loaded -> {
etag = r.etag
if (s.dirty && s.text != r.text) {
s.copy(busy = false, status = "local edits kept, server differs")
} else {
drafts.clear(date)
s.copy(text = r.text, busy = false, dirty = false, status = "synced")
}
when (val fetched = withContext(Dispatchers.IO) { repo.fetch(date) }) {
is Fetched.Ok -> reconcile(date, fetched.text, fetched.etag)
// Create once. Repeated requests for a missing file look like probing.
Fetched.Absent ->
if (_state.value.text.isNotBlank()) push(date, _state.value.text)
else finish("nothing on the server for today yet")
Fetched.Unauthorized ->
_state.update {
it.copy(busy = false, status = "credentials rejected", needsSetup = true)
}
SyncResult.Absent ->
s.copy(busy = false, status = "nothing on the server for today yet")
SyncResult.Unauthorized ->
s.copy(busy = false, status = "credentials rejected", needsSetup = true)
is SyncResult.Failed -> s.copy(busy = false, status = r.reason)
is SyncResult.Saved -> s.copy(busy = false)
}
is Fetched.Failed -> finish(fetched.reason)
}
}
}
fun save() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, status = "saving") }
viewModelScope.launch {
val date = _state.value.date
val text = _state.value.text
val r = withContext(Dispatchers.IO) { repo.save(date, text, etag) }
_state.update { s ->
when (r) {
is SyncResult.Saved -> {
drafts.clear(date)
s.copy(busy = false, dirty = false, status = "saved")
}
SyncResult.Unauthorized ->
s.copy(busy = false, status = "credentials rejected", needsSetup = true)
is SyncResult.Failed -> s.copy(busy = false, status = r.reason)
else -> s.copy(busy = false)
}
private suspend fun reconcile(date: LocalDate, theirs: String, theirEtag: String?) {
val mine = _state.value.text
val base = notes.readBase(date)
if (base == null) {
// Nothing to merge against. Anything local that is not already the
// server's text is set aside rather than silently dropped.
if (mine.isNotEmpty() && mine != theirs) {
val name = setAside(date, mine) ?: return
adopt(date, theirs, theirEtag, "server copy taken, yours saved as $name")
} else {
adopt(date, theirs, theirEtag, "synced")
}
return
}
if (theirEtag != null && theirEtag == base.etag) {
// The server has not moved since the last agreement, so local edits
// are the only news there is.
if (mine != base.text) push(date, mine) else finish("synced")
return
}
when (val merged = merge3(base.text, mine, theirs)) {
is MergeResult.Clean ->
if (merged.text == theirs) adopt(date, theirs, theirEtag, "synced")
else push(date, merged.text)
// Both sides changed the same lines. Keep the server's copy as the
// note and put the local one beside it, which is the convention
// already in this directory.
MergeResult.Conflict -> {
val name = setAside(date, mine) ?: return
adopt(date, theirs, theirEtag, "conflict, yours saved as $name")
}
// The ETag a PUT reports is not the one the file will report, so the
// authoritative value has to be read back.
if (r is SyncResult.Saved) etag = null
}
}
private suspend fun push(date: LocalDate, text: String) {
when (val r = withContext(Dispatchers.IO) { repo.push("$date.md", text) }) {
is Pushed.Ok -> adopt(date, text, r.etag, "synced")
Pushed.Unauthorized -> _state.update {
it.copy(busy = false, status = "credentials rejected", needsSetup = true)
}
// Local text stays exactly as it is and goes again next foreground.
is Pushed.Failed -> finish(r.reason)
}
}
/** Writes the local text beside the note, returning the filename it used. */
private suspend fun setAside(date: LocalDate, mine: String): String? {
val name = "$date.sync-conflict-${STAMP.format(java.time.LocalDateTime.now())}-$DEVICE.md"
return when (val r = withContext(Dispatchers.IO) { repo.push(name, mine) }) {
is Pushed.Ok -> name
Pushed.Unauthorized -> {
_state.update {
it.copy(busy = false, status = "credentials rejected", needsSetup = true)
}
null
}
// Nothing is adopted if the copy could not be saved, so the local
// text is never replaced by something it cannot be recovered from.
is Pushed.Failed -> {
finish("could not save your copy: ${r.reason}")
null
}
}
}
private fun adopt(date: LocalDate, text: String, etag: String?, status: String) {
notes.writeBase(date, text, etag)
notes.writeWorking(date, text)
_state.update { it.copy(text = text, busy = false, dirty = false, status = status) }
}
private fun finish(status: String) = _state.update { it.copy(busy = false, status = status) }
fun saveCredentials(baseUrl: String, user: String, password: String) {
store.save(Credentials(baseUrl, user, password))
credentials.save(Credentials(baseUrl, user, password))
_state.update { it.copy(needsSetup = false) }
refresh()
sync()
}
fun credentials() = store.load()
fun credentials() = credentials.load()
private companion object {
val STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
val DEVICE: String = Build.MODEL.uppercase().replace(Regex("[^A-Z0-9]"), "")
.ifEmpty { "ANDROID" }
}
}
@@ -0,0 +1,121 @@
package com.example.todo.editor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MergeTest {
private fun clean(base: String, mine: String, theirs: String): String {
val r = merge3(base, mine, theirs)
assertTrue("expected a clean merge, got $r", r is MergeResult.Clean)
return (r as MergeResult.Clean).text
}
private fun conflicts(base: String, mine: String, theirs: String) =
assertTrue(merge3(base, mine, theirs) is MergeResult.Conflict)
// The case this whole thing exists for.
@Test fun `boxes ticked on different lines both survive`() {
val base = "- [ ] a\n- [ ] b\n- [ ] c"
val mine = "- [x] a\n- [ ] b\n- [ ] c"
val theirs = "- [ ] a\n- [ ] b\n- [x] c"
assertEquals("- [x] a\n- [ ] b\n- [x] c", clean(base, mine, theirs))
}
@Test fun `the same box ticked on both sides is not a conflict`() {
val base = "- [ ] a\n- [ ] b"
val both = "- [x] a\n- [ ] b"
assertEquals(both, clean(base, both, both))
}
@Test fun `the same line changed differently is a conflict`() {
conflicts("- [ ] a", "- [x] a", "- [ ] a renamed")
}
@Test fun `no local change takes the server copy`() {
val base = "- [ ] a"
assertEquals("- [ ] a\n- [ ] new", clean(base, base, "- [ ] a\n- [ ] new"))
}
@Test fun `no server change keeps the local copy`() {
val base = "- [ ] a"
assertEquals("- [x] a", clean(base, "- [x] a", base))
}
@Test fun `additions at opposite ends both survive`() {
val base = "b\nc"
assertEquals("a\nb\nc\nd", clean(base, "a\nb\nc", "b\nc\nd"))
}
@Test fun `an insertion does not desynchronise later lines`() {
val base = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d"
// A line added near the top on one side, a box ticked at the bottom on
// the other. Aligning by position alone would mangle this.
val mine = "- [ ] a\n- [ ] NEW\n- [ ] b\n- [ ] c\n- [ ] d"
val theirs = "- [ ] a\n- [ ] b\n- [ ] c\n- [x] d"
assertEquals("- [ ] a\n- [ ] NEW\n- [ ] b\n- [ ] c\n- [x] d", clean(base, mine, theirs))
}
@Test fun `a deletion on one side is kept`() {
val base = "a\nb\nc"
assertEquals("a\nc", clean(base, "a\nc", base))
}
@Test fun `deleting on one side while editing that line on the other conflicts`() {
conflicts("a\nb\nc", "a\nc", "a\nB\nc")
}
@Test fun `both sides deleting the same line agree`() {
assertEquals("a\nc", clean("a\nb\nc", "a\nc", "a\nc"))
}
@Test fun `edits in separate sections both survive`() {
val base = "## Work\n- [ ] w1\n\n## Personal\n- [ ] p1"
val mine = "## Work\n- [x] w1\n\n## Personal\n- [ ] p1"
val theirs = "## Work\n- [ ] w1\n\n## Personal\n- [x] p1"
assertEquals("## Work\n- [x] w1\n\n## Personal\n- [x] p1", clean(base, mine, theirs))
}
@Test fun `identical text merges to itself`() {
val s = "# 2026-09-09\n\n- [ ] a"
assertEquals(s, clean(s, s, s))
}
@Test fun `empty base with the same addition on both sides`() {
assertEquals("- [ ] a", clean("", "- [ ] a", "- [ ] a"))
}
@Test fun `trailing newlines are preserved`() {
val base = "a\nb\n"
assertEquals("a\nB\n", clean(base, "a\nB\n", base))
}
@Test fun `repeated identical lines do not confuse alignment`() {
val base = "- [ ] x\n- [ ] x\n- [ ] x"
val mine = "- [x] x\n- [ ] x\n- [ ] x"
val theirs = "- [ ] x\n- [ ] x\n- [x] x"
assertEquals("- [x] x\n- [ ] x\n- [x] x", clean(base, mine, theirs))
}
@Test fun `a realistic day of edits on both devices`() {
val base = """
# 2026-09-09
## To-Do
- [ ] Schedule an eye exam
- [ ] Fix thermal paste on my server
- [ ] Clean up dotfiles repo
""".trimIndent()
// phone: ticked one while out
val mine = base.replace("- [ ] Schedule an eye exam", "- [x] Schedule an eye exam")
// laptop: ticked another and added a line
val theirs = base
.replace("- [ ] Clean up dotfiles repo", "- [x] Clean up dotfiles repo")
.plus("\n- [ ] Book flights")
val merged = clean(base, mine, theirs)
assertTrue(merged.contains("- [x] Schedule an eye exam"))
assertTrue(merged.contains("- [x] Clean up dotfiles repo"))
assertTrue(merged.contains("- [ ] Book flights"))
}
}