diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c20f0fc..672f1cd5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Check the plugin description for more details and animated usage examples. * In addition to a list of standard separators, you can now also choose your own separator for all data types, including for arrays. * You can automatically pad (or truncate) integers to a specific length. +* Future backwards compatibility, ensuring that your settings can always be imported into future versions. ### Changed * Randomness now uses templates to generate data. diff --git a/gradle.properties b/gradle.properties index 1ad74a934..f1fb348f6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,6 @@ group=com.fwdekker # TODO: Also remove `beta` from `PersistentSettings' annotation`! +# Version number should also be updated in `com.fwdekker.randomness.PersistentSettings.Companion.CURRENT_VERSION`. version=3.0.0-beta.3 # Compatibility diff --git a/src/main/kotlin/com/fwdekker/randomness/Settings.kt b/src/main/kotlin/com/fwdekker/randomness/Settings.kt index c4abd8eea..420bfb784 100644 --- a/src/main/kotlin/com/fwdekker/randomness/Settings.kt +++ b/src/main/kotlin/com/fwdekker/randomness/Settings.kt @@ -1,21 +1,29 @@ package com.fwdekker.randomness +import com.fwdekker.randomness.PersistentSettings.Companion.CURRENT_VERSION import com.fwdekker.randomness.template.Template import com.fwdekker.randomness.template.TemplateList import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.SettingsCategory import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service +import com.intellij.util.xmlb.XmlSerializer import com.intellij.util.xmlb.annotations.Transient +import org.jdom.Element +import java.lang.module.ModuleDescriptor import com.intellij.openapi.components.State as JBState /** * Contains references to various [State] objects. * + * @property version The version of Randomness with which these settings were created. * @property templateList The template list. */ -data class Settings(var templateList: TemplateList = TemplateList()) : State() { +data class Settings( + var version: String = CURRENT_VERSION, + var templateList: TemplateList = TemplateList(), +) : State() { /** * @see TemplateList.templates */ @@ -43,36 +51,64 @@ data class Settings(var templateList: TemplateList = TemplateList()) : State() { /** * The persistent [Settings] instance. */ - val DEFAULT: Settings by lazy { PersistentSettings.default.state } + val DEFAULT: Settings by lazy { service().settings } } } /** - * The actual user's actual stored actually-serialized settings (actually). + * The persistent [Settings] instance, stored as an [Element] to allow custom conversion for backwards compatibility. + * + * @see Settings.DEFAULT Preferred method of accessing the persistent settings instance. */ @JBState( name = "Randomness", storages = [ - Storage("\$APP_CONFIG\$/randomness.xml", deprecated = true), - Storage("\$APP_CONFIG\$/randomness-beta.xml", exportable = true), + Storage("randomness.xml", deprecated = true), + Storage("randomness-beta.xml", exportable = true), ], category = SettingsCategory.PLUGINS, ) -class PersistentSettings : PersistentStateComponent { - private val settings = Settings() +class PersistentSettings : PersistentStateComponent { + /** + * The persistent settings instance. + * + * @see Settings.DEFAULT Preferred method of accessing the persistent settings instance. + */ + val settings = Settings() /** - * Returns the template list. + * Returns the [settings] as an [Element]. */ - override fun getState() = settings + override fun getState(): Element = XmlSerializer.serialize(settings) /** - * Copies [settings] into `this`. + * Deserializes [element] into a [Settings] instance, which is then copied into the [settings] instance. * * @see TemplateList.copyFrom */ - override fun loadState(settings: Settings) = this.settings.copyFrom(settings) + override fun loadState(element: Element) = + settings.copyFrom(XmlSerializer.deserialize(upgrade(element), Settings::class.java)) + + + /** + * Silently upgrades the format of the settings contained in [element] to the format of the latest version. + */ + private fun upgrade(element: Element): Element { + val elementVersion = element.getAttributeValueByName("version")?.let { ModuleDescriptor.Version.parse(it) } + + when { + elementVersion == null -> Unit + + // Placeholder to show how an upgrade might work. Remove this once an actual upgrade has been added. + elementVersion < ModuleDescriptor.Version.parse("0.0.0-placeholder") -> + element.getContentByPath("templateList", null, "templates", null)?.getElements() + ?.forEachIndexed { idx, template -> template.setAttributeValueByName("name", "Template$idx") } + } + + element.setAttributeValueByName("version", CURRENT_VERSION) + return element + } /** @@ -80,8 +116,8 @@ class PersistentSettings : PersistentStateComponent { */ companion object { /** - * The persistent instance. + * The currently-running version of Randomness. */ - val default: PersistentSettings get() = service() + const val CURRENT_VERSION: String = "3.0.0-beta.3" } } diff --git a/src/main/kotlin/com/fwdekker/randomness/XmlHelpers.kt b/src/main/kotlin/com/fwdekker/randomness/XmlHelpers.kt new file mode 100644 index 000000000..fe9082f42 --- /dev/null +++ b/src/main/kotlin/com/fwdekker/randomness/XmlHelpers.kt @@ -0,0 +1,49 @@ +package com.fwdekker.randomness + +import org.jdom.Element + + +/** + * Returns a list of all [Element]s contained in this `Element`. + */ +fun Element.getElements(): List = + content().toList().filterIsInstance() + +/** + * Returns the [Element] contained in this `Element` that has attribute `name="[name]"`, or `null` if no single such + * `Element` exists. + */ +fun Element.getContentByName(name: String): Element? = + getContent { (it as Element).getAttribute("name")?.value == name }.singleOrNull() + +/** + * Returns the value of the `value` attribute of the single [Element] contained in this `Element` that has attribute + * `name="[name]"`, or `null` if no single such `Element` exists. + */ +fun Element.getAttributeValueByName(name: String): String? = + getContentByName(name)?.getAttribute("value")?.value + +/** + * Sets the value of the `value` attribute of the single [Element] contained in this `Element` that has attribute + * `name="[name]"` to [value], or does nothing if no single such `Element` exists. + */ +fun Element.setAttributeValueByName(name: String, value: String) { + getContentByName(name)?.setAttribute("value", value) +} + +/** + * Returns the single [Element] that is contained in this `Element`, or `null` if this `Element` does not contain + * exactly one `Element`. + */ +fun Element.getSingleContent(): Element? = + content.singleOrNull() as? Element + +/** + * Traverses a path of [Element]s based on their [names] by monadically calling either [getContentByName] (if the name + * is not `null`) or [getSingleContent] (if the name is `null`). + */ +fun Element.getContentByPath(vararg names: String?): Element? = + names.fold(this as? Element) { acc, name -> + if (name == null) acc?.getSingleContent() + else acc?.getContentByName(name) + } diff --git a/src/test/kotlin/com/fwdekker/randomness/SettingsTest.kt b/src/test/kotlin/com/fwdekker/randomness/SettingsTest.kt index 1af6b1c93..a98771cd0 100644 --- a/src/test/kotlin/com/fwdekker/randomness/SettingsTest.kt +++ b/src/test/kotlin/com/fwdekker/randomness/SettingsTest.kt @@ -22,7 +22,12 @@ object SettingsTest : FunSpec({ "succeeds for default state" to row(Settings(), null), "fails if template list is invalid" to - row(Settings(TemplateList(mutableListOf(Template("Duplicate"), Template("Duplicate")))), ""), + row( + Settings( + templateList = TemplateList(mutableListOf(Template("Duplicate"), Template("Duplicate"))), + ), + "", + ), ) ) { (scheme, validation) -> scheme shouldValidateAsBundle validation @@ -31,7 +36,7 @@ object SettingsTest : FunSpec({ context("deepCopy") { test("deep-copies the template list") { - val settings = Settings(TemplateList(mutableListOf(Template("old")))) + val settings = Settings(templateList = TemplateList(mutableListOf(Template("old")))) val copy = settings.deepCopy() copy.templates[0].name = "new" @@ -49,8 +54,8 @@ object SettingsTest : FunSpec({ } test("deep-copies the template list") { - val settings = Settings(TemplateList(mutableListOf(Template("old")))) - val other = Settings(TemplateList(mutableListOf(Template("new")))) + val settings = Settings(templateList = TemplateList(mutableListOf(Template("old")))) + val other = Settings(templateList = TemplateList(mutableListOf(Template("new")))) settings.copyFrom(other) diff --git a/src/test/kotlin/com/fwdekker/randomness/XmlHelpersTest.kt b/src/test/kotlin/com/fwdekker/randomness/XmlHelpersTest.kt new file mode 100644 index 000000000..f1039503a --- /dev/null +++ b/src/test/kotlin/com/fwdekker/randomness/XmlHelpersTest.kt @@ -0,0 +1,297 @@ +package com.fwdekker.randomness + +import com.intellij.openapi.util.JDOMUtil +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe + + +/** + * Unit tests for extension functions in `XmlHelpersTest`. + */ +object XmlHelpersTest : FunSpec({ + context("getElements") { + test("returns all contained elements") { + val element = JDOMUtil.load( + """ + + + + + + + """.trimIndent() + ) + + val elements = element.getElements() + + elements.map { it.name } shouldContainExactly listOf("content1", "content2", "content3", "content4") + } + } + + context("getContentByName") { + test("returns the child with the given name") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + + element.getContentByName("needle")?.name shouldBe "content2" + } + + test("returns `null` if no children have the given name") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + + element.getContentByName("needle")?.name shouldBe null + } + + test("returns `null` if multiple children have the given name") { + val element = JDOMUtil.load( + """ + + + + + + + """.trimIndent() + ) + + element.getContentByName("needle")?.name shouldBe null + } + } + + context("getAttributeValueByName") { + test("returns value attribute of the child with the given name") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + + element.getAttributeValueByName("needle") shouldBe "value" + } + + test("returns `null` if no children have the given name") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + + element.getAttributeValueByName("needle") shouldBe null + } + + test("returns `null` if multiple children have the given name") { + val element = JDOMUtil.load( + """ + + + + + + + """.trimIndent() + ) + + element.getAttributeValueByName("needle") shouldBe null + } + + test("returns `null` if the child does not have a value attribute") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + + element.getAttributeValueByName("needle") shouldBe null + } + } + + context("setAttributeValueByName") { + test("changes the child's value to the given value") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + element.getAttributeValueByName("needle") shouldBe "value" + + element.setAttributeValueByName("needle", "new-value") + + element.getAttributeValueByName("needle") shouldBe "new-value" + } + + test("adds an attribute if the child does not have a value attribute") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + element.getAttributeValueByName("needle") shouldBe null + + element.setAttributeValueByName("needle", "new-value") + + element.getAttributeValueByName("needle") shouldBe "new-value" + } + + test("does nothing if no children have the given name") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + + val copy = JDOMUtil.load(JDOMUtil.write(element, "")) + element.setAttributeValueByName("needle", "new-value") + + JDOMUtil.write(element, "") shouldBe JDOMUtil.write(copy, "") + } + + test("does nothing if multiple children have the given name") { + val element = JDOMUtil.load( + """ + + + + + + + """.trimIndent() + ) + + val copy = JDOMUtil.load(JDOMUtil.write(element, "")) + element.setAttributeValueByName("needle", "new-value") + + JDOMUtil.write(element, "") shouldBe JDOMUtil.write(copy, "") + } + } + + context("getSingleContent") { + test("returns the singular child") { + val element = JDOMUtil.load("") + + element.getSingleContent()?.name shouldBe "child" + } + + test("returns `null` if there are no children") { + val element = JDOMUtil.load("") + + element.getSingleContent() shouldBe null + } + + test("returns `null` if there are multiple children") { + val element = JDOMUtil.load( + """ + + + + + """.trimIndent() + ) + + element.getSingleContent() shouldBe null + } + } + + context("getContentByPath") { + test("returns the child with the given name") { + val element = JDOMUtil.load( + """ + + + + + + """.trimIndent() + ) + + element.getContentByPath("needle")?.name shouldBe "content2" + } + + test("returns the singular child if `null` is given") { + val element = JDOMUtil.load("") + + element.getContentByPath(null)?.name shouldBe "child" + } + + test("returns the element at the given path") { + val element = JDOMUtil.load( + """ + + + + + + + + + + + + + """.trimIndent() + ) + + element.getContentByPath("needle", null, "prong", null)?.name shouldBe "target" + } + + test("returns `null` if no element matches the path") { + val element = JDOMUtil.load( + """ + + + + + + + + + + + + + """.trimIndent() + ) + + element.getContentByPath("wrong", null, "prong", null)?.name shouldBe null + } + } +}) diff --git a/src/test/kotlin/com/fwdekker/randomness/template/TemplateJTreeTest.kt b/src/test/kotlin/com/fwdekker/randomness/template/TemplateJTreeTest.kt index bb9995dcd..bc04985a2 100644 --- a/src/test/kotlin/com/fwdekker/randomness/template/TemplateJTreeTest.kt +++ b/src/test/kotlin/com/fwdekker/randomness/template/TemplateJTreeTest.kt @@ -58,13 +58,13 @@ object TemplateJTreeTest : FunSpec({ originalSettings = Settings( - TemplateList( + templateList = TemplateList( mutableListOf( Template("Template0", mutableListOf(DummyScheme("Scheme0"), DummyScheme("Scheme1"))), Template("Template1", mutableListOf(DummyScheme("Scheme2"))), - Template("Template2", mutableListOf(DummyScheme("Scheme3"), DummyScheme("Scheme4"))) + Template("Template2", mutableListOf(DummyScheme("Scheme3"), DummyScheme("Scheme4"))), ) - ) + ), ) originalSettings.applyContext(originalSettings) currentSettings = originalSettings.deepCopy(retainUuid = true) diff --git a/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt b/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt index bef1c0aef..9c8427591 100644 --- a/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt +++ b/src/test/kotlin/com/fwdekker/randomness/template/TemplateListEditorTest.kt @@ -57,12 +57,12 @@ object TemplateListEditorTest : FunSpec({ ideaFixture.setUp() context = Settings( - TemplateList( + templateList = TemplateList( mutableListOf( Template("Template1", mutableListOf(IntegerScheme(), StringScheme())), - Template("Template2", mutableListOf(DecimalScheme(), WordScheme())) + Template("Template2", mutableListOf(DecimalScheme(), WordScheme())), ) - ) + ), ) context.templateList.applyContext(context) diff --git a/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceEditorTest.kt b/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceEditorTest.kt index faab8a366..7f9c32471 100644 --- a/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceEditorTest.kt +++ b/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceEditorTest.kt @@ -48,13 +48,13 @@ object TemplateReferenceEditorTest : FunSpec({ ideaFixture.setUp() context = Settings( - TemplateList( + templateList = TemplateList( mutableListOf( Template("Template0", mutableListOf(DummyScheme())), Template("Template1", mutableListOf(TemplateReference())), Template("Template2", mutableListOf(DummyScheme())), ) - ) + ), ) reference = context.templates[1].schemes[0] as TemplateReference diff --git a/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceTest.kt b/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceTest.kt index f30ab8597..fcffceb65 100644 --- a/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceTest.kt +++ b/src/test/kotlin/com/fwdekker/randomness/template/TemplateReferenceTest.kt @@ -40,7 +40,7 @@ object TemplateReferenceTest : FunSpec({ referencingTemplate = Template("referencing", mutableListOf(reference)) list = TemplateList(mutableListOf(referencedTemplate, referencingTemplate)) - list.applyContext(Settings(list)) + list.applyContext(Settings(templateList = list)) } @@ -178,7 +178,7 @@ object TemplateReferenceTest : FunSpec({ test("changes the list of templates into which this reference refers") { reference.template shouldNot beNull() - reference.applyContext(Settings(TemplateList(mutableListOf()))) + reference.applyContext(Settings(templateList = TemplateList(mutableListOf()))) reference.template should beNull() } @@ -198,7 +198,7 @@ object TemplateReferenceTest : FunSpec({ list.templates += safeTemplate referencedTemplate.schemes += TemplateReference(referencingTemplate.uuid) - list.applyContext(Settings(list)) + list.applyContext(Settings(templateList = list)) reference.canReference(safeTemplate) shouldBe true } @@ -210,7 +210,7 @@ object TemplateReferenceTest : FunSpec({ test("returns false if the reference would create a cycle") { referencedTemplate.schemes += TemplateReference(referencingTemplate.uuid) - list.applyContext(Settings(list)) + list.applyContext(Settings(templateList = list)) reference.canReference(referencedTemplate) shouldBe false } @@ -220,7 +220,7 @@ object TemplateReferenceTest : FunSpec({ list.templates += otherReferencedTemplate referencedTemplate.schemes += TemplateReference(otherReferencedTemplate.uuid) - list.applyContext(Settings(list)) + list.applyContext(Settings(templateList = list)) reference.canReference(referencedTemplate) shouldBe false } @@ -232,7 +232,7 @@ object TemplateReferenceTest : FunSpec({ list.templates += cycleTemplate2 cycleTemplate1.schemes += TemplateReference(cycleTemplate2.uuid) - list.applyContext(Settings(list)) + list.applyContext(Settings(templateList = list)) reference.canReference(referencedTemplate) shouldBe true }