Первые тесты

This commit is contained in:
2026-05-19 00:48:07 +03:00
parent fd6f2e5879
commit eecaf44b72
58 changed files with 1634 additions and 501 deletions

View File

@@ -1,24 +0,0 @@
package com.github.nullptroma.wallenc.ui
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.github.nullptroma.wallenc.ui.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,66 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.R
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TextSecretsScreenContentTest {
@get:Rule
val composeRule = createComposeRule()
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun showsEmptyStateWhenNoSecrets() {
composeRule.setContent {
MaterialTheme {
TextSecretsScreenContent(
uiState = TextSecretsScreenState(
isLoading = false,
isAvailable = true,
items = emptyList(),
),
)
}
}
composeRule.onNodeWithText(context.getString(R.string.text_secret_empty_state)).assertIsDisplayed()
}
@Test
fun showsSecretTitle() {
val secret = TextSecretRecord(
id = "s1",
title = "Production DB",
items = listOf(TextSecretEntryRecord(label = "user", value = "admin")),
)
composeRule.setContent {
MaterialTheme {
TextSecretsScreenContent(
uiState = TextSecretsScreenState(
isLoading = false,
isAvailable = true,
items = listOf(secret),
),
) {
TextSecretListCard(
secret = secret,
onClick = {},
enabled = true,
)
}
}
}
composeRule.onNodeWithText("Production DB").assertIsDisplayed()
}
}

View File

@@ -0,0 +1,70 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.R
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TwoFaTokensScreenContentTest {
@get:Rule
val composeRule = createComposeRule()
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun showsEmptyStateWhenNoTokens() {
composeRule.setContent {
MaterialTheme {
TwoFaTokensScreenContent(
uiState = TwoFaTokensScreenState(
isLoading = false,
isAvailable = true,
items = emptyList(),
),
)
}
}
composeRule.onNodeWithText(context.getString(R.string.two_fa_empty_state)).assertIsDisplayed()
}
@Test
fun showsIssuerAndAccountForToken() {
val token = TwoFaTokenRecord(
id = "1",
issuer = "GitHub",
account = "user@example.com",
secret = "SECRET",
)
composeRule.setContent {
MaterialTheme {
TwoFaTokensScreenContent(
uiState = TwoFaTokensScreenState(
isLoading = false,
isAvailable = true,
items = listOf(token),
),
) {
TwoFaTokenListHeader(
issuer = token.issuer,
account = token.account,
modifier = Modifier.padding(8.dp),
)
}
}
}
composeRule.onNodeWithText("GitHub").assertIsDisplayed()
composeRule.onNodeWithText("user@example.com").assertIsDisplayed()
}
}

View File

