Skip to content

Commit

Permalink
Move the Selfie.expectSelfie methods into selfie-lib (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedtwigg authored Dec 24, 2023
2 parents 972d8f9 + 6ffbccc commit 8b12de9
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 109 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,50 @@
*/
package com.diffplug.selfie

import com.diffplug.selfie.junit5.Router
import com.diffplug.selfie.junit5.recordCall
import java.util.Map.entry
import org.opentest4j.AssertionFailedError
import kotlin.jvm.JvmOverloads
import kotlin.jvm.JvmStatic

/** NOT FOR ENDUSERS. Implemented by Selfie to integrate with various test frameworks. */
interface SnapshotStorage {
/** Determines if the system is in write mode or read mode. */
val isWrite: Boolean
/** Indicates that the following value should be written into test sourcecode. */
fun writeInline(literalValue: LiteralValue<*>)
/** Performs a comparison between disk and actual, writing the actual to disk if necessary. */
fun readWriteDisk(actual: Snapshot, sub: String): ExpectedActual
/**
* Marks that the following sub snapshots should be kept, null means to keep all snapshots for the
* currently executing class.
*/
fun keep(subOrKeepAll: String?)
/** Creates an assertion failed exception to throw. */
fun assertFailed(message: String, expected: Any? = null, actual: Any? = null): Error
}

expect fun initStorage(): SnapshotStorage

object Selfie {
private val storage: SnapshotStorage = initStorage()

/**
* Sometimes a selfie is environment-specific, but should not be deleted when run in a different
* environment.
*/
@JvmStatic
fun preserveSelfiesOnDisk(vararg subsToKeep: String): Unit {
if (subsToKeep.isEmpty()) {
Router.keep(null)
storage.keep(null)
} else {
subsToKeep.forEach { Router.keep(it) }
subsToKeep.forEach { storage.keep(it) }
}
}

class DiskSelfie internal constructor(actual: Snapshot) : LiteralStringSelfie(actual) {
@JvmOverloads
fun toMatchDisk(sub: String = ""): Snapshot {
val comparison = Router.readWriteThroughPipeline(actual, sub)
if (!RW.isWrite) {
comparison.assertEqual()
val comparison = storage.readWriteDisk(actual, sub)
if (!storage.isWrite) {
comparison.assertEqual(storage)
}
return comparison.actual
}
Expand All @@ -58,6 +77,11 @@ object Selfie {
check(onlyFacets.isNotEmpty()) {
"Must have at least one facet to display, this was empty."
}
if (onlyFacets.contains("")) {
check(onlyFacets.indexOf("") == 0) {
"If you're going to specify the subject facet (\"\"), you have to list it first, this was $onlyFacets"
}
}
}
}
/** Extract a single facet from a snapshot in order to do an inline snapshot. */
Expand All @@ -72,16 +96,13 @@ object Selfie {
TODO("BASE64")
} else onlyValue.valueString()
} else {
// multiple values might need our SnapshotFile escaping, we'll use it just in case
val facetsToCheck =
return serializeOnlyFacets(
actual,
onlyFacets
?: buildList {
?: buildList<String> {
add("")
addAll(actual.facets.keys)
}
val snapshotToWrite =
Snapshot.ofEntries(facetsToCheck.map { entry(it, actual.subjectOrFacet(it)) })
return serializeMultiple(snapshotToWrite, !facetsToCheck.contains(""))
})
}
}
fun toBe_TODO() = toBeDidntMatch(null, actualString(), LiteralString)
Expand All @@ -103,15 +124,15 @@ object Selfie {

/** Implements the inline snapshot whenever a match fails. */
private fun <T : Any> toBeDidntMatch(expected: T?, actual: T, format: LiteralFormat<T>): T {
if (RW.isWrite) {
Router.writeInline(recordCall(), LiteralValue(expected, actual, format))
if (storage.isWrite) {
storage.writeInline(LiteralValue(expected, actual, format))
return actual
} else {
if (expected == null) {
throw AssertionFailedError(
throw storage.assertFailed(
"`.toBe_TODO()` was called in `read` mode, try again with selfie in write mode")
} else {
throw AssertionFailedError(
throw storage.assertFailed(
"Inline literal did not match the actual value", expected, actual)
}
}
Expand Down Expand Up @@ -148,61 +169,50 @@ object Selfie {
infix fun Boolean.shouldBeSelfie(expected: Boolean): Boolean = expectSelfie(this).toBe(expected)
}

internal class ExpectedActual(val expected: Snapshot?, val actual: Snapshot) {
fun assertEqual() {
class ExpectedActual(val expected: Snapshot?, val actual: Snapshot) {
internal fun assertEqual(storage: SnapshotStorage) {
if (expected == null) {
throw AssertionFailedError("No such snapshot")
throw storage.assertFailed("No such snapshot")
} else if (expected.subject == actual.subject && expected.facets == actual.facets) {
return
} else {
val allKeys =
mutableSetOf<String>()
.apply {
add("")
addAll(expected.facets.keys)
addAll(actual.facets.keys)
val mismatchedKeys =
sequence {
yield("")
yieldAll(expected.facets.keys)
for (facet in actual.facets.keys) {
if (!expected.facets.containsKey(facet)) {
yield(facet)
}
}
}
.filter { expected.subjectOrFacetMaybe(it) != actual.subjectOrFacetMaybe(it) }
.toList()
.sorted()
val mismatchInExpected = mutableMapOf<String, SnapshotValue>()
val mismatchInActual = mutableMapOf<String, SnapshotValue>()
for (key in allKeys) {
val expectedValue = expected.facets[key]
val actualValue = actual.facets[key]
if (expectedValue != actualValue) {
expectedValue?.let { mismatchInExpected[key] = it }
actualValue?.let { mismatchInActual[key] = it }
}
}
val includeRoot = mismatchInExpected.containsKey("")
throw AssertionFailedError(
throw storage.assertFailed(
"Snapshot failure",
serializeMultiple(Snapshot.ofEntries(mismatchInExpected.entries), !includeRoot),
serializeMultiple(Snapshot.ofEntries(mismatchInActual.entries), !includeRoot))
serializeOnlyFacets(expected, mismatchedKeys),
serializeOnlyFacets(actual, mismatchedKeys))
}
}
}
private fun serializeMultiple(actual: Snapshot, removeEmptySubject: Boolean): String {
if (removeEmptySubject) {
check(actual.subject.valueString().isEmpty()) {
"The subject was expected to be empty, was '${actual.subject.valueString()}'"
}
}
val file = SnapshotFile()
file.snapshots = ArrayMap.of(mutableListOf("" to actual))
/**
* Returns a serialized form of only the given facets if they are available, silently omits missing
* facets.
*/
private fun serializeOnlyFacets(snapshot: Snapshot, keys: Collection<String>): String {
val buf = StringBuilder()
file.serialize(buf::append)

check(buf.startsWith(EMPTY_SUBJECT))
check(buf.endsWith(EOF))
buf.setLength(buf.length - EOF.length)
val str = buf.substring(EMPTY_SUBJECT.length)
return if (!removeEmptySubject) str
else {
check(str[0] == '\n')
str.substring(1)
val writer = StringWriter { buf.append(it) }
for (key in keys) {
if (key.isEmpty()) {
SnapshotFile.writeValue(writer, snapshot.subjectOrFacet(key))
} else {
snapshot.subjectOrFacetMaybe(key)?.let {
SnapshotFile.writeKey(writer, "", key)
SnapshotFile.writeValue(writer, it)
}
}
}
buf.setLength(buf.length - 1)
return buf.toString()
}

private const val EMPTY_SUBJECT = "╔═ ═╗\n"
private const val EOF = "\n╔═ [end of file] ═╗\n"
Original file line number Diff line number Diff line change
Expand Up @@ -156,28 +156,6 @@ class SnapshotFile {
}
writeKey(valueWriter, "", "end of file")
}
private fun writeKey(valueWriter: StringWriter, key: String, facet: String?) {
valueWriter.write("╔═ ")
valueWriter.write(SnapshotValueReader.nameEsc.escape(key))
if (facet != null) {
valueWriter.write("[")
valueWriter.write(SnapshotValueReader.nameEsc.escape(facet))
valueWriter.write("]")
}
valueWriter.write(" ═╗\n")
}
private fun writeValue(valueWriter: StringWriter, value: SnapshotValue) {
if (value.isBinary) {
TODO("BASE64")
} else {
val escaped =
SnapshotValueReader.bodyEsc
.escape(value.valueString())
.efficientReplace("\n", "\n\uD801\uDF41")
valueWriter.write(escaped)
valueWriter.write("\n")
}
}

var wasSetAtTestTime: Boolean = false
fun setAtTestTime(key: String, snapshot: Snapshot) {
Expand Down Expand Up @@ -222,6 +200,28 @@ class SnapshotFile {
result.unixNewlines = unixNewlines
return result
}
internal fun writeKey(valueWriter: StringWriter, key: String, facet: String?) {
valueWriter.write("╔═ ")
valueWriter.write(SnapshotValueReader.nameEsc.escape(key))
if (facet != null) {
valueWriter.write("[")
valueWriter.write(SnapshotValueReader.nameEsc.escape(facet))
valueWriter.write("]")
}
valueWriter.write(" ═╗\n")
}
internal fun writeValue(valueWriter: StringWriter, value: SnapshotValue) {
if (value.isBinary) {
TODO("BASE64")
} else {
val escaped =
SnapshotValueReader.bodyEsc
.escape(value.valueString())
.efficientReplace("\n", "\n\uD801\uDF41")
valueWriter.write(escaped)
valueWriter.write("\n")
}
}
}
}

Expand Down
19 changes: 19 additions & 0 deletions selfie-lib/src/jsMain/kotlin/com/diffplug/selfie/Selfie.js.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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
actual fun initStorage(): SnapshotStorage {
TODO("Not yet implemented")
}
25 changes: 25 additions & 0 deletions selfie-lib/src/jvmMain/kotlin/com/diffplug/selfie/Selfie.jvm.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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
actual fun initStorage(): SnapshotStorage {
try {
val clazz = Class.forName("com.diffplug.selfie.junit5.SnapshotStorageJUnit5")
return clazz.getMethod("initStorage").invoke(null) as SnapshotStorage
} catch (e: ClassNotFoundException) {
throw IllegalStateException(
"Missing required artifact `com.diffplug.spotless:selfie-runner-junit5", e)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie
package com.diffplug.selfie.junit5

/**
* Determines whether Selfie is overwriting snapshots or erroring-out on mismatch.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ package com.diffplug.selfie.junit5

import com.diffplug.selfie.*
import com.diffplug.selfie.ExpectedActual
import com.diffplug.selfie.RW
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
Expand All @@ -29,17 +28,22 @@ import org.junit.platform.engine.support.descriptor.MethodSource
import org.junit.platform.launcher.TestExecutionListener
import org.junit.platform.launcher.TestIdentifier
import org.junit.platform.launcher.TestPlan
import org.opentest4j.AssertionFailedError

/** Routes between `toMatchDisk()` calls and the snapshot file / pruning machinery. */
internal object Router {
internal object SnapshotStorageJUnit5 : SnapshotStorage {
@JvmStatic fun initStorage(): SnapshotStorage = this
override val isWrite: Boolean
get() = RW.isWrite

private class ClassMethod(val clazz: ClassProgress, val method: String)
private val threadCtx = ThreadLocal<ClassMethod?>()
private fun classAndMethod() =
threadCtx.get()
?: throw AssertionError(
"Selfie `toMatchDisk` must be called only on the original thread.")
private fun suffix(sub: String) = if (sub == "") "" else "/$sub"
fun readWriteThroughPipeline(actual: Snapshot, sub: String): ExpectedActual {
override fun readWriteDisk(actual: Snapshot, sub: String): ExpectedActual {
val cm = classAndMethod()
val suffix = suffix(sub)
val callStack = recordCall()
Expand All @@ -50,15 +54,19 @@ internal object Router {
ExpectedActual(cm.clazz.read(cm.method, suffix), actual)
}
}
fun keep(subOrKeepAll: String?) {
override fun keep(subOrKeepAll: String?) {
val cm = classAndMethod()
if (subOrKeepAll == null) {
cm.clazz.keep(cm.method, null)
} else {
cm.clazz.keep(cm.method, suffix(subOrKeepAll))
}
}
fun writeInline(call: CallStack, literalValue: LiteralValue<*>) {
override fun assertFailed(message: String, expected: Any?, actual: Any?): Error =
if (expected == null && actual == null) AssertionFailedError(message)
else AssertionFailedError(message, expected, actual)
override fun writeInline(literalValue: LiteralValue<*>) {
val call = recordCall()
val cm =
threadCtx.get()
?: throw AssertionError("Selfie `toBe` must be called only on the original thread.")
Expand Down Expand Up @@ -100,13 +108,13 @@ internal class ClassProgress(val parent: Progress, val className: String) {
// the methods below called by the TestExecutionListener on its runtime thread
@Synchronized fun startMethod(method: String) {
assertNotTerminated()
Router.start(this, method)
SnapshotStorageJUnit5.start(this, method)
assert(method.indexOf('/') == -1) { "Method name cannot contain '/', was $method" }
methods = methods.plus(method, MethodSnapshotGC())
}
@Synchronized fun finishedMethodWithSuccess(method: String, success: Boolean) {
assertNotTerminated()
Router.finish(this, method)
SnapshotStorageJUnit5.finish(this, method)
methods[method]!!.succeeded(success)
}
@Synchronized fun finishedClassWithSuccess(success: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
package com.diffplug.selfie.junit5

import com.diffplug.selfie.LiteralValue
import com.diffplug.selfie.RW
import com.diffplug.selfie.Snapshot
import com.diffplug.selfie.SourceFile
import java.nio.file.Files
Expand Down
Loading

0 comments on commit 8b12de9

Please sign in to comment.