From aae52e8954870fe51cd8f00eec9aa36685dc81da Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 22:28:40 +0200 Subject: [PATCH] =?UTF-8?q?refactor(qualite):=20retirer=20le=20gris=C3=A9?= =?UTF-8?q?=20d'un=20serveur=20sans=20ffmpeg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waveflow-server refuse de démarrer sans ffmpeg : GET /api/v2/transcode/status répond toujours available: true. Le grisé des profils transcodés, ses deux messages et le relevé fait à chaque ouverture du compte défendaient un état impossible. Sortent l'appel au statut du transcodage, sa réponse, le dépôt, et ce que l'état et le ViewModel de la qualité en tiraient. Les onze tests qui éprouvaient cet état sortent avec lui. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../main/java/app/waveflow/MainActivity.kt | 6 +- .../app/waveflow/data/remote/CatalogApi.kt | 8 -- .../waveflow/data/remote/CatalogRepository.kt | 4 - .../main/java/app/waveflow/data/remote/Dto.kt | 12 --- .../waveflow/data/remote/HttpCatalogApi.kt | 8 -- .../ui/quality/StreamQualitySection.kt | 40 ++------ .../ui/quality/StreamQualityViewModel.kt | 67 ++----------- .../data/remote/HttpCatalogApiTest.kt | 14 --- .../java/app/waveflow/testing/ServerFakes.kt | 10 -- .../ui/quality/StreamQualitySectionTest.kt | 60 ------------ .../ui/quality/StreamQualityViewModelTest.kt | 93 +------------------ .../waveflow/ui/server/ServerScreenTest.kt | 12 --- 12 files changed, 21 insertions(+), 313 deletions(-) delete mode 100644 app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index ecc8f8f..7bf4fbd 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -529,12 +529,10 @@ private fun WaveFlowRoot( serverState.connected?.let { session -> val qualityState by qualityViewModel.state.collectAsStateWithLifecycle() - // La taille bouge à chaque piste lue, et ffmpeg peut - // avoir été installé ou retiré du serveur : les deux - // se relèvent à l'ouverture, pas une fois pour toutes. + // La taille bouge à chaque piste lue : elle se relève + // à l'ouverture, pas une fois pour toutes. LaunchedEffect(Unit) { cacheViewModel.refresh() - qualityViewModel.refresh() } ServerAccountScreen( diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt index f98f03f..2839e5a 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -69,14 +69,6 @@ interface CatalogApi { trackId: String, rendering: StreamRendering, ): String - - /** - * `GET /api/v2/transcode/status` : le serveur sait-il transcoder ? - * - * Un serveur sans ffmpeg sert les originaux et rien d'autre. Lui demander - * une version transcodée ferait échouer chaque piste. - */ - suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean } /** diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt index e58a037..05e670b 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -43,10 +43,6 @@ class CatalogRepository( suspend fun streamUrl(trackId: String, rendering: StreamRendering): String = authorized { url, token -> api.streamTicket(url, token, trackId, rendering) } - /** Voir [CatalogApi.transcodingAvailable]. */ - suspend fun transcodingAvailable(): Boolean = - authorized { url, token -> api.transcodingAvailable(url, token) } - /** * Exécute [call] avec un jeton valide, en réessayant une fois sur refus. * diff --git a/app/src/main/java/app/waveflow/data/remote/Dto.kt b/app/src/main/java/app/waveflow/data/remote/Dto.kt index 07db249..425a532 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -139,18 +139,6 @@ internal data class StreamTicketResponse( @SerialName("expires_at") val expiresAt: Long, ) -/** - * `GET /api/v2/transcode/status`. - * - * Le serveur rend aussi `active`, `global_limit` et `per_user_limit` ; seul - * `available` sert aujourd'hui, et un champ lu sans être utilisé serait un - * champ de plus à croire à jour. - */ -@Serializable -internal data class TranscodeStatusResponse( - val available: Boolean, -) - @Serializable internal data class ArtistDetailResponse( val id: String, diff --git a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt index af80c58..fab4ff1 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -109,13 +109,6 @@ class HttpCatalogApi( return http.absoluteUrl(serverUrl, ticket.url, query = rendering.toQuery()) } - override suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean = - http.get( - serverUrl = serverUrl, - path = TRANSCODE_STATUS, - accessToken = accessToken, - ).decode().available - /** L'original ne dit rien : le serveur le sert par défaut, et refuse qu'on lui donne un débit. */ private fun StreamRendering.toQuery(): Map = if (isOriginal) { @@ -141,7 +134,6 @@ class HttpCatalogApi( } private companion object { - const val TRANSCODE_STATUS = "api/v2/transcode/status" const val ALBUMS = "api/v2/albums" const val ARTISTS = "api/v2/artists" const val TRACKS = "api/v2/tracks" diff --git a/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt b/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt index efe9096..0a9ed96 100644 --- a/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt +++ b/app/src/main/java/app/waveflow/ui/quality/StreamQualitySection.kt @@ -52,20 +52,18 @@ fun StreamQualitySection( QualityRow( quality = quality, selected = quality == state.current, - enabled = state.isSelectable(quality), onChoose = onChoose, ) } } Text( - text = note(state), + // Le rendu est posé sur chaque piste quand elle entre dans la + // file : changer d'avis en pleine écoute sans que rien ne bouge + // se lirait sinon comme une panne. + text = "S'applique aux pistes lancées ensuite, pas à la file en cours.", style = MaterialTheme.typography.bodySmall, - color = if (state.isCurrentUnavailable) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp), ) } @@ -76,7 +74,6 @@ fun StreamQualitySection( private fun QualityRow( quality: StreamQuality, selected: Boolean, - enabled: Boolean, onChoose: (StreamQuality) -> Unit, ) { Row( @@ -85,13 +82,12 @@ private fun QualityRow( .fillMaxWidth() .selectable( selected = selected, - enabled = enabled, role = Role.RadioButton, onClick = { onChoose(quality) }, ) .padding(horizontal = 16.dp, vertical = 10.dp), ) { - RadioButton(selected = selected, onClick = null, enabled = enabled) + RadioButton(selected = selected, onClick = null) Column( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.padding(start = 16.dp), @@ -99,11 +95,7 @@ private fun QualityRow( Text( text = quality.label, style = MaterialTheme.typography.bodyLarge, - color = if (enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = MaterialTheme.colorScheme.onSurface, ) Text( text = quality.summary, @@ -113,21 +105,3 @@ private fun QualityRow( } } } - -/** - * Ce qu'il faut savoir sous les choix. - * - * Par défaut, que le réglage ne touche pas la file en cours : le rendu est posé - * sur chaque piste quand elle y entre, et changer d'avis en pleine écoute sans - * que rien ne bouge se lirait sinon comme une panne. - */ -internal fun note(state: StreamQualityUiState): String = when { - state.isCurrentUnavailable -> - "Ce serveur ne sait pas transcoder : ses pistes ne pourront pas être lues " + - "dans la qualité choisie. Revenez à la qualité d'origine." - - state.transcodingAvailable == false -> - "Ce serveur ne sait pas transcoder : seule la qualité d'origine est disponible." - - else -> "S'applique aux pistes lancées ensuite, pas à la file en cours." -} diff --git a/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt b/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt index 7905421..b824929 100644 --- a/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/quality/StreamQualityViewModel.kt @@ -1,6 +1,5 @@ package app.waveflow.ui.quality -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -8,95 +7,47 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import app.waveflow.WaveFlowApp import app.waveflow.data.PreferencesStore -import app.waveflow.data.remote.CatalogRepository -import app.waveflow.data.remote.ServerException import app.waveflow.model.StreamQuality -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** * Ce que l'écran sait de la qualité de lecture. * - * @property transcodingAvailable ce que le serveur a dit de sa capacité à - * transcoder, `null` tant qu'il n'a rien dit. L'inconnu ne ferme aucun choix : - * seul un refus explicite le fait, sans quoi une coupure réseau passagère - * grisait des profils que le serveur sert très bien. + * Tous les profils se choisissent, sans demander au serveur s'il sait + * transcoder : `waveflow-server` refuse de démarrer sans ffmpeg, et un serveur + * joignable le sait donc toujours. */ data class StreamQualityUiState( val current: StreamQuality = StreamQuality.Original, - val transcodingAvailable: Boolean? = null, -) { - /** L'original se sert toujours ; le reste suppose un serveur qui transcode. */ - fun isSelectable(quality: StreamQuality): Boolean = - quality.rendering.isOriginal || transcodingAvailable != false - - /** - * Le choix enregistré ne peut pas être servi par ce serveur. - * - * Le cas se présente : la préférence appartient à l'application, pas au - * serveur, et elle survit à un changement de serveur. - */ - val isCurrentUnavailable: Boolean get() = !isSelectable(current) -} +) /** La qualité de lecture, vue et choisie depuis le compte serveur. */ class StreamQualityViewModel( private val store: PreferencesStore, - private val catalog: CatalogRepository, ) : ViewModel() { - private val disponible = MutableStateFlow(null) - /** * `Eagerly`, comme les réglages : l'écran doit s'ouvrir sur le choix * enregistré, pas sur le défaut qu'il remplacerait sous les yeux. */ - val state: StateFlow = combine(store.preferences, disponible) { prefs, dispo -> - StreamQualityUiState(current = prefs.streamQuality, transcodingAvailable = dispo) - }.stateIn(viewModelScope, SharingStarted.Eagerly, StreamQualityUiState()) - - private var releve: Job? = null + val state: StateFlow = store.preferences + .map { prefs -> StreamQualityUiState(current = prefs.streamQuality) } + .stateIn(viewModelScope, SharingStarted.Eagerly, StreamQualityUiState()) - /** - * À l'ouverture de l'écran : ffmpeg peut avoir été installé, ou retiré, - * depuis la dernière fois. - * - * Un relevé en échec laisse ce qu'on savait déjà. Un serveur injoignable - * n'a rien appris de neuf sur sa capacité à transcoder. - */ - fun refresh() { - releve?.cancel() - releve = viewModelScope.launch { - try { - disponible.value = catalog.transcodingAvailable() - } catch (error: ServerException) { - Log.w(TAG, "Capacité de transcodage inconnue", error) - } - } - } - - /** Un profil que le serveur a déclaré ne pas servir n'est pas enregistré. */ fun choose(quality: StreamQuality) { - if (!state.value.isSelectable(quality)) return viewModelScope.launch { store.setStreamQuality(quality) } } companion object { - private const val TAG = "StreamQualityViewModel" - val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as WaveFlowApp - StreamQualityViewModel( - store = app.container.preferencesStore, - catalog = app.container.catalogRepository, - ) + StreamQualityViewModel(store = app.container.preferencesStore) } } } diff --git a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt index 26da9ed..316ef58 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -227,20 +227,6 @@ class HttpCatalogApiTest { assertEquals("${url()}/api/v2/stream/VkdLrczM?format=opus&bitrate=96", streamUrl) } - @Test - fun `un serveur sans ffmpeg le dit`() = runTest { - server.enqueue( - MockResponse().setBody("""{"available":false,"active":0,"global_limit":4,"per_user_limit":2}"""), - ) - - val disponible = api.transcodingAvailable(url(), "wfa_1") - - val request = server.takeRequest() - assertEquals("/api/v2/transcode/status", request.path) - assertEquals("Bearer wfa_1", request.getHeader("Authorization")) - assertEquals(false, disponible) - } - @Test fun `un jeton refuse remonte comme tel`() = runTest { server.enqueue( diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 757ef2d..0460fae 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -188,14 +188,6 @@ class FakeCatalogApi( var lastRendering: StreamRendering? = null private set - /** Ce que le serveur répond sur sa capacité à transcoder. */ - var transcoding = true - - override suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean { - record(serverUrl, accessToken, null) - return transcoding - } - private suspend fun record(serverUrl: String, accessToken: String, page: Pair?) { calls++ lastServerUrl = serverUrl @@ -326,8 +318,6 @@ class PagingCatalogApi( rendering: StreamRendering, ): String = "$serverUrl/api/v2/stream/ticket-$trackId" - override suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean = true - private fun failIfDue(call: Int) { if (failFromCall > 0 && call >= failFromCall) { throw ServerException.Unreachable("coupure") diff --git a/app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt b/app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt deleted file mode 100644 index 4111888..0000000 --- a/app/src/test/java/app/waveflow/ui/quality/StreamQualitySectionTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package app.waveflow.ui.quality - -import app.waveflow.model.StreamQuality -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** Ce que la section propose, et ce qu'elle dit sous les choix. */ -class StreamQualitySectionTest { - - @Test - fun `sans rien savoir du serveur, aucun choix n'est ferme`() { - // Une coupure réseau n'apprend rien de la capacité du serveur : - // griser les profils sur un relevé manqué fermerait ce qu'il sert. - val etat = StreamQualityUiState(transcodingAvailable = null) - - StreamQuality.entries.forEach { assertTrue(it.name, etat.isSelectable(it)) } - } - - @Test - fun `un serveur sans ffmpeg ne laisse que l'original`() { - val etat = StreamQualityUiState(transcodingAvailable = false) - - assertTrue(etat.isSelectable(StreamQuality.Original)) - assertFalse(etat.isSelectable(StreamQuality.Haute)) - assertFalse(etat.isSelectable(StreamQuality.Economie)) - } - - @Test - fun `par defaut, la note dit que la file en cours n'est pas touchee`() { - // Changer d'avis en pleine écoute sans que rien ne bouge se lirait - // sinon comme une panne. - assertEquals( - "S'applique aux pistes lancées ensuite, pas à la file en cours.", - note(StreamQualityUiState(transcodingAvailable = true)), - ) - } - - @Test - fun `un serveur sans ffmpeg le dit, sans alarme quand le choix est servi`() { - val etat = StreamQualityUiState(current = StreamQuality.Original, transcodingAvailable = false) - - assertFalse(etat.isCurrentUnavailable) - assertEquals( - "Ce serveur ne sait pas transcoder : seule la qualité d'origine est disponible.", - note(etat), - ) - } - - @Test - fun `un choix que ce serveur ne sert pas est signale`() { - // La préférence survit à un changement de serveur : on peut arriver - // sur un serveur sans ffmpeg avec Économie déjà choisie. - val etat = StreamQualityUiState(current = StreamQuality.Economie, transcodingAvailable = false) - - assertTrue(etat.isCurrentUnavailable) - assertTrue(note(etat), note(etat).endsWith("Revenez à la qualité d'origine.")) - } -} diff --git a/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt b/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt index b6604db..66156fd 100644 --- a/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/quality/StreamQualityViewModelTest.kt @@ -1,61 +1,25 @@ package app.waveflow.ui.quality -import app.waveflow.data.remote.CatalogRepository -import app.waveflow.data.remote.ServerException -import app.waveflow.data.remote.ServerSessionRepository import app.waveflow.model.AppPreferences -import app.waveflow.model.ServerSession import app.waveflow.model.StreamQuality -import app.waveflow.testing.FakeCatalogApi import app.waveflow.testing.FakePreferencesStore -import app.waveflow.testing.FakeServerApi -import app.waveflow.testing.FakeSessionStore import app.waveflow.testing.MainDispatcherRule import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -/** - * La qualité de lecture vue depuis le compte serveur. - * - * Robolectric parce que le ViewModel journalise un relevé en échec : sans lui, - * `android.util.Log` lève, et l'erreur se déguise en panne du code testé. - */ -@RunWith(RobolectricTestRunner::class) +/** La qualité de lecture vue depuis le compte serveur. */ class StreamQualityViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private val session = ServerSession.Connected( - serverUrl = "https://musique.test", - username = "admin", - accessToken = "wfa_1", - refreshToken = "wfr_1", - deviceId = "appareil-1", - accessExpiresAtMs = Long.MAX_VALUE, - ) - - private suspend fun catalogue(api: FakeCatalogApi): CatalogRepository { - val sessions = ServerSessionRepository( - api = FakeServerApi(), - store = FakeSessionStore(stored = session), - deviceName = "Pixel de test", - now = { 0L }, - ) - sessions.restore() - return CatalogRepository(api, sessions) - } - @Test fun `l'ecran s'ouvre sur le choix enregistre`() = runTest { val magasin = FakePreferencesStore(AppPreferences(streamQuality = StreamQuality.Haute)) - val vm = StreamQualityViewModel(magasin, catalogue(FakeCatalogApi())) + val vm = StreamQualityViewModel(magasin) assertEquals(StreamQuality.Haute, vm.state.value.current) } @@ -63,61 +27,10 @@ class StreamQualityViewModelTest { @Test fun `choisir un profil l'enregistre`() = runTest { val magasin = FakePreferencesStore() - val vm = StreamQualityViewModel(magasin, catalogue(FakeCatalogApi())) + val vm = StreamQualityViewModel(magasin) vm.choose(StreamQuality.Economie) assertEquals(StreamQuality.Economie, magasin.streamQuality) } - - @Test - fun `un serveur sans ffmpeg le fait savoir a l'ecran`() = runTest { - val api = FakeCatalogApi().apply { transcoding = false } - val vm = StreamQualityViewModel(FakePreferencesStore(), catalogue(api)) - - vm.refresh() - - assertEquals(false, vm.state.value.transcodingAvailable) - } - - @Test - fun `un profil que le serveur ne sert pas n'est pas enregistre`() = runTest { - // L'écran le grise déjà. La garde tient aussi pour l'appui qui arrive - // avant que l'écran ne se soit repeint. - val magasin = FakePreferencesStore() - val api = FakeCatalogApi().apply { transcoding = false } - val vm = StreamQualityViewModel(magasin, catalogue(api)) - vm.refresh() - - vm.choose(StreamQuality.Economie) - - assertEquals(StreamQuality.Original, magasin.streamQuality) - } - - @Test - fun `sans reponse du serveur, rien n'est ferme`() = runTest { - // Une coupure n'apprend rien de la capacité du serveur : l'inconnu - // reste l'inconnu, et ne grise aucun profil. - val api = FakeCatalogApi(failure = ServerException.Unreachable("coupure")) - val vm = StreamQualityViewModel(FakePreferencesStore(), catalogue(api)) - - vm.refresh() - - assertNull(vm.state.value.transcodingAvailable) - } - - @Test - fun `un releve en echec garde ce qu'on savait`() = runTest { - // Un serveur qui a dit ne pas transcoder, puis devient injoignable : - // la coupure n'a pas installé ffmpeg. Oublier sa réponse rouvrirait des - // profils qui feraient échouer chaque piste. - val api = FakeCatalogApi().apply { transcoding = false } - val vm = StreamQualityViewModel(FakePreferencesStore(), catalogue(api)) - vm.refresh() - - api.pendantLAppel = { throw ServerException.Unreachable("coupure") } - vm.refresh() - - assertEquals(false, vm.state.value.transcodingAvailable) - } } diff --git a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt index d30b5e0..0471efa 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -82,18 +82,6 @@ class ServerScreenTest { assertEquals(listOf(StreamQuality.Haute), choisies) } - @Test - fun `un serveur sans ffmpeg grise les profils transcodes`() { - // Proposer un profil que le serveur ne sait pas produire ferait - // échouer chaque piste : il reste visible, pour qu'on sache qu'il - // existe, mais ne se choisit pas. - afficherCompte(quality = StreamQualityUiState(transcodingAvailable = false)) - - compose.onNodeWithText("Qualité d'origine").assertIsEnabled() - compose.onNodeWithText("Haute qualité").assertIsNotEnabled() - compose.onNodeWithText("Économie").assertIsNotEnabled() - } - /** * Nombre de nœuds dont un texte contient [text]. *