mirror of
https://github.com/avinal/nikki.git
synced 2026-07-03 21:40:09 +05:30
Add task notifications, JSON backup, and comprehensive test suite
Notifications: - TaskCheckWorker (WorkManager, 15-min periodic) scans cached memos for tasks due today or overdue, fires Android notifications - TaskNotificationManager: notification channel, tap-to-open intent - Notification deduplication via SharedPreferences (notified task IDs) - Toggle in settings (on/off persisted in DataStore) - POST_NOTIFICATIONS permission requested on API 33+ JSON Backup: - BackupManager: export all memos to JSON, import from JSON - Export: saves to device via Android SAF (CreateDocument) - Import: reads JSON file, creates memos via API, refreshes cache - Backup format: version, exportedAt, memoCount, array of BackupMemo - Export/import buttons in settings with status feedback Test Suite (67 tests, all passing): - TaskParserTest (29): checkbox parsing, priorities, dates, labels, lists, stable IDs, toggle/replace, line indices, edge cases - DtoMappersTest (16): ID extraction, timestamps, visibility, properties, attachments, reactions, comment count from relations - DtoSerializationTest (7): JSON deserialization, unknown fields, relation object refs, attachment size strings - BackupManagerTest (7): export/import round-trip, version, memo count, invalid JSON handling - MemoVisibilityTest (5): fromApiString, toApiString, round-trip - ApiResultTest (3): Success, Error, NetworkError sealed class Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.avinal.memos
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.avinal.memos.notifications.TaskNotificationManager
|
||||
import com.avinal.memos.notifications.scheduleTaskChecker
|
||||
import com.avinal.memos.util.LocalAppDependencies
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -20,6 +27,11 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
deps.initialize()
|
||||
com.avinal.memos.util.appContext = applicationContext
|
||||
|
||||
TaskNotificationManager.createChannel(this)
|
||||
requestNotificationPermission()
|
||||
scheduleTaskChecker(applicationContext)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalAppDependencies provides deps) {
|
||||
@@ -27,4 +39,14 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ kotlin {
|
||||
compileSdk = 36
|
||||
minSdk = 26
|
||||
|
||||
withHostTest {}
|
||||
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
@@ -68,6 +70,11 @@ kotlin {
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.avinal.memos.db.MemosDatabase
|
||||
import com.avinal.memos.db.entity.toDomain
|
||||
import com.avinal.memos.parser.TaskParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
class TaskCheckWorker(
|
||||
private val context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val prefs = context.getSharedPreferences("task_notifications", Context.MODE_PRIVATE)
|
||||
val notificationsEnabled = prefs.getBoolean("enabled", true)
|
||||
if (!notificationsEnabled) return Result.success()
|
||||
|
||||
val notifiedIds = prefs.getStringSet("notified_ids", emptySet()) ?: emptySet()
|
||||
val today = kotlin.time.Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
|
||||
val db = Room.databaseBuilder<MemosDatabase>(
|
||||
context = context,
|
||||
name = context.getDatabasePath("memos.db").absolutePath,
|
||||
)
|
||||
.fallbackToDestructiveMigration(true)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
.build()
|
||||
|
||||
try {
|
||||
val memos = db.memoDao().getAll().map { it.toDomain() }
|
||||
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
|
||||
|
||||
val dueTasks = allTasks.filter { task ->
|
||||
!task.isCompleted &&
|
||||
task.dueDate != null &&
|
||||
task.dueDate <= today &&
|
||||
task.id !in notifiedIds
|
||||
}
|
||||
|
||||
val newNotifiedIds = notifiedIds.toMutableSet()
|
||||
|
||||
dueTasks.forEach { task ->
|
||||
val dueLabel = when {
|
||||
task.dueDate!! < today -> "overdue"
|
||||
else -> "due today"
|
||||
}
|
||||
val priority = task.priority?.let { " p$it" } ?: ""
|
||||
TaskNotificationManager.showTaskNotification(
|
||||
context = context,
|
||||
notificationId = task.id.hashCode(),
|
||||
taskText = task.text,
|
||||
dueLabel = "$dueLabel$priority",
|
||||
)
|
||||
newNotifiedIds.add(task.id)
|
||||
}
|
||||
|
||||
if (newNotifiedIds.size > notifiedIds.size) {
|
||||
prefs.edit().putStringSet("notified_ids", newNotifiedIds).apply()
|
||||
}
|
||||
|
||||
// Clean up old IDs (tasks that no longer exist)
|
||||
val allTaskIds = allTasks.map { it.id }.toSet()
|
||||
val cleaned = newNotifiedIds.filter { it in allTaskIds }.toSet()
|
||||
if (cleaned.size < newNotifiedIds.size) {
|
||||
prefs.edit().putStringSet("notified_ids", cleaned).apply()
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
object TaskNotificationManager {
|
||||
|
||||
private const val CHANNEL_ID = "task_reminders"
|
||||
private const val CHANNEL_NAME = "Task Reminders"
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Reminders for tasks with due dates"
|
||||
}
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun showTaskNotification(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
taskText: String,
|
||||
dueLabel: String,
|
||||
) {
|
||||
val openIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
} ?: Intent()
|
||||
val pendingOpen = PendingIntent.getActivity(
|
||||
context, notificationId, openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_popup_reminder)
|
||||
.setContentTitle(taskText)
|
||||
.setContentText(dueLabel)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingOpen)
|
||||
.build()
|
||||
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notification)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun scheduleTaskChecker(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<TaskCheckWorker>(15, TimeUnit.MINUTES).build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
"task_check",
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.avinal.memos.util
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileSaver(onSaved: (Boolean) -> Unit): (String, String) -> Unit {
|
||||
val context = LocalContext.current
|
||||
var pendingContent: String? = null
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||
) { uri: Uri? ->
|
||||
if (uri != null && pendingContent != null) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { stream ->
|
||||
stream.write(pendingContent!!.toByteArray())
|
||||
}
|
||||
onSaved(true)
|
||||
} catch (_: Exception) {
|
||||
onSaved(false)
|
||||
}
|
||||
}
|
||||
pendingContent = null
|
||||
}
|
||||
|
||||
return { filename: String, content: String ->
|
||||
pendingContent = content
|
||||
launcher.launch(filename)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileLoader(onLoaded: (String) -> Unit): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
) { uri: Uri? ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
try {
|
||||
val text = context.contentResolver.openInputStream(uri)?.use { it.bufferedReader().readText() }
|
||||
if (text != null) onLoaded(text)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return { launcher.launch("application/json") }
|
||||
}
|
||||
@@ -124,6 +124,58 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionHeader("notifications")
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
val notificationsOn by viewModel.notificationsEnabled.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { viewModel.setNotificationsEnabled(!notificationsOn) }.padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("task reminders", fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(
|
||||
if (notificationsOn) "on" else "off",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (notificationsOn) accent else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text("get notified when tasks are due or overdue", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionHeader("backup")
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
var backupStatus by remember { mutableStateOf("") }
|
||||
|
||||
val saveFile = com.avinal.memos.util.rememberFileSaver { success ->
|
||||
backupStatus = if (success) "backup exported" else "export failed"
|
||||
}
|
||||
val loadFile = com.avinal.memos.util.rememberFileLoader { json ->
|
||||
viewModel.importFromJson(json) { count ->
|
||||
backupStatus = if (count >= 0) "$count memos imported" else "invalid backup file"
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"export backup",
|
||||
fontSize = 15.sp, color = accent,
|
||||
modifier = Modifier.clickable {
|
||||
viewModel.getExportJson { json ->
|
||||
saveFile("memos-backup.json", json)
|
||||
}
|
||||
}.padding(vertical = 6.dp),
|
||||
)
|
||||
Text(
|
||||
"import backup",
|
||||
fontSize = 15.sp, color = accent,
|
||||
modifier = Modifier.clickable { loadFile() }.padding(vertical = 6.dp),
|
||||
)
|
||||
if (backupStatus.isNotEmpty()) {
|
||||
Text(backupStatus, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(36.dp))
|
||||
SectionHeader("about")
|
||||
SettingsItem("version", "1.0.0")
|
||||
|
||||
@@ -3,18 +3,22 @@ package com.avinal.memos.ui.settings
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.avinal.memos.domain.AuthRepository
|
||||
import com.avinal.memos.domain.Memo
|
||||
import com.avinal.memos.domain.MemoRepository
|
||||
import com.avinal.memos.domain.MemoVisibility
|
||||
import com.avinal.memos.domain.User
|
||||
import com.avinal.memos.ui.theme.MetroTheme
|
||||
import com.avinal.memos.util.BackupManager
|
||||
import com.avinal.memos.util.TokenStore
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
private val tokenStore: TokenStore,
|
||||
val tokenStore: TokenStore,
|
||||
private val memoRepository: MemoRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -29,6 +33,9 @@ class SettingsViewModel(
|
||||
val currentAccent: StateFlow<String> = tokenStore.accentColor
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Cobalt")
|
||||
|
||||
val notificationsEnabled: StateFlow<Boolean> = tokenStore.notificationsEnabled
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { authRepository.validateToken() }
|
||||
}
|
||||
@@ -41,6 +48,33 @@ class SettingsViewModel(
|
||||
viewModelScope.launch { tokenStore.saveAccentColor(name) }
|
||||
}
|
||||
|
||||
fun setNotificationsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { tokenStore.saveNotificationsEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun getExportJson(onResult: (String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val memos = memoRepository.observeMemos().first()
|
||||
onResult(BackupManager.exportToJson(memos))
|
||||
}
|
||||
}
|
||||
|
||||
fun importFromJson(json: String, onResult: (Int) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backup = BackupManager.parseFromJson(json)
|
||||
if (backup == null) { onResult(-1); return@launch }
|
||||
|
||||
var imported = 0
|
||||
backup.memos.forEach { backupMemo ->
|
||||
val visibility = MemoVisibility.fromApiString(backupMemo.visibility)
|
||||
val result = memoRepository.createMemo(backupMemo.content, visibility)
|
||||
if (result is com.avinal.memos.api.ApiResult.Success) imported++
|
||||
}
|
||||
memoRepository.refreshMemos()
|
||||
onResult(imported)
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
memoRepository.clearCache()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.avinal.memos.util
|
||||
|
||||
import com.avinal.memos.domain.Memo
|
||||
import com.avinal.memos.domain.MemoVisibility
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val backupJson = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BackupData(
|
||||
val version: Int = 1,
|
||||
val exportedAt: String = "",
|
||||
val memoCount: Int = 0,
|
||||
val memos: List<BackupMemo> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupMemo(
|
||||
val content: String,
|
||||
val visibility: String = "PRIVATE",
|
||||
val pinned: Boolean = false,
|
||||
val tags: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
object BackupManager {
|
||||
|
||||
fun exportToJson(memos: List<Memo>): String {
|
||||
val now = kotlin.time.Clock.System.now().toString()
|
||||
val data = BackupData(
|
||||
version = 1,
|
||||
exportedAt = now,
|
||||
memoCount = memos.size,
|
||||
memos = memos.map { memo ->
|
||||
BackupMemo(
|
||||
content = memo.content,
|
||||
visibility = memo.visibility.toApiString(),
|
||||
pinned = memo.pinned,
|
||||
tags = memo.tags,
|
||||
)
|
||||
},
|
||||
)
|
||||
return backupJson.encodeToString(BackupData.serializer(), data)
|
||||
}
|
||||
|
||||
fun parseFromJson(json: String): BackupData? {
|
||||
return try {
|
||||
backupJson.decodeFromString(BackupData.serializer(), json)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.avinal.memos.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
expect fun rememberFileSaver(onSaved: (Boolean) -> Unit): (String, String) -> Unit
|
||||
|
||||
@Composable
|
||||
expect fun rememberFileLoader(onLoaded: (String) -> Unit): () -> Unit
|
||||
@@ -13,6 +13,7 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
||||
val accessToken: Flow<String?> = dataStore.data.map { it[KEY_ACCESS_TOKEN] }
|
||||
val theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" }
|
||||
val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" }
|
||||
val notificationsEnabled: Flow<Boolean> = dataStore.data.map { (it[KEY_NOTIFICATIONS] ?: "true") == "true" }
|
||||
|
||||
suspend fun saveCredentials(serverUrl: String, token: String) {
|
||||
dataStore.edit { prefs ->
|
||||
@@ -29,6 +30,10 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
||||
dataStore.edit { it[KEY_ACCENT] = name }
|
||||
}
|
||||
|
||||
suspend fun saveNotificationsEnabled(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_NOTIFICATIONS] = enabled.toString() }
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
dataStore.edit {
|
||||
val theme = it[KEY_THEME]
|
||||
@@ -44,5 +49,6 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
||||
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||
private val KEY_THEME = stringPreferencesKey("app_theme")
|
||||
private val KEY_ACCENT = stringPreferencesKey("accent_color")
|
||||
private val KEY_NOTIFICATIONS = stringPreferencesKey("notifications_enabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.avinal.memos
|
||||
|
||||
import com.avinal.memos.api.ApiResult
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ApiResultTest {
|
||||
|
||||
@Test
|
||||
fun successHoldsData() {
|
||||
val result: ApiResult<String> = ApiResult.Success("hello")
|
||||
assertIs<ApiResult.Success<String>>(result)
|
||||
assertEquals("hello", result.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorHoldsCodeAndMessage() {
|
||||
val result: ApiResult<String> = ApiResult.Error(404, "not found")
|
||||
assertIs<ApiResult.Error>(result)
|
||||
assertEquals(404, result.code)
|
||||
assertEquals("not found", result.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkErrorHoldsException() {
|
||||
val ex = RuntimeException("timeout")
|
||||
val result: ApiResult<String> = ApiResult.NetworkError(ex)
|
||||
assertIs<ApiResult.NetworkError>(result)
|
||||
assertEquals("timeout", result.exception.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.avinal.memos
|
||||
|
||||
import com.avinal.memos.domain.Attachment
|
||||
import com.avinal.memos.domain.Memo
|
||||
import com.avinal.memos.domain.MemoVisibility
|
||||
import com.avinal.memos.domain.Reaction
|
||||
import com.avinal.memos.util.BackupManager
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
class BackupManagerTest {
|
||||
|
||||
private fun makeMemo(
|
||||
id: String = "m1",
|
||||
content: String = "hello",
|
||||
visibility: MemoVisibility = MemoVisibility.PRIVATE,
|
||||
pinned: Boolean = false,
|
||||
tags: List<String> = emptyList(),
|
||||
) = Memo(
|
||||
id = id, uid = "", content = content, visibility = visibility,
|
||||
pinned = pinned, state = "NORMAL",
|
||||
createTime = Instant.DISTANT_PAST, updateTime = Instant.DISTANT_PAST,
|
||||
displayTime = Instant.DISTANT_PAST, creator = "test",
|
||||
hasTaskList = false, hasIncompleteTasks = false, title = "",
|
||||
tags = tags, snippet = "",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun exportProducesValidJson() {
|
||||
val memos = listOf(makeMemo())
|
||||
val json = BackupManager.exportToJson(memos)
|
||||
assertNotNull(json)
|
||||
assertTrue(json.isNotEmpty(), "json should not be empty")
|
||||
val parsed = BackupManager.parseFromJson(json)
|
||||
assertNotNull(parsed, "should be parseable back")
|
||||
assertEquals(1, parsed.memos.size)
|
||||
assertEquals("hello", parsed.memos[0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportImportRoundTrip() {
|
||||
val original = listOf(
|
||||
makeMemo(id = "1", content = "First memo", tags = listOf("work")),
|
||||
makeMemo(id = "2", content = "Second memo", visibility = MemoVisibility.PUBLIC, pinned = true),
|
||||
)
|
||||
val json = BackupManager.exportToJson(original)
|
||||
val parsed = BackupManager.parseFromJson(json)
|
||||
assertNotNull(parsed)
|
||||
assertEquals(2, parsed.memos.size)
|
||||
assertEquals("First memo", parsed.memos[0].content)
|
||||
assertEquals("Second memo", parsed.memos[1].content)
|
||||
assertEquals("PRIVATE", parsed.memos[0].visibility)
|
||||
assertEquals("PUBLIC", parsed.memos[1].visibility)
|
||||
assertTrue(parsed.memos[1].pinned)
|
||||
assertEquals(listOf("work"), parsed.memos[0].tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportSetsVersion() {
|
||||
val json = BackupManager.exportToJson(emptyList())
|
||||
val parsed = BackupManager.parseFromJson(json)
|
||||
assertNotNull(parsed)
|
||||
assertEquals(1, parsed.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportSetsMemoCount() {
|
||||
val json = BackupManager.exportToJson(listOf(makeMemo(), makeMemo(id = "2")))
|
||||
val parsed = BackupManager.parseFromJson(json)
|
||||
assertEquals(2, parsed!!.memoCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvalidJsonReturnsNull() {
|
||||
assertNull(BackupManager.parseFromJson("not valid json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseEmptyObjectReturnsDefaults() {
|
||||
val parsed = BackupManager.parseFromJson("{}")
|
||||
assertNotNull(parsed)
|
||||
assertEquals(0, parsed.memos.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportedAtIsNonEmpty() {
|
||||
val json = BackupManager.exportToJson(emptyList())
|
||||
val parsed = BackupManager.parseFromJson(json)
|
||||
assertTrue(parsed!!.exportedAt.isNotEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.avinal.memos
|
||||
|
||||
import com.avinal.memos.api.model.AttachmentDto
|
||||
import com.avinal.memos.api.model.MemoDto
|
||||
import com.avinal.memos.api.model.MemoPropertyDto
|
||||
import com.avinal.memos.api.model.ReactionDto
|
||||
import com.avinal.memos.api.model.RelationDto
|
||||
import com.avinal.memos.api.model.RelationRefDto
|
||||
import com.avinal.memos.api.model.UserDto
|
||||
import com.avinal.memos.api.model.toDomain
|
||||
import com.avinal.memos.domain.MemoVisibility
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DtoMappersTest {
|
||||
|
||||
@Test
|
||||
fun memoIdExtractedFromName() {
|
||||
val dto = MemoDto(name = "memos/abc123def")
|
||||
val memo = dto.toDomain()
|
||||
assertEquals("abc123def", memo.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun creatorIdExtractedFromName() {
|
||||
val dto = MemoDto(creator = "users/avinal")
|
||||
val memo = dto.toDomain()
|
||||
assertEquals("avinal", memo.creator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun visibilityMapped() {
|
||||
assertEquals(MemoVisibility.PUBLIC, MemoDto(visibility = "PUBLIC").toDomain().visibility)
|
||||
assertEquals(MemoVisibility.PRIVATE, MemoDto(visibility = "PRIVATE").toDomain().visibility)
|
||||
assertEquals(MemoVisibility.PROTECTED, MemoDto(visibility = "PROTECTED").toDomain().visibility)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timestampsParsed() {
|
||||
val dto = MemoDto(
|
||||
createTime = "2026-05-13T09:10:31Z",
|
||||
updateTime = "2026-05-13T09:10:31Z",
|
||||
)
|
||||
val memo = dto.toDomain()
|
||||
assertTrue(memo.createTime.toEpochMilliseconds() > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTimestampHandled() {
|
||||
val dto = MemoDto(createTime = "", updateTime = "")
|
||||
val memo = dto.toDomain()
|
||||
assertEquals(kotlin.time.Instant.DISTANT_PAST, memo.createTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidTimestampHandled() {
|
||||
val dto = MemoDto(createTime = "not-a-date")
|
||||
val memo = dto.toDomain()
|
||||
assertEquals(kotlin.time.Instant.DISTANT_PAST, memo.createTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun propertyFieldsMapped() {
|
||||
val dto = MemoDto(property = MemoPropertyDto(hasTaskList = true, hasIncompleteTasks = true, title = "My Title"))
|
||||
val memo = dto.toDomain()
|
||||
assertTrue(memo.hasTaskList)
|
||||
assertTrue(memo.hasIncompleteTasks)
|
||||
assertEquals("My Title", memo.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullPropertyHandled() {
|
||||
val dto = MemoDto(property = null)
|
||||
val memo = dto.toDomain()
|
||||
assertFalse(memo.hasTaskList)
|
||||
assertEquals("", memo.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tagsMapped() {
|
||||
val dto = MemoDto(tags = listOf("work", "urgent"))
|
||||
val memo = dto.toDomain()
|
||||
assertEquals(listOf("work", "urgent"), memo.tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachmentsMapped() {
|
||||
val dto = MemoDto(attachments = listOf(
|
||||
AttachmentDto(name = "attachments/abc", filename = "img.jpg", type = "image/jpeg", size = "1024")
|
||||
))
|
||||
val memo = dto.toDomain()
|
||||
assertEquals(1, memo.attachments.size)
|
||||
assertEquals("img.jpg", memo.attachments[0].filename)
|
||||
assertEquals("image/jpeg", memo.attachments[0].mimeType)
|
||||
assertTrue(memo.attachments[0].isImage)
|
||||
assertEquals(1024L, memo.attachments[0].size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonImageAttachment() {
|
||||
val dto = MemoDto(attachments = listOf(
|
||||
AttachmentDto(type = "application/pdf")
|
||||
))
|
||||
assertFalse(dto.toDomain().attachments[0].isImage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reactionsMapped() {
|
||||
val dto = MemoDto(reactions = listOf(
|
||||
ReactionDto(name = "memos/m1/reactions/1", creator = "users/avinal", reactionType = "❤️")
|
||||
))
|
||||
val memo = dto.toDomain()
|
||||
assertEquals(1, memo.reactions.size)
|
||||
assertEquals("❤️", memo.reactions[0].reactionType)
|
||||
assertEquals("avinal", memo.reactions[0].creator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commentCountFromRelations() {
|
||||
val dto = MemoDto(
|
||||
name = "memos/m1",
|
||||
relations = listOf(
|
||||
RelationDto(
|
||||
memo = RelationRefDto(name = "memos/comment1"),
|
||||
relatedMemo = RelationRefDto(name = "memos/m1"),
|
||||
type = "COMMENT",
|
||||
),
|
||||
RelationDto(
|
||||
memo = RelationRefDto(name = "memos/comment2"),
|
||||
relatedMemo = RelationRefDto(name = "memos/m1"),
|
||||
type = "COMMENT",
|
||||
),
|
||||
RelationDto(
|
||||
memo = RelationRefDto(name = "memos/m1"),
|
||||
relatedMemo = RelationRefDto(name = "memos/m2"),
|
||||
type = "REFERENCE",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(2, dto.toDomain().commentCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userDtoMapped() {
|
||||
val dto = UserDto(name = "users/avinal", username = "avinal", nickname = "Avinal", email = "a@b.com", role = "USER")
|
||||
val user = dto.toDomain()
|
||||
assertEquals("avinal", user.id)
|
||||
assertEquals("avinal", user.username)
|
||||
assertEquals("Avinal", user.nickname)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachmentSizeStringParsed() {
|
||||
val dto = AttachmentDto(size = "197065")
|
||||
assertEquals(197065L, dto.toDomain().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachmentSizeInvalidStringDefaultsToZero() {
|
||||
val dto = AttachmentDto(size = "not-a-number")
|
||||
assertEquals(0L, dto.toDomain().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.avinal.memos
|
||||
|
||||
import com.avinal.memos.api.model.ListMemosResponse
|
||||
import com.avinal.memos.api.model.MemoDto
|
||||
import com.avinal.memos.api.model.MemoPropertyDto
|
||||
import com.avinal.memos.api.model.AttachmentDto
|
||||
import com.avinal.memos.api.model.ReactionDto
|
||||
import com.avinal.memos.api.model.RelationDto
|
||||
import com.avinal.memos.api.model.RelationRefDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DtoSerializationTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun deserializeMinimalMemo() {
|
||||
val str = """{"name":"memos/abc","content":"hello"}"""
|
||||
val dto = json.decodeFromString(MemoDto.serializer(), str)
|
||||
assertEquals("memos/abc", dto.name)
|
||||
assertEquals("hello", dto.content)
|
||||
assertEquals("NORMAL", dto.state)
|
||||
assertEquals("PRIVATE", dto.visibility)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeFullMemo() {
|
||||
val str = """
|
||||
{
|
||||
"name":"memos/xyz",
|
||||
"uid":"uid1",
|
||||
"creator":"users/avinal",
|
||||
"createTime":"2026-05-13T09:10:31Z",
|
||||
"updateTime":"2026-05-13T09:10:31Z",
|
||||
"content":"# Hello",
|
||||
"state":"NORMAL",
|
||||
"visibility":"PRIVATE",
|
||||
"pinned":true,
|
||||
"tags":["work","devops"],
|
||||
"snippet":"Hello",
|
||||
"attachments":[{"name":"attachments/a1","filename":"img.jpg","type":"image/jpeg","size":"1024"}],
|
||||
"reactions":[{"name":"memos/xyz/reactions/1","creator":"users/avinal","reactionType":"❤️","createTime":"2026-05-14T13:29:20Z"}],
|
||||
"relations":[{"memo":{"name":"memos/c1","snippet":""},"relatedMemo":{"name":"memos/xyz","snippet":""},"type":"COMMENT"}],
|
||||
"property":{"hasLink":false,"hasTaskList":true,"hasCode":false,"hasIncompleteTasks":true,"title":"Hello"}
|
||||
}
|
||||
""".trimIndent()
|
||||
val dto = json.decodeFromString(MemoDto.serializer(), str)
|
||||
assertEquals("memos/xyz", dto.name)
|
||||
assertTrue(dto.pinned)
|
||||
assertEquals(listOf("work", "devops"), dto.tags)
|
||||
assertEquals(1, dto.attachments.size)
|
||||
assertEquals("image/jpeg", dto.attachments[0].type)
|
||||
assertEquals(1, dto.reactions.size)
|
||||
assertEquals("❤️", dto.reactions[0].reactionType)
|
||||
assertEquals(1, dto.relations.size)
|
||||
assertEquals("COMMENT", dto.relations[0].type)
|
||||
assertEquals("memos/c1", dto.relations[0].memo.name)
|
||||
assertTrue(dto.property!!.hasTaskList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeListMemosResponse() {
|
||||
val str = """{"memos":[{"name":"memos/1","content":"a"},{"name":"memos/2","content":"b"}],"nextPageToken":"abc123"}"""
|
||||
val response = json.decodeFromString(ListMemosResponse.serializer(), str)
|
||||
assertEquals(2, response.memos.size)
|
||||
assertEquals("abc123", response.nextPageToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeEmptyListMemosResponse() {
|
||||
val str = """{"memos":[],"nextPageToken":""}"""
|
||||
val response = json.decodeFromString(ListMemosResponse.serializer(), str)
|
||||
assertTrue(response.memos.isEmpty())
|
||||
assertEquals("", response.nextPageToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownFieldsIgnored() {
|
||||
val str = """{"name":"memos/1","content":"hello","someNewField":"value","anotherField":42}"""
|
||||
val dto = json.decodeFromString(MemoDto.serializer(), str)
|
||||
assertEquals("hello", dto.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relationWithObjectRefs() {
|
||||
val str = """{"memo":{"name":"memos/c1","snippet":"comment"},"relatedMemo":{"name":"memos/m1","snippet":"parent"},"type":"COMMENT"}"""
|
||||
val rel = json.decodeFromString(RelationDto.serializer(), str)
|
||||
assertEquals("memos/c1", rel.memo.name)
|
||||
assertEquals("memos/m1", rel.relatedMemo.name)
|
||||
assertEquals("COMMENT", rel.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachmentSizeAsString() {
|
||||
val str = """{"name":"attachments/a1","filename":"doc.pdf","type":"application/pdf","size":"999999"}"""
|
||||
val att = json.decodeFromString(AttachmentDto.serializer(), str)
|
||||
assertEquals("999999", att.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.avinal.memos
|
||||
|
||||
import com.avinal.memos.domain.MemoVisibility
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MemoVisibilityTest {
|
||||
|
||||
@Test
|
||||
fun fromApiStringPrivate() {
|
||||
assertEquals(MemoVisibility.PRIVATE, MemoVisibility.fromApiString("PRIVATE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromApiStringPublic() {
|
||||
assertEquals(MemoVisibility.PUBLIC, MemoVisibility.fromApiString("PUBLIC"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromApiStringProtected() {
|
||||
assertEquals(MemoVisibility.PROTECTED, MemoVisibility.fromApiString("PROTECTED"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromApiStringUnknownDefaultsToPrivate() {
|
||||
assertEquals(MemoVisibility.PRIVATE, MemoVisibility.fromApiString("UNKNOWN"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toApiStringRoundTrips() {
|
||||
MemoVisibility.entries.forEach { vis ->
|
||||
assertEquals(vis, MemoVisibility.fromApiString(vis.toApiString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.avinal.memos
|
||||
|
||||
import com.avinal.memos.parser.TaskParser
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TaskParserTest {
|
||||
|
||||
@Test
|
||||
fun extractsBasicUncheckedTask() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Buy milk")
|
||||
assertEquals(1, tasks.size)
|
||||
assertEquals("Buy milk", tasks[0].text)
|
||||
assertFalse(tasks[0].isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsCheckedTask() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [x] Done task")
|
||||
assertEquals(1, tasks.size)
|
||||
assertTrue(tasks[0].isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsCheckedTaskUppercaseX() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [X] Done task")
|
||||
assertEquals(1, tasks.size)
|
||||
assertTrue(tasks[0].isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsPriority() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Fix bug p1")
|
||||
assertEquals(1, tasks[0].priority)
|
||||
assertEquals("Fix bug", tasks[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsAllPriorities() {
|
||||
val content = """
|
||||
- [ ] Task A p1
|
||||
- [ ] Task B p2
|
||||
- [ ] Task C p3
|
||||
""".trimIndent()
|
||||
val tasks = TaskParser.extractTasks("m1", content)
|
||||
assertEquals(3, tasks.size)
|
||||
assertEquals(1, tasks[0].priority)
|
||||
assertEquals(2, tasks[1].priority)
|
||||
assertEquals(3, tasks[2].priority)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsExplicitDate() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Review PR 2026-05-25")
|
||||
assertNotNull(tasks[0].dueDate)
|
||||
assertEquals(2026, tasks[0].dueDate!!.year)
|
||||
assertEquals(25, tasks[0].dueDate!!.dayOfMonth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsTodayKeyword() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Do thing @today")
|
||||
assertNotNull(tasks[0].dueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsTomorrowKeyword() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Do thing @tomorrow")
|
||||
assertNotNull(tasks[0].dueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsLabels() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Fix @backend @urgent")
|
||||
assertEquals(listOf("backend", "urgent"), tasks[0].labels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labelsExcludeDateKeywords() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Fix @today @backend")
|
||||
assertEquals(listOf("backend"), tasks[0].labels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsLists() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Review #work #devops")
|
||||
assertEquals(listOf("work", "devops"), tasks[0].lists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listsIgnoreNumericStart() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Task #123")
|
||||
assertTrue(tasks[0].lists.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cleansMetadataFromText() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Buy groceries @today p2 @shopping #personal")
|
||||
assertEquals("Buy groceries", tasks[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleTasksFromOneMemo() {
|
||||
val content = """
|
||||
Some text
|
||||
- [ ] Task 1
|
||||
- [x] Task 2
|
||||
- [ ] Task 3
|
||||
More text
|
||||
""".trimIndent()
|
||||
val tasks = TaskParser.extractTasks("m1", content)
|
||||
assertEquals(3, tasks.size)
|
||||
assertFalse(tasks[0].isCompleted)
|
||||
assertTrue(tasks[1].isCompleted)
|
||||
assertFalse(tasks[2].isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noTasksInPlainText() {
|
||||
val tasks = TaskParser.extractTasks("m1", "Just some text\nNo tasks here")
|
||||
assertTrue(tasks.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun indentedTasksWork() {
|
||||
val tasks = TaskParser.extractTasks("m1", " - [ ] Indented task")
|
||||
assertEquals(1, tasks.size)
|
||||
assertEquals("Indented task", tasks[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stableIdsAcrossParses() {
|
||||
val content = "- [ ] Task A\n- [ ] Task B"
|
||||
val first = TaskParser.extractTasks("m1", content)
|
||||
val second = TaskParser.extractTasks("m1", content)
|
||||
assertEquals(first[0].id, second[0].id)
|
||||
assertEquals(first[1].id, second[1].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentMemosProduceDifferentIds() {
|
||||
val content = "- [ ] Same task"
|
||||
val fromMemo1 = TaskParser.extractTasks("m1", content)
|
||||
val fromMemo2 = TaskParser.extractTasks("m2", content)
|
||||
assertTrue(fromMemo1[0].id != fromMemo2[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconstructLinePreservesMetadata() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Buy milk @today p2 #personal")
|
||||
val reconstructed = TaskParser.reconstructLine(tasks[0])
|
||||
assertTrue(reconstructed.startsWith("- [ ]"))
|
||||
assertTrue(reconstructed.contains("Buy milk"))
|
||||
assertTrue(reconstructed.contains("p2"))
|
||||
assertTrue(reconstructed.contains("#personal"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconstructLineForCompletedTask() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [x] Done task p1")
|
||||
val reconstructed = TaskParser.reconstructLine(tasks[0])
|
||||
assertTrue(reconstructed.startsWith("- [x]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toggleTaskInContentChecks() {
|
||||
val content = "some text\n- [ ] Task A\n- [ ] Task B\nmore"
|
||||
val tasks = TaskParser.extractTasks("m1", content)
|
||||
val toggled = TaskParser.toggleTaskInContent(content, tasks[0])
|
||||
assertTrue(toggled.contains("- [x] Task A"))
|
||||
assertTrue(toggled.contains("- [ ] Task B"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toggleTaskInContentUnchecks() {
|
||||
val content = "- [x] Done\n- [ ] Not done"
|
||||
val tasks = TaskParser.extractTasks("m1", content)
|
||||
val toggled = TaskParser.toggleTaskInContent(content, tasks[0])
|
||||
assertTrue(toggled.contains("- [ ] Done"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceTaskLineInContent() {
|
||||
val content = "line1\n- [ ] Old task\nline3"
|
||||
val tasks = TaskParser.extractTasks("m1", content)
|
||||
val replaced = TaskParser.replaceTaskLineInContent(content, tasks[0], "- [ ] New task p1")
|
||||
assertTrue(replaced.contains("- [ ] New task p1"))
|
||||
assertFalse(replaced.contains("Old task"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPriorityReturnsNull() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task")
|
||||
assertNull(tasks[0].priority)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noDateReturnsNull() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task")
|
||||
assertNull(tasks[0].dueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun originalLinePreserved() {
|
||||
val line = "- [ ] Buy groceries @today p2 #personal"
|
||||
val tasks = TaskParser.extractTasks("m1", line)
|
||||
assertEquals(line, tasks[0].originalLine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bulletListNotParsedAsTask() {
|
||||
val tasks = TaskParser.extractTasks("m1", "- Regular list item\n* Another item")
|
||||
assertTrue(tasks.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun memoIdStoredCorrectly() {
|
||||
val tasks = TaskParser.extractTasks("memo123", "- [ ] Task")
|
||||
assertEquals("memo123", tasks[0].memoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lineIndexCorrect() {
|
||||
val content = "header\n\n- [ ] First\ntext\n- [ ] Second"
|
||||
val tasks = TaskParser.extractTasks("m1", content)
|
||||
assertEquals(2, tasks[0].lineIndex)
|
||||
assertEquals(4, tasks[1].lineIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.avinal.memos.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileSaver(onSaved: (Boolean) -> Unit): (String, String) -> Unit {
|
||||
return { _, _ -> /* TODO: iOS file saver */ }
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileLoader(onLoaded: (String) -> Unit): () -> Unit {
|
||||
return { /* TODO: iOS file loader */ }
|
||||
}
|
||||
Reference in New Issue
Block a user