diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 9c5d2de336c9..7d257143cafd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -141,6 +141,7 @@ import com.ichi2.anki.noteeditor.Toolbar.TextFormatListener import com.ichi2.anki.noteeditor.Toolbar.TextWrapper import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.ImageOcclusion +import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.previewer.TemplatePreviewerArguments import com.ichi2.anki.previewer.TemplatePreviewerPage @@ -2422,10 +2423,11 @@ class NoteEditorFragment : populateEditFields(changeType, false) updateFieldsFromStickyText() - // Showing the deck selection parts is not needed for Image Occlusion notetypes - // as deck selection is handled by the backend page - requireView().findViewById(R.id.CardEditorDeckText).isVisible = !currentNotetypeIsImageOcclusion() - requireView().findViewById(R.id.note_deck_name).isVisible = !currentNotetypeIsImageOcclusion() + // When adding a note, ImageOcclusion handles the deck selection + // as a user can reach this screen directly from an intent + val disableDeckEditing = addNote && currentNotetypeIsImageOcclusion() + requireView().findViewById(R.id.CardEditorDeckText).isVisible = !disableDeckEditing + requireView().findViewById(R.id.note_deck_name).isVisible = !disableDeckEditing } private fun addClozeButton( @@ -2751,22 +2753,27 @@ class NoteEditorFragment : private fun currentNotetypeIsImageOcclusion() = currentlySelectedNotetype?.isImageOcclusion == true private fun setupImageOcclusionEditor(imagePath: String = "") { - val kind: String - val id: Long - if (addNote) { - kind = "add" - // if opened from an intent, the selected note type may not be suitable for IO - id = - if (currentNotetypeIsImageOcclusion()) { - currentlySelectedNotetype!!.id - } else { - 0 - } - } else { - kind = "edit" - id = editorNote?.id!! - } - val intent = ImageOcclusion.getIntent(requireContext(), kind, id, imagePath, deckId) + val args = + if (addNote) { + // if opened from an intent, the selected note type may not be suitable for IO + val noteTypeId = + if (currentNotetypeIsImageOcclusion()) { + currentlySelectedNotetype!!.id + } else { + 0 + } + ImageOcclusionArgs.Add( + noteTypeId = noteTypeId, + imagePath = imagePath, + originalDeckId = deckId, + ) + } else { + ImageOcclusionArgs.Edit( + noteId = editorNote!!.id, + ) + } + + val intent = ImageOcclusion.getIntent(requireContext(), args) requestIOEditorCloser.launch(intent) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt index 75e3b1079632..df96a36ce8dd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/ImageOcclusion.kt @@ -24,26 +24,45 @@ import android.webkit.WebView import android.widget.TextView import androidx.activity.addCallback import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.appbar.MaterialToolbar -import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R import com.ichi2.anki.SingleFragmentActivity import com.ichi2.anki.common.annotations.NeedsTest +import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DiscardChangesDialog -import com.ichi2.anki.launchCatchingTask -import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs import com.ichi2.anki.pages.viewmodel.ImageOcclusionViewModel -import com.ichi2.anki.requireAnkiActivity -import com.ichi2.anki.selectedDeckIfNotFiltered +import com.ichi2.anki.pages.viewmodel.ImageOcclusionViewModel.Companion.IO_ARGS_KEY import com.ichi2.anki.startDeckSelection +import com.ichi2.utils.HandlerUtils +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import timber.log.Timber +/** + * Page provided by the backend, for a user to add or edit an image occlusion (IO) note + * + * IO: Like an image-based cloze: hide parts of an image, revealed on the back + * ([docs](https://docs.ankiweb.net/editing.html#image-occlusion) and + * [source](https://github.com/ankitects/anki/blob/main/proto/anki/image_occlusion.proto)). + * + * When adding, a user may select the deck of the note + * + * **Paths** + * `/image-occlusion/$PATH` + * `/image-occlusion/$NOTE_ID` + * + * @see ImageOcclusionViewModel + * @see ImageOcclusion.getIntent + */ class ImageOcclusion : PageFragment(R.layout.image_occlusion), DeckSelectionDialog.DeckSelectionListener { @@ -66,11 +85,6 @@ class ImageOcclusion : deckNameView = view.findViewById(R.id.deck_name) deckNameView.setOnClickListener { startDeckSelection(all = false, filtered = false, skipEmptyDefault = false) } - requireAnkiActivity().launchCatchingTask { - val selectedDeck = withCol { selectedDeckIfNotFiltered() } - deckNameView.text = selectedDeck.name - } - @NeedsTest("#17393 verify that the added image occlusion cards are put in the correct deck") view.findViewById(R.id.toolbar).setOnMenuItemClickListener { if (it.itemId == R.id.action_save) { @@ -79,6 +93,8 @@ class ImageOcclusion : } return@setOnMenuItemClickListener true } + + setupFlows() } override fun onCreateWebViewClient(savedInstanceState: Bundle?): PageWebViewClient = @@ -88,7 +104,7 @@ class ImageOcclusion : url: String?, ) { super.onPageFinished(view, url) - viewModel.webViewOptions.let { options -> + viewModel.args.toImageOcclusionMode().let { options -> view?.evaluateJavascript("globalThis.anki.imageOcclusion.mode = $options") { super.onPageFinished(view, url) } @@ -99,13 +115,7 @@ class ImageOcclusion : override fun onDeckSelected(deck: SelectableDeck?) { if (deck == null) return require(deck is SelectableDeck.Deck) - deckNameView.text = deck.name - val deckDidChange = viewModel.handleDeckSelection(deck.deckId) - if (deckDidChange) { - viewLifecycleOwner.lifecycleScope.launch { - withCol { decks.select(viewModel.selectedDeckId) } - } - } + viewModel.handleDeckSelection(deck.deckId) } // HACK: detect a successful save; #19443 will provide a better method @@ -120,28 +130,44 @@ class ImageOcclusion : } } - companion object { - const val IO_ARGS_KEY = "IMAGE_OCCLUSION_ARGS" + private fun setupFlows() { + fun onDeckNameChanged(name: String) { + deckNameView.text = name + } + viewModel.deckNameFlow?.launchCollectionInLifecycleScope(::onDeckNameChanged) ?: run { + deckNameView.isVisible = false + } + } + + // TODO: Move this to an extension method once we have context parameters + private fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + this@launchCollectionInLifecycleScope.collect { + if (isRobolectric) { + HandlerUtils.postOnNewHandler { runBlocking { block(it) } } + } else { + block(it) + } + } + } + } + } + + companion object { /** - * @param editorWorkingDeckId the current deck id that [com.ichi2.anki.NoteEditorFragment] is using + * @param args arguments for either adding or editing a note */ fun getIntent( context: Context, - kind: String, - noteOrNotetypeId: Long, - imagePath: String?, - editorWorkingDeckId: DeckId, + args: ImageOcclusionArgs, ): Intent { - val suffix = if (kind == "edit") noteOrNotetypeId else Uri.encode(imagePath) - - val args = - ImageOcclusionArgs( - kind = kind, - id = noteOrNotetypeId, - imagePath = imagePath, - editorDeckId = editorWorkingDeckId, - ) + val suffix = + when (args) { + is ImageOcclusionArgs.Add -> Uri.encode(args.imagePath) + is ImageOcclusionArgs.Edit -> args.noteId + } val arguments = bundleOf( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/viewmodel/ImageOcclusionViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/viewmodel/ImageOcclusionViewModel.kt index 219f41ac0386..aeb628a7346e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/viewmodel/ImageOcclusionViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/viewmodel/ImageOcclusionViewModel.kt @@ -21,21 +21,65 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.libanki.NoteId +import com.ichi2.anki.libanki.NoteTypeId import com.ichi2.anki.pages.ImageOcclusion +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import org.json.JSONObject import timber.log.Timber +/** + * Arguments for either adding or editing an image occlusion note + * + * @see ImageOcclusionArgs.Add + * @see ImageOcclusionArgs.Edit + */ @Parcelize -data class ImageOcclusionArgs( - val kind: String, - val id: Long, - val imagePath: String?, - val editorDeckId: Long, -) : Parcelable +sealed class ImageOcclusionArgs : Parcelable { + @Parcelize + data class Add( + val imagePath: String, + val noteTypeId: NoteTypeId, + /** + * The ID of the deck that was selected when the editor was opened. + * Used to restore the deck after saving a note to prevent unexpected deck changes. + */ + val originalDeckId: DeckId, + ) : ImageOcclusionArgs() + + @Parcelize + data class Edit( + val noteId: NoteId, + ) : ImageOcclusionArgs() + + /** + * A [JSONObject] containing options for loading the [image occlusion page][ImageOcclusion]. + * This includes the type of operation ("add" or "edit"), and relevant IDs and paths. + * + * See 'IOMode' in https://github.com/ankitects/anki/blob/main/ts/routes/image-occlusion/lib.ts + */ + fun toImageOcclusionMode() = + when (this) { + is Add -> + JSONObject().also { + it.put("kind", "add") + it.put("imagePath", this.imagePath) + it.put("notetypeId", this.noteTypeId) + } + is Edit -> + JSONObject().also { + it.put("kind", "edit") + it.put("noteId", this.noteId) + } + } +} /** * ViewModel for the Image Occlusion fragment. @@ -43,36 +87,33 @@ data class ImageOcclusionArgs( class ImageOcclusionViewModel( savedStateHandle: SavedStateHandle, ) : ViewModel() { - var selectedDeckId: Long + val args: ImageOcclusionArgs = + checkNotNull(savedStateHandle[IO_ARGS_KEY]) { "$IO_ARGS_KEY required" } + + private val originalDeckId: DeckId? = (args as? ImageOcclusionArgs.Add)?.originalDeckId /** - * The ID of the deck that was originally selected when the editor was opened. - * This is used to restore the deck after saving a note to prevent unexpected deck changes. + * The currently selected deck ID + * + * Only valid in 'ADD' mode */ - val oldDeckId: Long + val selectedDeckIdFlow: MutableStateFlow? = + originalDeckId?.let { MutableStateFlow(it) } /** - * A [JSONObject] containing options for initializing the WebView. This includes - * the type of operation ("add" or "edit"), and relevant IDs and paths. + * The currently selected deck name + * + * Only valid in 'ADD' mode */ - val webViewOptions: JSONObject + val deckNameFlow = + selectedDeckIdFlow?.map { did -> withCol { decks.name(did) } } init { - val args: ImageOcclusionArgs = checkNotNull(savedStateHandle[ImageOcclusion.IO_ARGS_KEY]) - - selectedDeckId = args.editorDeckId - oldDeckId = args.editorDeckId - - webViewOptions = - JSONObject().apply { - put("kind", args.kind) - if (args.kind == "add") { - put("imagePath", args.imagePath) - put("notetypeId", args.id) - } else { - put("noteId", args.id) - } - } + // if we are in 'add' mode, the current deck is used to add the note. + // This is reverted in 'resetTemporaryDeckOverride' + selectedDeckIdFlow + ?.onEach { withCol { decks.select(it) } } + ?.launchIn(viewModelScope) } /** @@ -80,21 +121,42 @@ class ImageOcclusionViewModel( * * @param deckId The [DeckId] object representing the selected deck. Can be null if no deck is selected. */ - fun handleDeckSelection(deckId: DeckId): Boolean { - if (deckId == selectedDeckId) return false - selectedDeckId = deckId - return true + fun handleDeckSelection(deckId: DeckId) { + selectedDeckIdFlow?.let { it.value = deckId } ?: run { + Timber.w("deck selection is unavailable") + } } + /** + * Executed when the 'save' operation is completed, before the UI receives the response + */ fun onSaveOperationCompleted() { Timber.i("save operation completed") - if (oldDeckId == selectedDeckId) return + if (originalDeckId != null) { + resetTemporaryDeckOverride(originalDeckId) + } + } + + /** + * Resets the current deck to the deck the screen was opened with + * + * Only for [ImageOcclusionArgs.Add] mode + */ + private fun resetTemporaryDeckOverride(originalDeckId: DeckId) { + // no need to reset if the DeckId was unchanged + if (originalDeckId == selectedDeckIdFlow?.value) return + // reset to the previous deck that the backend "saw" as selected, this - // avoids other screens unexpectedly having their working decks modified( - // most important being the Reviewer where the user would find itself - // studying another deck after editing a note with changing the deck) + // avoids other screens unexpectedly having their working decks modified + // For example, the study screen: if a user backgrounds the screen, then + // adds an occluded image via 'Share' viewModelScope.launch { - CollectionManager.withCol { backend.setCurrentDeck(oldDeckId) } + Timber.i("resetting temporary deck override") + withCol { backend.setCurrentDeck(originalDeckId) } } } + + companion object { + const val IO_ARGS_KEY = "IMAGE_OCCLUSION_ARGS" + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt index 0a24b5346d88..5923695ddcf3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt @@ -48,7 +48,6 @@ import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.not import org.hamcrest.MatcherAssert.assertThat import org.intellij.lang.annotations.Language -import org.json.JSONObject import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -104,7 +103,7 @@ class CustomStudyDialogTest : RobolectricTest() { "usn": -1 } """.trimIndent() - assertThat(customStudy, isJsonEqual(JSONObject(expected))) + assertThat(customStudy, isJsonEqual(expected)) } @Test diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/pages/viewmodel/ImageOcclusionViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/pages/viewmodel/ImageOcclusionViewModelTest.kt new file mode 100644 index 000000000000..eff28ee467c9 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/pages/viewmodel/ImageOcclusionViewModelTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.pages.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.ichi2.anki.libanki.Consts +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.libanki.NoteId +import com.ichi2.anki.libanki.NoteTypeId +import com.ichi2.anki.libanki.testutils.AnkiTest +import com.ichi2.anki.pages.viewmodel.ImageOcclusionViewModel.Companion.IO_ARGS_KEY +import com.ichi2.anki.pages.viewmodel.NoteIdBuilder.Id +import com.ichi2.testutils.EmptyApplication +import com.ichi2.testutils.JvmTest +import com.ichi2.testutils.isJsonEqual +import kotlinx.coroutines.flow.first +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertInstanceOf +import org.junit.jupiter.api.assertNull +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.properties.Delegates.notNull +import kotlin.test.assertNotNull + +/** + * Tests [ImageOcclusionViewModel] + */ +// TODO: make this run with no Android dependencies +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +class ImageOcclusionViewModelTest : JvmTest() { + private var deckIdToSwitchTo by notNull() + + @Before + override fun setUp() { + super.setUp() + deckIdToSwitchTo = addDeck(OTHER_DECK_NAME) + } + + @Test + fun `ADD - deck name is set`() = + withAddViewModel { + assertThat("deckId", selectedDeckIdFlow?.value, equalTo(1)) + assertNotNull(deckNameFlow) + assertThat("deck name", deckNameFlow.first(), equalTo("Default")) + } + + @Test + fun `ADD - deck selection changes id`() = + withAddViewModel { + val deckIdFlow = assertNotNull(selectedDeckIdFlow, "selectedDeckIdFlow") + deckIdFlow.test { + assertThat("initial deck id", awaitItem(), equalTo(1)) + handleDeckSelection(deckIdToSwitchTo) + assertThat("changed deck id", awaitItem(), equalTo(deckIdToSwitchTo)) + } + } + + @Test + fun `ADD - deck selection changes name`() = + withAddViewModel { + val deckNameFlow = assertNotNull(deckNameFlow, "deckNameFlow") + deckNameFlow.test { + assertThat("initial deck name", awaitItem(), equalTo("Default")) + handleDeckSelection(deckIdToSwitchTo) + assertThat("changed deck name", awaitItem(), equalTo(OTHER_DECK_NAME)) + } + } + + // This test is subject to change + // * decks.current could only be set during the 'add' operation + // * the proto could be extended to accept a deckId + @Test + fun `ADD - selected deck is changed and reverted`() = + withAddViewModel { + // change the deck + assertNotNull(deckNameFlow) + deckNameFlow.test { + assertThat("initial deck", this.awaitItem(), equalTo("Default")) + assertThat("initial current deck", col.decks.getCurrentId(), equalTo(1)) + + handleDeckSelection(deckIdToSwitchTo) + + assertThat("changed deck", this.awaitItem(), equalTo(OTHER_DECK_NAME)) + assertThat("changed current deck", col.decks.getCurrentId(), equalTo(deckIdToSwitchTo)) + } + + onSaveOperationCompleted() + assertThat("current deck is reverted", col.decks.getCurrentId(), equalTo(1)) + } + + @Test + fun `EDIT - flows are null`() = + withEditViewModel { + assertNull(selectedDeckIdFlow) + assertNull(deckNameFlow) + } + + @Test + fun `EDIT - full test`() = + withEditViewModel { + // effectively a no-op: call doesn't do anything for 'EDIT' yet + assertDoesNotThrow { onSaveOperationCompleted() } + } + + @Test + fun `ADD - args are copied correctly`() = + withAddViewModel(imagePath = "/", noteTypeId = 12, deckId = 2) { + val args = assertInstanceOf(args) + + val expected = + ImageOcclusionArgs.Add( + imagePath = "/", + noteTypeId = 12, + originalDeckId = 2, + ) + assertThat(args, equalTo(expected)) + } + + @Test + fun `EDIT - args are copied correctly`() = + withEditViewModel(noteIdBuilder = Id(12)) { + val args = assertInstanceOf(args) + val expected = ImageOcclusionArgs.Edit(noteId = 12) + assertThat(args, equalTo(expected)) + } + + @Test + fun `ADD - JSON Serialization`() { + val expected = """ + { + "kind": "add", + "imagePath": "/", + "notetypeId": 12 + } + """ + + withAddViewModel(imagePath = "/", noteTypeId = 12, deckId = 2) { + assertThat(args.toImageOcclusionMode(), isJsonEqual(expected)) + } + } + + @Test + fun `EDIT - JSON Serialization`() { + val expected = """ + { + "kind": "edit", + "noteId": 12 + } + """ + + withEditViewModel(noteIdBuilder = Id(12)) { + assertThat(args.toImageOcclusionMode(), isJsonEqual(expected)) + } + } + + companion object { + const val OTHER_DECK_NAME = "Temp" + } +} + +fun AnkiTest.withViewModel( + args: ImageOcclusionArgs, + block: suspend ImageOcclusionViewModel.() -> Unit, +) = runTest { + val savedStateHandle = + SavedStateHandle().apply { + this[IO_ARGS_KEY] = args + } + + block(ImageOcclusionViewModel(savedStateHandle)) +} + +fun AnkiTest.withAddViewModel( + imagePath: String = "", + noteTypeId: NoteTypeId = 0, + deckId: DeckId = Consts.DEFAULT_DECK_ID, + block: suspend ImageOcclusionViewModel.() -> Unit, +) = withViewModel( + ImageOcclusionArgs.Add( + imagePath = imagePath, + noteTypeId = noteTypeId, + originalDeckId = deckId, + ), + block, +) + +fun AnkiTest.withEditViewModel( + noteIdBuilder: NoteIdBuilder = NoteIdBuilder.CreateNew, + block: suspend ImageOcclusionViewModel.() -> Unit, +) = withViewModel( + ImageOcclusionArgs.Edit( + noteId = noteIdBuilder.build(this), + ), + block, +) + +sealed class NoteIdBuilder { + companion object CreateNew : NoteIdBuilder() + + data class Id( + val id: NoteId, + ) : NoteIdBuilder() + + fun build(testContext: AnkiTest): NoteId = + when (this) { + is Id -> id + is CreateNew -> testContext.addBasicNote().id + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/JsonUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/JsonUtils.kt index 48916381404c..797bdfb6d826 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/JsonUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/JsonUtils.kt @@ -19,11 +19,14 @@ package com.ichi2.testutils import com.ichi2.anki.common.json.JSONObjectHolder import org.hamcrest.BaseMatcher import org.hamcrest.Description +import org.intellij.lang.annotations.Language import org.json.JSONObject fun isJsonEqual(value: JSONObject) = IsJsonEqual(value) -fun isJsonEqual(value: String) = IsJsonEqual(JSONObject(value)) +fun isJsonEqual( + @Language("JSON") value: String, +) = IsJsonEqual(JSONObject(value)) private fun matchesJsonValue( expectedValue: JSONObject, @@ -35,7 +38,7 @@ private fun matchesJsonValue( } // And that each key have the same associated values in both object. for (key in expectedValue.keys()) { - if (expectedValue[key] != actualValue[key]) { + if (!areJsonEquivalent(expectedValue[key], actualValue[key])) { return false } } @@ -78,3 +81,24 @@ private fun jsonObjectOf(vararg pairs: Pair): JSONObject = put(key, value) } } + +/** + * Returns whether [a] and [b] produce the same JSON output as a string + * + * [JSONObject] handles Int and Long differently, but they are equivalent in the JSON output + */ +private fun areJsonEquivalent( + a: Any, + b: Any, +): Boolean { + if (a == b) return true + + // In the string output, 1L and 1 are the same + fun isIntOrLong(n: Any) = n is Int || n is Long + if (isIntOrLong(a) && isIntOrLong(b)) { + return (a as Number).toLong() == (b as Number).toLong() + } + + // Double & Long are not equivalent: '1.0' and '1' are different textual outputs + return false +}