From 55fdebed6c426728117299031af5693c0b5366b2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 19 Dec 2023 14:04:22 -0800 Subject: [PATCH 1/4] Implementation of java single-line literals. --- .../kotlin/com/diffplug/selfie/Literals.kt | 64 ++++++++++++++++--- .../com/diffplug/selfie/LiteralStringTest.kt | 61 ++++++++++++++++++ 2 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/LiteralStringTest.kt diff --git a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Literals.kt b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Literals.kt index c98621ad..c5b36e04 100644 --- a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Literals.kt +++ b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Literals.kt @@ -60,15 +60,63 @@ object LiteralInt : LiteralFormat { object LiteralString : LiteralFormat { override fun encode(value: String, language: Language): String { - if (!value.contains("\n")) { - // TODO: replace \t, maybe others... - return "\"" + value.replace("\"", "\\\"") + "\"" - } else { - // TODO: test! It's okay to assume Java 15+ for now - return "\"\"\"\n" + value + "\"\"\"" - } + return singleLineJavaToSource(value) } override fun parse(str: String, language: Language): String { - TODO() + return singleLineJavaFromSource(str) + } + private fun singleLineJavaToSource(value: String): String { + val source = StringBuilder() + source.append("\"") + for (char in value) { + when (char) { + '\b' -> source.append("\\b") + '\n' -> source.append("\\n") + '\r' -> source.append("\\r") + '\t' -> source.append("\\t") + '\"' -> source.append("\\\"") + '\\' -> source.append("\\\\") + else -> + if (isControlChar(char)) { + source.append("\\u") + source.append(char.code.toString(16).padStart(4, '0')) + } else { + source.append(char) + } + } + } + source.append("\"") + return source.toString() + } + private fun isControlChar(c: Char): Boolean { + return c in '\u0000'..'\u001F' || c == '\u007F' + } + private fun singleLineJavaFromSource(source: String): String { + val value = StringBuilder() + var i = 0 + while (i < source.length) { + var c = source[i] + if (c == '\\') { + i++ + c = source[i] + when (c) { + 'b' -> value.append('\b') + 'n' -> value.append('\n') + 'r' -> value.append('\r') + 't' -> value.append('\t') + '\"' -> value.append('\"') + '\\' -> value.append('\\') + 'u' -> { + val code = source.substring(i + 1, i + 5).toInt(16) + value.append(code.toChar()) + i += 4 + } + } + } else if (c != '\"') { + value.append(c) + } + i++ + } + return value.toString() } } diff --git a/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/LiteralStringTest.kt b/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/LiteralStringTest.kt new file mode 100644 index 00000000..f117abe5 --- /dev/null +++ b/selfie-lib/src/commonTest/kotlin/com/diffplug/selfie/LiteralStringTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2023 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.selfie + +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class LiteralStringTest { + @Test + fun encode() { + encode( + "1", + """ + "1" + """ + .trimIndent()) + encode( + "1\n\tABC", + """ + "1\n\tABC" + """ + .trimIndent()) + } + private fun encode(value: String, expected: String) { + val actual = LiteralString.encode(value, Language.JAVA) + actual shouldBe expected + } + + @Test + fun decode() { + decode( + """ + "1" + """ + .trimIndent(), + "1") + decode( + """ + "1\n\tABC" + """ + .trimIndent(), + "1\n\tABC") + } + private fun decode(value: String, expected: String) { + val actual = LiteralString.parse(value, Language.JAVA) + actual shouldBe expected + } +} From f1437b3ba63732685fe7df8cc8a8646526c4966a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 19 Dec 2023 14:20:37 -0800 Subject: [PATCH 2/4] Reduce duplicated code amongst the inline snapshots. --- .../main/kotlin/com/diffplug/selfie/Selfie.kt | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/Selfie.kt b/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/Selfie.kt index 9089a0d3..895e145c 100644 --- a/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/Selfie.kt +++ b/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/Selfie.kt @@ -48,9 +48,26 @@ object Selfie { fun expectSelfie(actual: T, snapshotter: Snapshotter) = DiskSelfie(snapshotter.snapshot(actual)) + /** Implements the inline snapshot whenever a match fails. */ + private fun toBeDidntMatch(expected: T?, actual: T, format: LiteralFormat): T { + if (RW.isWrite) { + Router.writeInline(recordCall(), LiteralValue(expected, actual, format)) + return actual + } else { + if (expected == null) { + throw AssertionFailedError( + "`.toBe_TODO()` was called in `read` mode, try again with selfie in write mode") + } else { + throw AssertionFailedError( + "Inline literal did not match the actual value", expected, actual) + } + } + } + class StringSelfie(private val actual: String) : DiskSelfie(Snapshot.of(actual)) { - fun toBe(expected: String): String = TODO() - fun toBe_TODO(): String = TODO() + fun toBe_TODO() = toBeDidntMatch(null, actual, LiteralString) + infix fun toBe(expected: String) = + if (actual == expected) expected else toBeDidntMatch(expected, actual, LiteralString) } @JvmStatic fun expectSelfie(actual: String) = StringSelfie(actual) @@ -63,26 +80,9 @@ object Selfie { @JvmStatic fun expectSelfie(actual: ByteArray) = BinarySelfie(actual) class IntSelfie(private val actual: Int) { - fun toBe_TODO(): Int = toBeDidntMatch(null) - infix fun toBe(expected: Int): Int = - if (actual == expected) expected - else { - toBeDidntMatch(expected) - } - private fun toBeDidntMatch(expected: Int?): Int { - if (RW.isWrite) { - Router.writeInline(recordCall(), LiteralValue(expected, actual, LiteralInt)) - return actual - } else { - if (expected == null) { - throw AssertionFailedError( - "`.toBe_TODO()` was called in `read` mode, try again with selfie in write mode") - } else { - throw AssertionFailedError( - "Inline literal did not match the actual value", expected, actual) - } - } - } + fun toBe_TODO() = toBeDidntMatch(null, actual, LiteralInt) + infix fun toBe(expected: Int) = + if (actual == expected) expected else toBeDidntMatch(expected, actual, LiteralInt) } @JvmStatic fun expectSelfie(actual: Int) = IntSelfie(actual) From 5282c22db3cb839dcbe33c26b6a3295ecfa6b0c1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 19 Dec 2023 14:25:00 -0800 Subject: [PATCH 3/4] Add preliminary support for inline Strings. --- .../selfie/junit5/InlineStringTest.kt | 48 +++++++++++++++++++ .../undertest/junit5/UT_InlineStringTest.kt | 13 +++++ 2 files changed, 61 insertions(+) create mode 100644 selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/InlineStringTest.kt create mode 100644 undertest-junit5/src/test/kotlin/undertest/junit5/UT_InlineStringTest.kt diff --git a/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/InlineStringTest.kt b/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/InlineStringTest.kt new file mode 100644 index 00000000..7f2ecd6e --- /dev/null +++ b/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/InlineStringTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2023 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.selfie.junit5 + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.TestMethodOrder + +/** Write-only test which asserts adding and removing snapshots results in same-class GC. */ +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +// @DisableIfTestFails don't disable if test fails because we *have* to run cleanup +class InlineStringTest : Harness("undertest-junit5") { + @Test @Order(1) + fun toBe_TODO() { + ut_mirror().lineWith("@Ignore").setContent("//@Ignore") + ut_mirror().lineWith("expectSelfie").setContent(" expectSelfie(\"Hello world\").toBe_TODO()") + gradleReadSSFail() + } + + @Test @Order(2) + fun toBe_write() { + gradleWriteSS() + ut_mirror().lineWith("expectSelfie").content() shouldBe + " expectSelfie(\"Hello world\").toBe(\"Hello world\")" + gradleReadSS() + } + + @Test @Order(3) + fun cleanup() { + ut_mirror().lineWith("expectSelfie").setContent(" expectSelfie(\"Hello world\").toBe_TODO()") + ut_mirror().lineWith("//@Ignore").setContent("@Ignore") + } +} diff --git a/undertest-junit5/src/test/kotlin/undertest/junit5/UT_InlineStringTest.kt b/undertest-junit5/src/test/kotlin/undertest/junit5/UT_InlineStringTest.kt new file mode 100644 index 00000000..f1e6fe03 --- /dev/null +++ b/undertest-junit5/src/test/kotlin/undertest/junit5/UT_InlineStringTest.kt @@ -0,0 +1,13 @@ +package undertest.junit5 +// spotless:off +import com.diffplug.selfie.Selfie.expectSelfie +import kotlin.test.Ignore +import kotlin.test.Test +// spotless:on + +@Ignore +class UT_InlineStringTest { + @Test fun singleLine() { + expectSelfie("Hello world").toBe_TODO() + } +} From d4ab2106edddef33cfdc4b784661f0a4c91ee69a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 20 Dec 2023 08:57:09 -0800 Subject: [PATCH 4/4] Before writing a value, double-check that we can roundtrip it. --- .../kotlin/com/diffplug/selfie/SourceFile.kt | 19 +++++++++++++++++++ .../junit5/__snapshots__/UT_PrismTrainTest.ss | 2 ++ 2 files changed, 21 insertions(+) diff --git a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SourceFile.kt b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SourceFile.kt index 94af2c17..3b84a3e5 100644 --- a/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SourceFile.kt +++ b/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SourceFile.kt @@ -43,6 +43,25 @@ class SourceFile(filename: String, content: String) { */ fun setLiteralAndGetNewlineDelta(literalValue: LiteralValue): Int { val encoded = literalValue.format.encode(literalValue.actual, language) + val roundTripped = literalValue.format.parse(encoded, language) // sanity check + if (roundTripped != literalValue.actual) { + throw Error( + "There is an error in " + + literalValue.format::class.simpleName + + ", the following value isn't roundtripping.\n" + + "Please this error and the data below at https://github.com/diffplug/selfie/issues/new\n" + + "```\n" + + "ORIGINAL\n" + + literalValue.actual + + "\n" + + "ROUNDTRIPPED\n" + + roundTripped + + "\n" + + "ENCODED ORIGINAL\n" + + encoded + + "\n" + + "```\n") + } val existingNewlines = slice.count { it == '\n' } val newNewlines = encoded.count { it == '\n' } contentSlice = Slice(slice.replaceSelfWith(".toBe($encoded)")) diff --git a/undertest-junit5/src/test/kotlin/undertest/junit5/__snapshots__/UT_PrismTrainTest.ss b/undertest-junit5/src/test/kotlin/undertest/junit5/__snapshots__/UT_PrismTrainTest.ss index 67565856..8df13763 100644 --- a/undertest-junit5/src/test/kotlin/undertest/junit5/__snapshots__/UT_PrismTrainTest.ss +++ b/undertest-junit5/src/test/kotlin/undertest/junit5/__snapshots__/UT_PrismTrainTest.ss @@ -1,3 +1,5 @@ ╔═ selfie ═╗ apple +╔═ selfie[count] ═╗ +5 ╔═ [end of file] ═╗