Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ build/
.gradle/
.env
*.png~
*.DS_Store
*.DS_Store
.kotlin/
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.notenoughupdates.moulconfig.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
annotation class ConfigOrder(
/**
* The order of this option within its category.
* Lower values appear first. Options without this annotation
* default to 0, and appear in declaration order.
*/
val value: Int = 0
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.github.notenoughupdates.moulconfig.annotations

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
annotation class ConfigOverride(
/**
* Controls placement of this field relative to its siblings, inheriting the overridden parent
* field's [ConfigOrder] value by default. Set explicitly to override that behaviour.
*
* Uses [Int.MIN_VALUE] as a sentinel to indicate inheritance — do not use that value directly.
*/
val overrideOrder: Int = Int.MIN_VALUE
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import io.github.notenoughupdates.moulconfig.annotations.ConfigEditorButton;
import io.github.notenoughupdates.moulconfig.annotations.ConfigEditorInfoText;
import io.github.notenoughupdates.moulconfig.annotations.ConfigOption;
import io.github.notenoughupdates.moulconfig.annotations.ConfigOrder;
import io.github.notenoughupdates.moulconfig.annotations.ConfigOverride;
import io.github.notenoughupdates.moulconfig.internal.BoundField;
import io.github.notenoughupdates.moulconfig.internal.Warnings;
import lombok.var;
Expand All @@ -38,8 +40,11 @@
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Stack;

Expand All @@ -61,10 +66,50 @@ public ConfigProcessorDriver(ConfigStructureReader reader) {
this.reader = reader;
}

private static List<Field> getAllFields(Class<?> type) {
private static List<Field> getSortedFields(Class<?> type) {
if (type == null) return new ArrayList<>();
List<Field> fields = getAllFields(type.getSuperclass());
fields.addAll(Arrays.asList(type.getDeclaredFields()));
List<Field> fields = getSortedFields(type.getSuperclass());
Map<Field, Integer> effectiveOrders = new IdentityHashMap<>();
for (Field f : fields) {
ConfigOrder order = f.getAnnotation(ConfigOrder.class);
effectiveOrders.put(f, order != null ? order.value() : 0);
}

for (Field field : type.getDeclaredFields()) {
int parentIndex = -1;
Field parent = null;
for (int i = 0; i < fields.size(); i++) {
if (fields.get(i).getName().equals(field.getName())) {
parentIndex = i;
parent = fields.get(i);
break;
}
}
if (parent != null) {
fields.remove(parentIndex);
if (field.getAnnotation(ConfigOverride.class) == null) {
Warnings.warn("Field " + field.getName() + " in " + type + " shadows a parent field. Add @ConfigOverride to suppress this warning.");
}
}

ConfigOverride override = field.getAnnotation(ConfigOverride.class);
ConfigOrder order = field.getAnnotation(ConfigOrder.class);
int effectiveOrder;
if (override != null && override.overrideOrder() == Integer.MIN_VALUE && parent != null) {
effectiveOrder = effectiveOrders.getOrDefault(parent, 0);
} else if (order != null) {
effectiveOrder = order.value();
} else {
effectiveOrder = 0;
}
effectiveOrders.put(field, effectiveOrder);

// Slot into parents' position, if it exists
if (parentIndex >= 0) fields.add(parentIndex, field);
else fields.add(field);
}

fields.sort(Comparator.comparingInt(effectiveOrders::get));
return fields;
}

Expand All @@ -73,7 +118,7 @@ public void processCategory(Object categoryObject,
Class<?> categoryClass = categoryObject.getClass();
Stack<Integer> accordionStack = new Stack<>();
Set<Integer> usedAccordionIds = new HashSet<>();
for (Field field : getAllFields(categoryClass)) {
for (Field field : getSortedFields(categoryClass)) {
if (field.getAnnotation(Category.class) != null) {
deferredSubCategories.add(new BoundField(field, categoryObject));
}
Expand Down Expand Up @@ -187,7 +232,7 @@ private void processCategoryMeta(

public void processConfig(Config configObject) {
reader.beginConfig(configObject.getClass(), this, configObject);
for (Field categoryField : getAllFields(configObject.getClass())) {
for (Field categoryField : getSortedFields(configObject.getClass())) {
processCategoryMeta(configObject, categoryField, null);
}
reader.endConfig();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package io.github.notenoughupdates.moulconfig

import io.github.notenoughupdates.moulconfig.annotations.ConfigOrder
import io.github.notenoughupdates.moulconfig.annotations.ConfigOverride
import io.github.notenoughupdates.moulconfig.internal.MCLogger
import io.github.notenoughupdates.moulconfig.internal.Warnings
import io.github.notenoughupdates.moulconfig.processor.ConfigProcessorDriver
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.lang.reflect.Field

/**
* Tests for field ordering and override deduplication in [ConfigProcessorDriver.getSortedFields].
*
* Covers:
* - Declaration order is preserved without annotations
* - [ConfigOrder] sorts fields within and across classes
* - Shadowed fields without [ConfigOverride] emit a warning
* - [ConfigOverride] suppresses that warning and slots into the parent's position
* - [ConfigOverride] inherits the parent's [ConfigOrder] value by default
* - [ConfigOverride.overrideOrder] takes precedence over inherited order when set
* - Correct behavior through multiple levels of inheritance
*/
@Suppress("unused")
class ConfigFieldOrderTest {

open class SingleClass {
val a = Unit
val b = Unit
val c = Unit
}

open class OrderedSingleClass {
@ConfigOrder(3)
val c = Unit
@ConfigOrder(1)
val a = Unit
@ConfigOrder(2)
val b = Unit
}

open class Parent {
val x = Unit
open val y = Unit
val z = Unit
}

open class ChildAppendsField : Parent() {
val w = Unit
}

open class ChildShadowsWithoutAnnotation : Parent() {
override val y = Unit
}

open class ChildShadowsWithAnnotation : Parent() {
@ConfigOverride
override val y = Unit
}

open class ParentWithOrderedField {
val a = Unit
@ConfigOrder(5)
open val b = Unit
val c = Unit
}

open class ChildInheritsParentOrder : ParentWithOrderedField() {
@ConfigOverride
override val b = Unit
}

open class ChildExplicitOverrideOrder : ParentWithOrderedField() {
@ConfigOverride(overrideOrder = 99)
override val b = Unit
}

open class GrandParent {
val p = Unit
open val q = Unit
}

open class MiddleParent : GrandParent() {
val r = Unit
}

open class GrandChild : MiddleParent() {
@ConfigOverride
override val q = Unit
}

private val getSortedFields = ConfigProcessorDriver::class.java
.getDeclaredMethod("getSortedFields", Class::class.java)
.also { it.isAccessible = true }

private val capturedWarnings = mutableListOf<String>()
private var previousLogger: MCLogger? = null

@BeforeEach
fun installCapturingLogger() {
previousLogger = Warnings.logger
Warnings.shouldWarn = true
Warnings.logger = object : MCLogger {
override fun warn(text: String) { capturedWarnings.add(text) }
override fun info(text: String) = Unit
override fun error(text: String, throwable: Throwable) = Unit
}
}

@AfterEach
fun restoreLogger() {
Warnings.logger = previousLogger
capturedWarnings.clear()
}

@Suppress("UNCHECKED_CAST")
private fun sortedFields(type: Class<*>) = getSortedFields.invoke(null, type) as List<Field>
private fun fieldNames(type: Class<*>) = sortedFields(type).map { it.name }

/** Declaration order of fields within a single class should be preserved when no ordering annotations are present. */
@Test fun `declaration order is preserved without annotations`() =
assertEquals(listOf("a", "b", "c"), fieldNames(SingleClass::class.java))

/** [ConfigOrder] should sort fields by ascending value, regardless of declaration order. */
@Test fun `ConfigOrder sorts fields within a class`() =
assertEquals(listOf("a", "b", "c"), fieldNames(OrderedSingleClass::class.java))

/** Fields declared in a parent class should appear before fields declared in the child. */
@Test fun `parent fields appear before child fields`() =
assertEquals(listOf("x", "y", "z", "w"), fieldNames(ChildAppendsField::class.java))

/** Shadowing a parent field without [ConfigOverride] should emit a warning. */
@Test fun `shadowing without ConfigOverride emits a warning`() {
sortedFields(ChildShadowsWithoutAnnotation::class.java)
assertTrue(capturedWarnings.any { "y" in it })
}

/** [ConfigOverride] should suppress the shadow warning. */
@Test fun `ConfigOverride suppresses shadow warning`() {
sortedFields(ChildShadowsWithAnnotation::class.java)
assertTrue(capturedWarnings.none { "y" in it })
}

/** [ConfigOverride] should slot the child field back into the parent field's original position. */
@Test fun `ConfigOverride slots child field into parent position`() {
val fields = sortedFields(ChildShadowsWithAnnotation::class.java)
assertEquals(listOf("x", "y", "z"), fields.map { it.name })
assertEquals(ChildShadowsWithAnnotation::class.java, fields[1].declaringClass)
}

/** [ConfigOverride] without an explicit [ConfigOverride.overrideOrder] should inherit the parent's [ConfigOrder] value. */
@Test fun `ConfigOverride without overrideOrder inherits parent ConfigOrder value`() {
val fields = sortedFields(ChildInheritsParentOrder::class.java)
assertEquals(listOf("a", "c", "b"), fields.map { it.name })
assertEquals(ChildInheritsParentOrder::class.java, fields.first { it.name == "b" }.declaringClass)
}

/** An explicit [ConfigOverride.overrideOrder] value should take precedence over the inherited parent order. */
@Test fun `ConfigOverride with explicit overrideOrder uses that value over inherited`() =
assertEquals("b", fieldNames(ChildExplicitOverrideOrder::class.java).last())

/** [ConfigOverride] should correctly resolve position through multiple levels of inheritance. */
@Test fun `override works correctly through multiple inheritance levels`() {
val fields = sortedFields(GrandChild::class.java)
assertEquals(listOf("p", "q", "r"), fields.map { it.name })
assertEquals(GrandChild::class.java, fields.first { it.name == "q" }.declaringClass)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.github.notenoughupdates.moulconfig.common

import io.github.notenoughupdates.moulconfig.internal.MCLogger

/**
* Minimal [IMinecraft] implementation for use in unit tests.
* Only implements what is required for [io.github.notenoughupdates.moulconfig.internal.Warnings] to initialise.
* All other methods throw [UnsupportedOperationException].
*/
class MockIMinecraft : IMinecraft {

override fun isDevelopmentEnvironment() = false

override fun getLogger(label: String) = object : MCLogger {
override fun warn(text: String) = Unit
override fun info(text: String) = Unit
override fun error(text: String, throwable: Throwable) = Unit
}

override fun loadResourceLocation(resourceLocation: MyResourceLocation) = TODO()
override fun isGeneratedSentinel(resourceLocation: MyResourceLocation) = TODO()
override fun generateDynamicTexture(image: java.awt.image.BufferedImage) = TODO()
override fun getMousePositionHF() = TODO()
override fun getDefaultFontRenderer() = TODO()
override fun getKeyboardConstants() = TODO()
override fun getScaledWidth() = TODO()
override fun getScaledHeight() = TODO()
override fun getScaleFactor() = TODO()
override fun isOnMacOs() = TODO()
override fun isMouseButtonDown(mouseButton: Int) = TODO()
override fun isKeyboardKeyDown(keyCode: Int) = TODO()
override fun addExtraBuiltinConfigProcessors(processor: io.github.notenoughupdates.moulconfig.processor.MoulConfigProcessor<*>) = TODO()
override fun sendClickableChatMessage(message: io.github.notenoughupdates.moulconfig.common.text.StructuredText, action: String, clickType: ClickType?) = TODO()
override fun getKeyName(keyCode: Int) = TODO()
override fun createLiteral(text: String) = TODO()
override fun createTranslatable(key: String, vararg args: io.github.notenoughupdates.moulconfig.common.text.StructuredText) = TODO()
override fun createStructuredTextInternal(`object`: Any) = TODO()
override fun registerPlatformTypeMorphisms(universe: io.github.notenoughupdates.moulconfig.xml.XMLUniverse) = TODO()
@Deprecated("See parent deprecation")
override fun provideTopLevelRenderContext() = TODO()
override fun openWrappedScreen(guiContext: io.github.notenoughupdates.moulconfig.gui.GuiContext) = TODO()
override fun copyToClipboard(string: String) = TODO()
override fun copyFromClipboard() = TODO()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
io.github.notenoughupdates.moulconfig.common.MockIMinecraft
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@

public class TestCategory {

@Expose
@ConfigOrder(Integer.MAX_VALUE)
@ConfigOption(name = "Bottom option!", desc = "Declared at top, but should appear at the bottom by Order annotation.")
@ConfigEditorInfoText(infoTitle = "Bottom option")
public boolean bottomOption = false;

@ConfigEditorButton(buttonText = "RUN!")
@Expose
@ConfigOption(name = "Button using runnable", desc = "Click to run")
Expand Down Expand Up @@ -202,4 +208,10 @@ public String toString() {
}
}

@Expose
@ConfigOrder(-1)
@ConfigOption(name = "Top option", desc = "Declared at the bottom, floated to top by Order annotation.")
@ConfigEditorInfoText(infoTitle = "Top option")
public boolean topOption = false;

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import java.util.*

class TestCategoryA {

@Expose
@ConfigOrder(Integer.MAX_VALUE)
@ConfigOption(name = "Bottom option!", desc = "Declared at top, but should appear at the bottom by Order annotation.")
@ConfigEditorInfoText(infoTitle = "Bottom option")
var bottomOption: Boolean = false

@ConfigOption(name ="Open Wide", desc= "Use a wider config menu")
@ConfigEditorBoolean
var isWide: Boolean = false
Expand Down Expand Up @@ -138,4 +144,10 @@ class TestCategoryA {
buttonText = "Click me")
val runnableId = Unit

@Expose
@ConfigOrder(-1)
@ConfigOption(name = "Top option!", desc = "Declared at the bottom, floated to top by Order annotation.")
@ConfigEditorInfoText(infoTitle = "Top option")
var topOption: Boolean = false

}