@@ -1,28 +1,16 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.outlined.Notes
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -33,7 +21,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.resolveText
@Composable
fun TextSecretsScreen(
@@ -60,99 +47,19 @@ fun TextSecretsScreen(
}
},
) { innerPadding ->
Column(
TextSecretsScreenContent(
uiState = uiState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.padding(innerPadding),
) {
uiState.errorNotification?.let { notification ->
Text(
text = notification.resolveText(),
color = MaterialTheme.colorScheme.error,
)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.text_secret_empty_state))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret ->
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Notes,
contentDescription = null,
modifier = Modifier
.size(22.dp)
.padding(top = 2.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier.padding(end = 12.dp),
) {
Text(
text = secret.title,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.text_secret_items_count, secret.items.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val fieldNames = secret.items
.map { item ->
item.label
?.trim()
.takeUnless { it.isNullOrBlank() }
?: stringResource(R.string.text_secret_item_without_label)
}
if (fieldNames.isNotEmpty()) {
fieldNames.take(3).forEach { fieldName ->
Text(
text = "\u2022 $fieldName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val hiddenCount = fieldNames.size - 3
if (hiddenCount > 0) {
Text(
text = stringResource(
R.string.text_secret_more_fields,
hiddenCount,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
)
}
}
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret ->
TextSecretListCard(
secret = secret,
onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable,
)
}
}
}

View File

@@ -0,0 +1,133 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.outlined.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.resolveText
@Composable
fun TextSecretsScreenContent(
uiState: TextSecretsScreenState,
modifier: Modifier = Modifier,
secretList: @Composable () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
uiState.errorNotification?.let { notification ->
Text(
text = notification.resolveText(),
color = MaterialTheme.colorScheme.error,
)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.text_secret_empty_state))
} else {
secretList()
}
}
}
@Composable
fun TextSecretListCard(
secret: TextSecretRecord,
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
onClick = onClick,
enabled = enabled,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Notes,
contentDescription = null,
modifier = Modifier
.size(22.dp)
.padding(top = 2.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.padding(end = 12.dp)) {
Text(
text = secret.title,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.text_secret_items_count, secret.items.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val fieldNames = secret.items
.map { item ->
item.label
?.trim()
.takeUnless { it.isNullOrBlank() }
?: stringResource(R.string.text_secret_item_without_label)
}
if (fieldNames.isNotEmpty()) {
fieldNames.take(3).forEach { fieldName ->
Text(
text = "\u2022 $fieldName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val hiddenCount = fieldNames.size - 3
if (hiddenCount > 0) {
Text(
text = stringResource(
R.string.text_secret_more_fields,
hiddenCount,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
)
}
}
}

View File

@@ -122,30 +122,14 @@ fun TwoFaTokensScreen(
}
},
) { innerPadding ->
Column(
TwoFaTokensScreenContent(
uiState = uiState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.padding(innerPadding),
) {
if (uiState.isLoading) {
CircularProgressIndicator()
return@Column
}
uiState.errorNotification?.let { notification ->
Text(
text = notification.resolveText(),
color = MaterialTheme.colorScheme.error,
)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.two_fa_empty_state))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item ->
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
@@ -159,30 +143,11 @@ fun TwoFaTokensScreen(
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
TwoFaTokenListHeader(
issuer = item.issuer,
account = item.account,
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
modifier = Modifier
.size(22.dp)
.padding(top = 2.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = item.issuer,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = item.account,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
)
Row {
IconButton(
onClick = { editingToken = item },
@@ -282,7 +247,6 @@ fun TwoFaTokensScreen(
}
}
}
}
}
}

View File

@@ -0,0 +1,86 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.resolveText
@Composable
fun TwoFaTokensScreenContent(
uiState: TwoFaTokensScreenState,
modifier: Modifier = Modifier,
tokenList: @Composable () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (uiState.isLoading) {
CircularProgressIndicator()
return@Column
}
uiState.errorNotification?.let { notification ->
Text(
text = notification.resolveText(),
color = MaterialTheme.colorScheme.error,
)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.two_fa_empty_state))
} else {
tokenList()
}
}
}
@Composable
fun TwoFaTokenListHeader(
issuer: String,
account: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
modifier = Modifier
.size(22.dp)
.padding(top = 2.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = issuer,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = account,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -1,17 +0,0 @@
package com.github.nullptroma.wallenc.ui
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,39 @@
package com.github.nullptroma.wallenc.ui.navigation
import android.content.Intent
import android.net.Uri
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class WallencDeepLinksTest {
@Test
fun matchesWallencViewIntent() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.MAIN_URI_PATTERN))
assertTrue(intent.matchesWallencDeepLink())
}
@Test
fun rejectsUnrelatedIntent() {
val intent = Intent(Intent.ACTION_MAIN)
assertFalse(intent.matchesWallencDeepLink())
}
@Test
fun matchesTasksAndSettingsHosts() {
assertTrue(
Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.TASKS_URI_PATTERN))
.matchesWallencDeepLink(),
)
assertTrue(
Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.SETTINGS_URI_PATTERN))
.matchesWallencDeepLink(),
)
}
}

View File

@@ -0,0 +1,37 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class OtpAuthUriParserTest {
@Test
fun parsesStandardTotpUri() {
val uri = "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&digits=6&period=30"
val parsed = parseOtpAuthTotpUri(uri)
assertNotNull(parsed)
assertEquals("GitHub", parsed!!.issuer)
assertEquals("user@example.com", parsed.account)
assertEquals("JBSWY3DPEHPK3PXP", parsed.secret)
assertEquals(6, parsed.digits)
assertEquals(30, parsed.periodSeconds)
assertEquals("SHA1", parsed.algorithm)
}
@Test
fun rejectsNonOtpauthScheme() {
assertNull(parseOtpAuthTotpUri("https://example.com"))
}
@Test
fun rejectsMissingSecret() {
assertNull(parseOtpAuthTotpUri("otpauth://totp/Test:account"))
}
}

View File

@@ -0,0 +1,42 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class TaskPipelineViewModelTest {
@Test
fun startTestTaskEnqueuesWork() = runBlocking {
val orchestrator = TaskOrchestrator(Dispatchers.Default)
val uiStrings = mockk<UiStringResolver>()
every { uiStrings.invoke(any<Int>(), any()) } returns "Test task"
every { uiStrings.invoke(any<Int>()) } returns "Test"
val viewModel = TaskPipelineViewModel(orchestrator, uiStrings)
viewModel.startTestTask(durationSec = 0, infinityIndeterminateProgress = false)
withTimeout(5_000) {
while (true) {
val task = orchestrator.pipelineState.value.tasks.singleOrNull()
if (task?.state is TaskRunState.Completed) break
delay(25)
}
}
val task = orchestrator.pipelineState.value.tasks.single()
assertTrue(task.state is TaskRunState.Completed)
}
}