diff --git a/src/main/java/gg/gemstone/component/ComponentParser.java b/src/main/java/gg/gemstone/component/ComponentParser.java index 30d2598..e37a95b 100644 --- a/src/main/java/gg/gemstone/component/ComponentParser.java +++ b/src/main/java/gg/gemstone/component/ComponentParser.java @@ -78,6 +78,24 @@ static ComponentParser componentParserAmpersand() { */ @NotNull Component parse(@NotNull String input); + /** + * Escapes {@code input} so that no token recognized by this parser's translator chain or by + * {@link MiniMessage} will be interpreted on a subsequent {@link #parse(String)} call. + * + * @param input the raw input string + * @return the escaped string + */ + @NotNull String escape(@NotNull String input); + + /** + * Removes every token recognized by this parser's translator chain and by {@link MiniMessage} + * from {@code input}, leaving only plain text. + * + * @param input the raw input string + * @return the stripped string + */ + @NotNull String strip(@NotNull String input); + /** * Returns a new {@link Builder} pre-populated with this parser's translator chain * and {@link MiniMessage} instance, allowing a modified copy to be built without diff --git a/src/main/java/gg/gemstone/component/ComponentParserImpl.java b/src/main/java/gg/gemstone/component/ComponentParserImpl.java index 9ac9f53..dac1f49 100644 --- a/src/main/java/gg/gemstone/component/ComponentParserImpl.java +++ b/src/main/java/gg/gemstone/component/ComponentParserImpl.java @@ -42,9 +42,35 @@ class ComponentParserImpl implements ComponentParser { @Override public @NotNull Component parse(@NotNull String input) { + requireNonNull(input, "input"); + return miniMessage.deserialize(translate(input)); } + @Override + public @NotNull String escape(@NotNull String input) { + requireNonNull(input, "input"); + + String result = input; + for (MiniMessageTranslator translator : translators) { + result = translator.escape(result); + } + + return miniMessage.escapeTags(result); + } + + @Override + public @NotNull String strip(@NotNull String input) { + requireNonNull(input, "input"); + + String result = input; + for (MiniMessageTranslator translator : translators) { + result = translator.strip(result); + } + + return miniMessage.stripTags(result); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -64,8 +90,6 @@ public ComponentParser.Builder toBuilder() { @VisibleForTesting @NotNull String translate(@NotNull String input) { - requireNonNull(input, "input"); - String result = input; for (MiniMessageTranslator translator : translators) { result = translator.translate(result); diff --git a/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java b/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java index f5addda..95a9e45 100644 --- a/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslator.java @@ -94,6 +94,46 @@ class LegacyFormattingCodeTranslator implements MiniMessageTranslator { this.sectionChar = sectionChar; } + @Override + public @NotNull String escape(@NotNull String input) { + return rewriteLegacyCodes(input, true); + } + + @Override + public @NotNull String strip(@NotNull String input) { + return rewriteLegacyCodes(input, false); + } + + private String rewriteLegacyCodes(String input, boolean escape) { + if (input.isEmpty()) { + return input; + } + + StringBuilder output = new StringBuilder(input.length()); + + int i = 0; + while (i < input.length()) { + char c = input.charAt(i); + + if (c == sectionChar && i + 1 < input.length()) { + char code = Character.toLowerCase(input.charAt(i + 1)); + + if (code == RESET_CODE || COLOR_CODES.containsKey(code) || FORMAT_CODES.containsKey(code)) { + if (escape) { + output.append(sectionChar).append('\\').append(input.charAt(i + 1)); + } + i += 2; + continue; + } + } + + output.append(c); + i++; + } + + return output.toString(); + } + @Override public @NotNull String translate(@NotNull String input) { if (input.isEmpty()) { diff --git a/src/main/java/gg/gemstone/component/translator/MiniMessageTranslator.java b/src/main/java/gg/gemstone/component/translator/MiniMessageTranslator.java index f48fdab..8f6d240 100644 --- a/src/main/java/gg/gemstone/component/translator/MiniMessageTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/MiniMessageTranslator.java @@ -35,4 +35,25 @@ public interface MiniMessageTranslator { * @return the translated string, or {@code input} unchanged if no recognized patterns were found */ @NotNull String translate(@NotNull String input); + + /** + * Escapes every pattern recognized by this translator so it will not be picked up by a + * subsequent {@link #translate(String)} call. The convention is to insert a backslash + * inside the recognized token. + * + *

Implementations must not re-escape tokens that have already been escaped by another + * translator earlier in the chain. + * + * @param input the string to escape + * @return the escaped string, or {@code input} unchanged if no recognized patterns were found + */ + @NotNull String escape(@NotNull String input); + + /** + * Removes every pattern recognized by this translator from {@code input}. + * + * @param input the string to strip + * @return the stripped string, or {@code input} unchanged if no recognized patterns were found + */ + @NotNull String strip(@NotNull String input); } diff --git a/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java b/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java index a4424fe..c4259b7 100644 --- a/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslator.java @@ -38,6 +38,12 @@ class MojangBoxedHexPatternTranslator implements MiniMessageTranslator { */ private static final String BOXED_HEX_REPLACEMENT = "<#$1>"; + /** + * Escape replacement: {@code <&#RRGGBB>} becomes {@code <&\#RRGGBB>}, neutralising the pattern + * for both this translator and {@link MojangUnboxedHexPatternTranslator}. + */ + private static final String BOXED_HEX_ESCAPE_REPLACEMENT = "<&\\\\#$1>"; + /** * Use {@link MiniMessageTranslators#MOJANG_BOXED_HEX}. */ @@ -48,4 +54,14 @@ class MojangBoxedHexPatternTranslator implements MiniMessageTranslator { public @NotNull String translate(final @NotNull String input) { return BOXED_MOJANG_PATTERN.matcher(input).replaceAll(BOXED_HEX_REPLACEMENT); } + + @Override + public @NotNull String escape(final @NotNull String input) { + return BOXED_MOJANG_PATTERN.matcher(input).replaceAll(BOXED_HEX_ESCAPE_REPLACEMENT); + } + + @Override + public @NotNull String strip(final @NotNull String input) { + return BOXED_MOJANG_PATTERN.matcher(input).replaceAll(""); + } } diff --git a/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java b/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java index b511197..27b656f 100644 --- a/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslator.java @@ -40,6 +40,12 @@ class MojangUnboxedHexPatternTranslator implements MiniMessageTranslator { */ private static final String BOXED_HEX_REPLACEMENT = "<#$1>"; + /** + * Escape replacement: {@code &#RRGGBB} becomes {@code &\#RRGGBB}, neutralising the pattern + * for this translator. + */ + private static final String UNBOXED_HEX_ESCAPE_REPLACEMENT = "&\\\\#$1"; + /** * Use {@link MiniMessageTranslators#MOJANG_UNBOXED_HEX}. */ @@ -50,4 +56,14 @@ class MojangUnboxedHexPatternTranslator implements MiniMessageTranslator { public @NotNull String translate(final @NotNull String input) { return UNBOXED_MOJANG_PATTERN.matcher(input).replaceAll(BOXED_HEX_REPLACEMENT); } + + @Override + public @NotNull String escape(final @NotNull String input) { + return UNBOXED_MOJANG_PATTERN.matcher(input).replaceAll(UNBOXED_HEX_ESCAPE_REPLACEMENT); + } + + @Override + public @NotNull String strip(final @NotNull String input) { + return UNBOXED_MOJANG_PATTERN.matcher(input).replaceAll(""); + } } diff --git a/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java b/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java index b59950c..1453c99 100644 --- a/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java +++ b/src/main/java/gg/gemstone/component/translator/UnboxedHexPatternTranslator.java @@ -33,16 +33,23 @@ class UnboxedHexPatternTranslator implements MiniMessageTranslator { /** * Matches unboxed hex codes (e.g. {@code #FFFFFF}), excluding those already boxed in - * MiniMessage format (e.g. {@code <#FFFFFF>}) or preceded by {@code &} (Mojang-style, - * e.g. {@code &#FFFFFF}) via a negative lookbehind for {@code <} and {@code &}. + * MiniMessage format (e.g. {@code <#FFFFFF>}), preceded by {@code &} (Mojang-style, + * e.g. {@code &#FFFFFF}), or preceded by a backslash (escape marker, e.g. {@code \#FFFFFF}) + * via a negative lookbehind for {@code <}, {@code &}, and {@code \}. */ - private static final Pattern UNBOXED_HEX_PATTERN = Pattern.compile("(?}). */ private static final String BOXED_HEX_REPLACEMENT = "<#$1>"; + /** + * Escape replacement: {@code #RRGGBB} becomes {@code #\RRGGBB}, neutralising the pattern + * for this translator. + */ + private static final String UNBOXED_HEX_ESCAPE_REPLACEMENT = "#\\\\$1"; + /** * Use {@link MiniMessageTranslators#UNBOXED_HEX}. */ @@ -53,4 +60,14 @@ class UnboxedHexPatternTranslator implements MiniMessageTranslator { public @NotNull String translate(final @NotNull String input) { return UNBOXED_HEX_PATTERN.matcher(input).replaceAll(BOXED_HEX_REPLACEMENT); } + + @Override + public @NotNull String escape(final @NotNull String input) { + return UNBOXED_HEX_PATTERN.matcher(input).replaceAll(UNBOXED_HEX_ESCAPE_REPLACEMENT); + } + + @Override + public @NotNull String strip(final @NotNull String input) { + return UNBOXED_HEX_PATTERN.matcher(input).replaceAll(""); + } } diff --git a/src/test/java/gg/gemstone/component/ComponentParserImplTest.java b/src/test/java/gg/gemstone/component/ComponentParserImplTest.java index 86c9560..bc242c4 100644 --- a/src/test/java/gg/gemstone/component/ComponentParserImplTest.java +++ b/src/test/java/gg/gemstone/component/ComponentParserImplTest.java @@ -332,4 +332,83 @@ void explicitEmptyTranslatorArrayReturnsInputUnchanged() { assertEquals(input, parser.translate(input)); } } + + @Nested + class Escape { + + @Test + void escapeNeutralisesAllTranslatorPatterns() { + // Each translator inserts its own backslash; no translator re-escapes another's output. + // MM.escapeTags leaves the boxed token alone because <&\#AABBCC> is not a known MM tag. + String input = "§cRed <&#AABBCC> 𛙩 #445566 done"; + assertEquals("§\\cRed <&\\#AABBCC> &\\#112233 #\\445566 done", parser.escape(input)); + } + + @Test + void escapedOutputIsNotRetranslated() { + // Re-running translate over escaped text must be a no-op for each translator. + String escaped = parser.escape("§cHello <&#AABBCC> 𛙩 #445566 done"); + assertEquals(escaped, parser.translate(escaped)); + } + + @Test + void miniMessageTagsAreEscapedByMiniMessage() { + // is not a translator pattern; MM.escapeTags must escape it (both open and close). + assertEquals("\\Hi\\", parser.escape("Hi")); + } + + @Test + void emptyStringReturnsEmpty() { + assertEquals("", parser.escape("")); + } + + @Test + void plainTextReturnsUnchanged() { + assertEquals("Hello, world!", parser.escape("Hello, world!")); + } + } + + @Nested + class Strip { + + @Test + void stripRemovesAllTranslatorPatternsAndMmTags() { + // §c, <&#AABBCC>, 𛙩, and #445566 are each stripped, leaving their surrounding + // spaces intact - four single-space gaps between "Red" and "done". + assertEquals("Red done", + parser.strip("§cRed <&#AABBCC> 𛙩 #445566 done")); + } + + @Test + void stripRemovesMiniMessageTags() { + assertEquals("Hi", parser.strip("Hi")); + } + + @Test + void stripRemovesMixedLegacyAndMmTags() { + assertEquals("Hello world", + parser.strip("§cHello §rworld")); + } + + @Test + void emptyStringReturnsEmpty() { + assertEquals("", parser.strip("")); + } + + @Test + void plainTextReturnsUnchanged() { + assertEquals("Hello, world!", parser.strip("Hello, world!")); + } + + @Test + void stripWithAmpersandLegacyTranslator() { + ComponentParserImpl parserAmpersand = new ComponentParserImpl( + List.of(MiniMessageTranslators.MOJANG_BOXED_HEX, MiniMessageTranslators.MOJANG_UNBOXED_HEX, + MiniMessageTranslators.UNBOXED_HEX, MiniMessageTranslators.LEGACY_CODE_AMPERSAND), + MiniMessage.miniMessage() + ); + + assertEquals("Hello world", parserAmpersand.strip("&cHello &rworld")); + } + } } diff --git a/src/test/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslatorTest.java b/src/test/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslatorTest.java index 370c7b1..3349b10 100644 --- a/src/test/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslatorTest.java +++ b/src/test/java/gg/gemstone/component/translator/LegacyFormattingCodeTranslatorTest.java @@ -651,4 +651,130 @@ void mixedExample_legacyCodeInsideUnclosedMmScopes_treatedAsInner() { "§cI'm red. I'm green. I'm blue! §fWill I be white?")); } } + + @Nested + class Escape { + + @Test + void emptyStringIsReturnedUnchanged() { + assertEquals("", translator.escape("")); + } + + @Test + void plainTextWithNoCodesIsReturnedUnchanged() { + assertEquals("Hello, world!", translator.escape("Hello, world!")); + } + + @Test + void escapesColorCode() { + assertEquals("§\\cHello", translator.escape("§cHello")); + } + + @Test + void escapesFormattingCode() { + assertEquals("§\\lBold", translator.escape("§lBold")); + } + + @Test + void escapesResetCode() { + assertEquals("a§\\rb", translator.escape("a§rb")); + } + + @Test + void preservesOriginalCaseOfCode() { + // §C is a valid code (case-insensitive lookup), but the original 'C' must be preserved. + assertEquals("§\\CHello", translator.escape("§CHello")); + } + + @Test + void escapesEveryCodeInString() { + assertEquals("§\\cA§\\lB§\\r", translator.escape("§cA§lB§r")); + } + + @Test + void unknownCodeCharIsLeftAlone() { + assertEquals("§zHello", translator.escape("§zHello")); + } + + @Test + void sectionCharAtEndOfStringIsLeftAlone() { + assertEquals("Hello§", translator.escape("Hello§")); + } + + @Test + void minimessageTagsArePassedThroughVerbatim() { + assertEquals("Hi", translator.escape("Hi")); + } + + @Test + void escapedOutputIsNoLongerTranslated() { + String escaped = translator.escape("§cHello"); + assertEquals("§\\cHello", escaped); + assertEquals(escaped, translator.translate(escaped)); + } + + @Test + void doesNotReEscapeAlreadyEscapedCode() { + // §\c - the char after § is '\', not a valid legacy code char, so we leave it alone. + assertEquals("§\\cHello", translator.escape("§\\cHello")); + } + + @Test + void customSectionChar() { + LegacyFormattingCodeTranslator ampersand = new LegacyFormattingCodeTranslator('&'); + assertEquals("&\\cHello", ampersand.escape("&cHello")); + assertEquals("§cHello", ampersand.escape("§cHello")); + } + } + + @Nested + class Strip { + + @Test + void emptyStringIsReturnedUnchanged() { + assertEquals("", translator.strip("")); + } + + @Test + void plainTextWithNoCodesIsReturnedUnchanged() { + assertEquals("Hello, world!", translator.strip("Hello, world!")); + } + + @Test + void stripsSingleColorCode() { + assertEquals("Hello", translator.strip("§cHello")); + } + + @Test + void stripsAllCodesInString() { + assertEquals("AB", translator.strip("§cA§lB§r")); + } + + @Test + void stripsAdjacentCodes() { + assertEquals("Hello", translator.strip("§c§l§nHello")); + } + + @Test + void preservesUnknownCodes() { + assertEquals("§zHello", translator.strip("§zHello")); + } + + @Test + void sectionCharAtEndOfStringIsPreserved() { + assertEquals("Hello§", translator.strip("Hello§")); + } + + @Test + void minimessageTagsArePassedThroughVerbatim() { + assertEquals("Hi", translator.strip("Hi")); + } + + @Test + void customSectionChar() { + LegacyFormattingCodeTranslator ampersand = new LegacyFormattingCodeTranslator('&'); + assertEquals("Hello", ampersand.strip("&cHello")); + assertEquals("§cHello", ampersand.strip("§cHello")); + } + } } diff --git a/src/test/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslatorTest.java b/src/test/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslatorTest.java index 04a270f..29b2038 100644 --- a/src/test/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslatorTest.java +++ b/src/test/java/gg/gemstone/component/translator/MojangBoxedHexPatternTranslatorTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class MojangBoxedHexPatternTranslatorTest { @@ -74,4 +75,84 @@ void shouldNotMatchSevenDigitHex() { void shouldPreservesSurroundingText() { assertEquals("Hello <#FFFFFF> world", translator.translate("Hello <&#FFFFFF> world")); } + + @Nested + class Escape { + + @Test + void shouldEscapeSingle() { + assertEquals("<&\\#FFFFFF>", translator.escape("<&#FFFFFF>")); + } + + @Test + void shouldEscapeMultiple() { + assertEquals("<&\\#FFFFFF> <&\\#000000>", translator.escape("<&#FFFFFF> <�>")); + } + + @Test + void shouldNotEscapeUnboxedMojang() { + assertEquals("&#FFFFFF", translator.escape("&#FFFFFF")); + } + + @Test + void shouldNotEscapeAlreadyConverted() { + assertEquals("<#FFFFFF>", translator.escape("<#FFFFFF>")); + } + + @Test + void shouldNotEscapeInvalidHex() { + assertEquals("<&#GGGGGG>", translator.escape("<&#GGGGGG>")); + } + + @Test + void shouldNotEscapeFiveDigitHex() { + assertEquals("<&#FFFFF>", translator.escape("<&#FFFFF>")); + } + + @Test + void shouldNotEscapeSevenDigitHex() { + assertEquals("<&#FFFFFFF>", translator.escape("<&#FFFFFFF>")); + } + + @Test + void escapedOutputIsNoLongerTranslated() { + String escaped = translator.escape("<&#AABBCC>"); + assertEquals("<&\\#AABBCC>", escaped); + assertEquals(escaped, translator.translate(escaped)); + } + } + + @Nested + class Strip { + + @Test + void shouldStripSingle() { + assertEquals("", translator.strip("<&#FFFFFF>")); + } + + @Test + void shouldStripMultiple() { + assertEquals(" ", translator.strip("<&#FFFFFF> <�>")); + } + + @Test + void shouldNotStripUnboxedMojang() { + assertEquals("&#FFFFFF", translator.strip("&#FFFFFF")); + } + + @Test + void shouldNotStripAlreadyConverted() { + assertEquals("<#FFFFFF>", translator.strip("<#FFFFFF>")); + } + + @Test + void shouldNotStripInvalidHex() { + assertEquals("<&#GGGGGG>", translator.strip("<&#GGGGGG>")); + } + + @Test + void shouldPreserveSurroundingText() { + assertEquals("Hello world", translator.strip("Hello <&#FFFFFF> world")); + } + } } diff --git a/src/test/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslatorTest.java b/src/test/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslatorTest.java index 82423ce..dffe5a9 100644 --- a/src/test/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslatorTest.java +++ b/src/test/java/gg/gemstone/component/translator/MojangUnboxedHexPatternTranslatorTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class MojangUnboxedHexPatternTranslatorTest { @@ -74,4 +75,79 @@ void shouldNotMatchSevenDigitHex() { void shouldPreserveSurroundingText() { assertEquals("Hello <#FFFFFF> world", translator.translate("Hello &#FFFFFF world")); } + + @Nested + class Escape { + + @Test + void shouldEscapeSingle() { + assertEquals("&\\#FFFFFF", translator.escape("&#FFFFFF")); + } + + @Test + void shouldEscapeMultiple() { + assertEquals("&\\#FFFFFF &\\#000000", translator.escape("&#FFFFFF �")); + } + + @Test + void shouldNotEscapeBoxedMojang() { + assertEquals("<&#FFFFFF>", translator.escape("<&#FFFFFF>")); + } + + @Test + void shouldNotEscapeAlreadyConverted() { + assertEquals("<#FFFFFF>", translator.escape("<#FFFFFF>")); + } + + @Test + void shouldNotEscapeInvalidHex() { + assertEquals("&#GGGGGG", translator.escape("&#GGGGGG")); + } + + @Test + void shouldNotEscapeSevenDigitHex() { + assertEquals("&#FFFFFFF", translator.escape("&#FFFFFFF")); + } + + @Test + void escapedOutputIsNoLongerTranslated() { + String escaped = translator.escape("&#AABBCC"); + assertEquals("&\\#AABBCC", escaped); + assertEquals(escaped, translator.translate(escaped)); + } + } + + @Nested + class Strip { + + @Test + void shouldStripSingle() { + assertEquals("", translator.strip("&#FFFFFF")); + } + + @Test + void shouldStripMultiple() { + assertEquals(" ", translator.strip("&#FFFFFF �")); + } + + @Test + void shouldNotStripBoxedMojang() { + assertEquals("<&#FFFFFF>", translator.strip("<&#FFFFFF>")); + } + + @Test + void shouldNotStripAlreadyConverted() { + assertEquals("<#FFFFFF>", translator.strip("<#FFFFFF>")); + } + + @Test + void shouldNotStripInvalidHex() { + assertEquals("&#GGGGGG", translator.strip("&#GGGGGG")); + } + + @Test + void shouldPreserveSurroundingText() { + assertEquals("Hello world", translator.strip("Hello &#FFFFFF world")); + } + } } diff --git a/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java b/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java index 4e104b1..3a6587d 100644 --- a/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java +++ b/src/test/java/gg/gemstone/component/translator/UnboxedHexPatternTranslatorTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class UnboxedHexPatternTranslatorTest { @@ -75,4 +76,87 @@ void shouldNotCorruptMojangBoxedAfterConversion() { // Simulates output from MojangBoxedHexPatternTranslator being passed in assertEquals("<#FFFFFF>", translator.translate("<#FFFFFF>")); } + + @Nested + class Escape { + + @Test + void shouldEscapeSingle() { + assertEquals("#\\FFFFFF", translator.escape("#FFFFFF")); + } + + @Test + void shouldEscapeMultiple() { + assertEquals("#\\FFFFFF #\\000000", translator.escape("#FFFFFF #000000")); + } + + @Test + void shouldNotEscapeAlreadyConverted() { + assertEquals("<#FFFFFF>", translator.escape("<#FFFFFF>")); + } + + @Test + void shouldNotEscapeMojangUnboxed() { + assertEquals("&#FFFFFF", translator.escape("&#FFFFFF")); + } + + @Test + void shouldNotEscapeInvalidHex() { + assertEquals("#GGGGGG", translator.escape("#GGGGGG")); + } + + @Test + void shouldNotEscapeSevenDigitHex() { + assertEquals("#FFFFFFF", translator.escape("#FFFFFFF")); + } + + @Test + void shouldNotReEscapeBackslashEscapedHex() { + // Simulates output of MojangBoxed/MojangUnboxed escape (e.g. "&\#FFFFFF"). + // The leading backslash must keep us from re-escaping the inner #FFFFFF. + assertEquals("&\\#FFFFFF", translator.escape("&\\#FFFFFF")); + assertEquals("<&\\#FFFFFF>", translator.escape("<&\\#FFFFFF>")); + } + + @Test + void escapedOutputIsNoLongerTranslated() { + String escaped = translator.escape("#AABBCC"); + assertEquals("#\\AABBCC", escaped); + assertEquals(escaped, translator.translate(escaped)); + } + } + + @Nested + class Strip { + + @Test + void shouldStripSingle() { + assertEquals("", translator.strip("#FFFFFF")); + } + + @Test + void shouldStripMultiple() { + assertEquals(" ", translator.strip("#FFFFFF #000000")); + } + + @Test + void shouldNotStripAlreadyConverted() { + assertEquals("<#FFFFFF>", translator.strip("<#FFFFFF>")); + } + + @Test + void shouldNotStripMojangUnboxed() { + assertEquals("&#FFFFFF", translator.strip("&#FFFFFF")); + } + + @Test + void shouldNotStripInvalidHex() { + assertEquals("#GGGGGG", translator.strip("#GGGGGG")); + } + + @Test + void shouldPreserveSurroundingText() { + assertEquals("Hello world", translator.strip("Hello #FFFFFF world")); + } + } }