From 0af6ce366e9531b91d958a8afbb5916b80c878d5 Mon Sep 17 00:00:00 2001 From: "Florine W. Dekker" Date: Sun, 22 Oct 2023 15:31:19 +0200 Subject: [PATCH] Add tests for configurable and editor --- README.md | 3 +- .../randomness/template/TemplateActions.kt | 2 +- .../template/TemplateListConfigurable.kt | 37 +++-- .../randomness/template/TemplateListEditor.kt | 59 +++---- .../template/TemplateListConfigurableTest.kt | 117 ++++++++++++++ .../template/TemplateListEditorTest.kt | 153 ++++++++++++------ 6 files changed, 276 insertions(+), 95 deletions(-) create mode 100644 src/test/kotlin/com/fwdekker/randomness/template/TemplateListConfigurableTest.kt diff --git a/README.md b/README.md index f6cc4c228..3020fd88e 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,10 @@ See [Plugin Signing](https://plugins.jetbrains.com/docs/intellij/plugin-signing. ### 🧪 Quality assurance ```bash -$ gradlew test # Run tests +$ gradlew test # Run tests (and collect coverage) $ gradlew test --tests X # Run tests in class X (package name optional) $ gradlew test -Pkotest.tags="X" # Run tests with `NamedTag` X (also supports not (!), and (&), or (|)) +$ gradlew koverHtmlReport # Create HTML coverage report for previous test run $ gradlew check # Run tests and static analysis $ gradlew runPluginVerifier # Check for compatibility issues ``` diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateActions.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateActions.kt index f734b439c..5bdc3d60e 100644 --- a/src/main/kotlin/com/fwdekker/randomness/template/TemplateActions.kt +++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateActions.kt @@ -209,6 +209,6 @@ class TemplateSettingsAction(private val template: Template? = null) : AnAction( override fun actionPerformed(event: AnActionEvent) = ShowSettingsUtil.getInstance() .showSettingsDialog(event.project, TemplateListConfigurable::class.java) { configurable -> - configurable?.also { it.templateToSelect = template?.uuid } + configurable?.also { it.schemeToSelect = template?.uuid } } } diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateListConfigurable.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListConfigurable.kt index 72c18e0f0..6ce97fde1 100644 --- a/src/main/kotlin/com/fwdekker/randomness/template/TemplateListConfigurable.kt +++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListConfigurable.kt @@ -11,7 +11,7 @@ import javax.swing.JComponent /** * Tells IntelliJ how to use a [TemplateListEditor] to edit a [TemplateList] in the settings dialog. * - * Set [templateToSelect] before [createComponent] is invoked to determine which template should be selected when the + * Set [schemeToSelect] before [createComponent] is invoked to determine which template should be selected when the * configurable opens. * * This class is separate from [TemplateListEditor] because that class creates UI components in the constructor. But @@ -28,9 +28,23 @@ class TemplateListConfigurable : Configurable, Disposable { lateinit var editor: TemplateListEditor private set /** - * The UUID of the template to select after calling [createComponent]. + * The UUID of the scheme to select after calling [createComponent]. */ - var templateToSelect: String? = null + var schemeToSelect: String? = null + + + /** + * Returns the name of the configurable as displayed in the settings window. + */ + override fun getDisplayName() = "Randomness" + + /** + * Creates a new editor and returns the root pane of the created editor. + */ + override fun createComponent(): JComponent { + editor = TemplateListEditor(initialSelection = schemeToSelect).also { Disposer.register(this, it) } + return editor.rootComponent + } /** @@ -61,6 +75,7 @@ class TemplateListConfigurable : Configurable, Disposable { */ override fun reset() = editor.reset() + /** * Recursively disposes this configurable's resources. */ @@ -70,20 +85,4 @@ class TemplateListConfigurable : Configurable, Disposable { * Non-recursively disposes this configurable's resources. */ override fun dispose() = Unit - - - /** - * Creates a new editor and returns the root pane of the created editor. - */ - override fun createComponent(): JComponent { - editor = TemplateListEditor().also { Disposer.register(this, it) } - editor.queueSelection = templateToSelect - return editor.rootComponent - } - - - /** - * Returns the name of the configurable as displayed in the settings window. - */ - override fun getDisplayName() = "Randomness" } diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateListEditor.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListEditor.kt index cf0036133..68fff6241 100644 --- a/src/main/kotlin/com/fwdekker/randomness/template/TemplateListEditor.kt +++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListEditor.kt @@ -20,8 +20,8 @@ import com.fwdekker.randomness.uuid.UuidSchemeEditor import com.fwdekker.randomness.word.WordScheme import com.fwdekker.randomness.word.WordSchemeEditor import com.intellij.openapi.Disposable -import com.intellij.openapi.ui.Splitter import com.intellij.openapi.util.Disposer +import com.intellij.ui.JBSplitter import com.intellij.ui.OnePixelSplitter import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBEmptyBorder @@ -42,9 +42,14 @@ import javax.swing.SwingUtilities * contained within them, and on the right, a [SchemeEditor] for the currently-selected template or scheme is shown. * * @property originalTemplateList The templates to edit. + * @param initialSelection the UUID of the scheme to select initially, or `null` or an invalid UUID to select the first + * template * @see TemplateListConfigurable */ -class TemplateListEditor(val originalTemplateList: TemplateList = Settings.DEFAULT.templateList) : Disposable { +class TemplateListEditor( + val originalTemplateList: TemplateList = Settings.DEFAULT.templateList, + initialSelection: String? = null, +) : Disposable { /** * The root component of the editor. */ @@ -58,16 +63,9 @@ class TemplateListEditor(val originalTemplateList: TemplateList = Settings.DEFAU PreviewPanel { templateTree.selectedTemplate ?: StringScheme("") } .also { Disposer.register(this, it) } - /** - * The UUID of the scheme to select after the next invocation of [reset]. - * - * @see TemplateListConfigurable - */ - var queueSelection: String? = null - init { - val splitter = createSplitter(false, SPLITTER_PROPORTION_KEY, DEFAULT_SPLITTER_PROPORTION) + val splitter = createSplitter() rootComponent.add(splitter, BorderLayout.CENTER) // Left half @@ -84,6 +82,14 @@ class TemplateListEditor(val originalTemplateList: TemplateList = Settings.DEFAU // Load current state reset() templateTree.expandNodes() + + // Select a scheme + initialSelection + ?.let { currentTemplateList.getSchemeByUuid(it) } + ?.also { + templateTree.selectedScheme = it + SwingUtilities.invokeLater { schemeEditor?.preferredFocusedComponent?.requestFocus() } + } } /** @@ -125,6 +131,18 @@ class TemplateListEditor(val originalTemplateList: TemplateList = Settings.DEFAU schemeEditorPanel.revalidate() // Show editor immediately } + /** + * Creates a new splitter. + * + * If a test that depends on [TemplateListEditor] freezes for a long time or fails to initialize the UI, try + * setting [useTestSplitter] to `true`. + * + * @return the created splitter + */ + private fun createSplitter() = + if (useTestSplitter) JBSplitter(false, SPLITTER_PROPORTION_KEY, DEFAULT_SPLITTER_PROPORTION) + else OnePixelSplitter(false, SPLITTER_PROPORTION_KEY, DEFAULT_SPLITTER_PROPORTION) + /** * Creates an editor to edit [scheme]. */ @@ -174,13 +192,6 @@ class TemplateListEditor(val originalTemplateList: TemplateList = Settings.DEFAU currentTemplateList.applyContext((+currentTemplateList.context).copy(templateList = currentTemplateList)) templateTree.reload() - - queueSelection?.also { - templateTree.selectedScheme = currentTemplateList.getSchemeByUuid(it) - SwingUtilities.invokeLater { schemeEditor?.preferredFocusedComponent?.requestFocus() } - - queueSelection = null - } } /** @@ -200,8 +211,8 @@ class TemplateListEditor(val originalTemplateList: TemplateList = Settings.DEFAU /** * Scrolls to the newly focused element if that element is in the [editor]. */ - override fun propertyChange(event: PropertyChangeEvent?) { - val focused = event?.newValue as? JComponent + override fun propertyChange(event: PropertyChangeEvent) { + val focused = event.newValue as? JComponent if (focused == null || !editor.rootComponent.isAncestorOf(focused)) return @@ -245,14 +256,8 @@ class TemplateListEditor(val originalTemplateList: TemplateList = Settings.DEFAU const val EDITOR_PANEL_MARGIN = 10 /** - * Creates a new splitter. - * - * For some reason, `OnePixelSplitter` does not work in tests. Therefore, tests overwrite this field to inject a - * different constructor. + * Whether [createSplitter] should use a separate kind of splitter that is more compatible with tests. */ - var createSplitter: (Boolean, String, Float) -> Splitter = - { vertical, proportionKey, defaultProportion -> - OnePixelSplitter(vertical, proportionKey, defaultProportion) - } + var useTestSplitter: Boolean = false } } diff --git a/src/test/kotlin/com/fwdekker/randomness/template/TemplateListConfigurableTest.kt b/src/test/kotlin/com/fwdekker/randomness/template/TemplateListConfigurableTest.kt new file mode 100644 index 000000000..85baf0fba --- /dev/null +++ b/src/test/kotlin/com/fwdekker/randomness/template/TemplateListConfigurableTest.kt @@ -0,0 +1,117 @@ +package com.fwdekker.randomness.template + +import com.fwdekker.randomness.Settings +import com.fwdekker.randomness.testhelpers.afterNonContainer +import com.fwdekker.randomness.testhelpers.beforeNonContainer +import com.fwdekker.randomness.testhelpers.guiGet +import com.fwdekker.randomness.testhelpers.guiRun +import com.fwdekker.randomness.testhelpers.matchBundle +import com.fwdekker.randomness.testhelpers.shouldContainExactly +import com.intellij.openapi.options.ConfigurationException +import com.intellij.testFramework.fixtures.IdeaTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.NamedTag +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.assertj.swing.edt.FailOnThreadViolationRepaintManager +import org.assertj.swing.fixture.Containers +import org.assertj.swing.fixture.FrameFixture + + +/** + * Unit tests for [TemplateListConfigurable]. + */ +class TemplateListConfigurableTest : FunSpec({ + tags(NamedTag("IdeaFixture")) + + + lateinit var ideaFixture: IdeaTestFixture + lateinit var frame: FrameFixture + + lateinit var configurable: TemplateListConfigurable + + + beforeContainer { + FailOnThreadViolationRepaintManager.install() + TemplateListEditor.useTestSplitter = true + } + + beforeNonContainer { + ideaFixture = IdeaTestFixtureFactory.getFixtureFactory().createBareFixture() + ideaFixture.setUp() + + configurable = TemplateListConfigurable() + frame = Containers.showInFrame(guiGet { configurable.createComponent() }) + } + + afterNonContainer { + frame.cleanUp() + guiRun { configurable.disposeUIResources() } + ideaFixture.tearDown() + } + + + context("templateToSelect") { + test("selects the template with the given UUID") { + frame.cleanUp() + guiRun { configurable.disposeUIResources() } + + configurable = TemplateListConfigurable() + configurable.schemeToSelect = Settings.DEFAULT.templates[2].uuid + frame = Containers.showInFrame(guiGet { configurable.createComponent() }) + + guiGet { frame.tree().target().selectionRows!! } shouldContainExactly arrayOf(4) + } + } + + + context("isModified") { + test("returns `false` if no modifications were made") { + configurable.isModified shouldBe false + } + + test("returns `true` if modifications were made") { + guiRun { frame.textBox("templateName").target().text = "New Name" } + + configurable.isModified shouldBe true + } + + test("returns `true` if no modifications were made but the template list is invalid") { + Settings.DEFAULT.templates[0].name = "" + guiRun { configurable.reset() } + + configurable.editor.isModified() shouldBe false + configurable.isModified shouldBe true + } + } + + context("apply") { + test("throws an exception if the template list is invalid") { + guiRun { frame.textBox("templateName").target().text = "" } + + shouldThrow { configurable.apply() } + .title should matchBundle("template_list.error.failed_to_save_settings") + } + + test("applies the changes") { + guiRun { frame.textBox("templateName").target().text = "New Name" } + + configurable.apply() + + Settings.DEFAULT.templates[0].name shouldBe "New Name" + } + } + + context("reset") { + test("resets the editor") { + guiRun { frame.textBox("templateName").target().text = "Changed Name" } + + guiRun { configurable.reset() } + + guiGet { frame.textBox("templateName").target().text } shouldNotBe "Changed Name" + } + } +}) diff --git a/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt b/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt index c62eaac91..95079a658 100644 --- a/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt +++ b/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt @@ -19,15 +19,17 @@ import com.fwdekker.randomness.word.WordScheme import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.IdeaTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory -import com.intellij.ui.JBSplitter import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.NamedTag import io.kotest.core.spec.style.FunSpec import io.kotest.data.Row2 import io.kotest.data.row import io.kotest.datatest.withData +import io.kotest.matchers.nulls.beNull import io.kotest.matchers.should import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNot +import io.kotest.matchers.shouldNotBe import org.assertj.swing.edt.FailOnThreadViolationRepaintManager import org.assertj.swing.fixture.AbstractComponentFixture import org.assertj.swing.fixture.Containers @@ -50,9 +52,7 @@ object TemplateListEditorTest : FunSpec({ beforeContainer { FailOnThreadViolationRepaintManager.install() - - TemplateListEditor.createSplitter = - { vertical, proportionKey, defaultProportion -> JBSplitter(vertical, proportionKey, defaultProportion) } + TemplateListEditor.useTestSplitter = true } beforeNonContainer { @@ -79,6 +79,29 @@ object TemplateListEditorTest : FunSpec({ } + context("initialSelection") { + withData( + mapOf String?, Int>>( + "selects the first template if set to `null`" to + row({ null }, 0), + "selects the first template if set to an invalid UUID" to + row({ "invalid" }, 0), + "selects the template with the given UUID" to + row({ templateList.templates[1].uuid }, 3), + "selects the scheme with the given UUID" to + row({ templateList.templates[1].schemes[0].uuid }, 4), + ) + ) { (uuid, expectedSelection) -> + frame.cleanUp() + guiRun { Disposer.dispose(editor) } + + editor = guiGet { TemplateListEditor(templateList, initialSelection = uuid()) } + frame = Containers.showInFrame(editor.rootComponent) + + guiGet { frame.tree().target().selectionRows!! } shouldContainExactly arrayOf(expectedSelection) + } + } + context("editor creation") { context("loads the appropriate editor") { withData( @@ -125,70 +148,106 @@ object TemplateListEditorTest : FunSpec({ } - context("reset") { - context("undoing changes") { - test("undoes changes to the current selection") { - guiRun { - frame.tree().target().setSelectionRow(1) - frame.spinner("minValue").target().value = 7L - } + context("doValidate") { + test("returns `null` for the default list") { + guiGet { editor.doValidate() } should beNull() + } - guiRun { editor.reset() } + test("returns `null` if the template list is valid") { + templateList.templates.setAll(listOf(Template(schemes = mutableListOf(DummyScheme())))) + templateList.applyContext(templateList.context) + guiRun { editor.reset() } - frame.spinner("minValue").target().value shouldBe 0L - } + guiGet { editor.doValidate() } should beNull() + } - test("undoes changes to another selection") { - guiRun { - frame.tree().target().setSelectionRow(1) - frame.spinner("minValue").target().value = 7L - frame.tree().target().setSelectionRow(3) - } + test("returns a string if the template list is invalid") { + templateList.templates.setAll(listOf(Template(schemes = mutableListOf(DummyScheme(valid = false))))) + templateList.applyContext(templateList.context) + guiRun { editor.reset() } + + guiGet { editor.doValidate() } shouldNot beNull() + } + } - guiRun { editor.reset() } + context("isModified") { + test("returns `false` if no modifications have been made") { + guiGet { editor.isModified() } shouldBe false + } - guiRun { - frame.tree().target().setSelectionRow(1) - frame.spinner("minValue").target().value shouldBe 0L - } + test("returns `true` if modifications have been made") { + guiRun { + frame.tree().target().selectionRows = intArrayOf(1) + frame.spinner("minValue").target().value = 1 } + + guiGet { editor.isModified() } shouldBe true } - context("selection") { - test("retains the existing selection if `queueSelection` is null") { - guiRun { frame.tree().target().selectionRows = intArrayOf(1) } + test("returns `false` if modifications have been reset") { + guiRun { + frame.tree().target().selectionRows = intArrayOf(1) + frame.spinner("minValue").target().value = 1 + } - editor.queueSelection = null - guiRun { editor.reset() } + guiRun { editor.reset() } + + guiGet { editor.isModified() } shouldBe false + } + } - frame.tree().target().selectionRows!! shouldContainExactly arrayOf(1) + context("apply") { + test("applies changes to the original list") { + guiRun { + frame.tree().target().selectionRows = intArrayOf(1) + frame.spinner("minValue").target().value = 3 } - test("clears the existing selection if the indicated scheme could not be found") { - guiRun { frame.tree().target().selectionRows = intArrayOf(3) } + (templateList.templates[0].schemes[0] as IntegerScheme).minValue shouldNotBe 3 + guiRun { editor.apply() } - editor.queueSelection = "231ee9da-8f72-4535-b770-0119fdf68f70" - guiRun { editor.reset() } + (templateList.templates[0].schemes[0] as IntegerScheme).minValue shouldBe 3 + } - frame.tree().target().selectionCount shouldBe 0 - } + test("does not couple the applied state to the editor's internal state") { + guiRun { + frame.tree().target().selectionRows = intArrayOf(2) + frame.textBox("pattern").target().text = "old" - test("selects the indicated template") { - guiRun { frame.tree().target().selectionRows = intArrayOf(4) } + guiRun { editor.apply() } - editor.queueSelection = templateList.templates[1].uuid - guiRun { editor.reset() } + frame.tree().target().selectionRows = intArrayOf(2) + frame.textBox("pattern").target().text = "new" + } + + (templateList.templates[0].schemes[1] as StringScheme).pattern shouldBe "old" + } + } - frame.tree().target().selectionRows!! shouldContainExactly arrayOf(3) + context("reset") { + test("undoes changes to the current selection") { + guiRun { + frame.tree().target().setSelectionRow(1) + frame.spinner("minValue").target().value = 7L } - test("selects the indicated scheme") { - guiRun { frame.tree().target().selectionRows = intArrayOf(2) } + guiRun { editor.reset() } + + frame.spinner("minValue").target().value shouldBe 0L + } + + test("undoes changes to another selection") { + guiRun { + frame.tree().target().setSelectionRow(1) + frame.spinner("minValue").target().value = 7L + frame.tree().target().setSelectionRow(3) + } - editor.queueSelection = templateList.templates[1].schemes[0].uuid - guiRun { editor.reset() } + guiRun { editor.reset() } - frame.tree().target().selectionRows!! shouldContainExactly arrayOf(4) + guiRun { + frame.tree().target().setSelectionRow(1) + frame.spinner("minValue").target().value shouldBe 0L } } }