Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add lens / prism infrastructure #54

Merged
merged 7 commits into from
Dec 20, 2023
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* 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

/** Given a full [Snapshot], a lens returns either null or a single [SnapshotValue]. */
@OptIn(ExperimentalStdlibApi::class)
interface SnapshotLens : AutoCloseable {
val defaultLensName: String
fun transform(testClass: String, key: String, snapshot: Snapshot): SnapshotValue?
override fun close() {}
}

/**
* A prism transforms a single [Snapshot] into a new [Snapshot], transforming / creating / removing
* [SnapshotValue]s along the way.
*/
@OptIn(ExperimentalStdlibApi::class)
interface SnapshotPrism : AutoCloseable {
fun transform(className: String, key: String, snapshot: Snapshot): Snapshot
override fun close() {}
}
fun interface SnapshotPredicate {
fun test(testClass: String, key: String, snapshot: Snapshot): Boolean
}

/** A prism with a fluent API for creating [LensHoldingPrism]s gated by predicates. */
open class CompoundPrism : SnapshotPrism {
private val prisms = mutableListOf<SnapshotPrism>()
fun add(prism: SnapshotPrism): CompoundPrism {
prisms.add(prism)
return this
}
fun ifClassKeySnapshot(predicate: SnapshotPredicate): LensHoldingPrism {
val prismWhere = LensHoldingPrism(predicate)
add(prismWhere)
return prismWhere
}
fun ifSnapshot(predicate: (Snapshot) -> Boolean) = ifClassKeySnapshot { _, _, snapshot ->
predicate(snapshot)
}
fun forEverySnapshot(): LensHoldingPrism = ifSnapshot { true }
fun ifString(predicate: (String) -> Boolean) = ifSnapshot {
!it.value.isBinary && predicate(it.value.valueString())
}
fun ifStringIsProbablyHtml(): LensHoldingPrism {
val regex = Regex("<\\/?[a-z][\\s\\S]*>")
return ifString { regex.find(it) != null }
}
override fun transform(className: String, key: String, snapshot: Snapshot): Snapshot {
var current = snapshot
prisms.forEach { current = it.transform(className, key, current) }
return current
}
override fun close() = prisms.forEach(SnapshotPrism::close)
}

/** A prism which applies lenses to a snapshot. */
open class LensHoldingPrism(val predicate: SnapshotPredicate) : SnapshotPrism {
private val lenses = mutableListOf<SnapshotPrism>()
private fun addLensOrReplaceRoot(name: String?, lens: SnapshotLens): LensHoldingPrism {
lenses.add(
object : SnapshotPrism {
override fun transform(testClass: String, key: String, snapshot: Snapshot): Snapshot {
val lensValue = lens.transform(testClass, key, snapshot)
return if (lensValue == null) snapshot
else {
if (name == null) snapshot.withNewRoot(lensValue) else snapshot.lens(name, lensValue)
}
}
override fun close() = lens.close()
})
return this
}
fun addLens(name: String, lens: SnapshotLens): LensHoldingPrism = addLensOrReplaceRoot(name, lens)
fun addLens(lens: SnapshotLens): LensHoldingPrism =
addLensOrReplaceRoot(lens.defaultLensName, lens)
fun replaceRootWith(lens: SnapshotLens): LensHoldingPrism = addLensOrReplaceRoot(null, lens)
override fun transform(testClass: String, key: String, snapshot: Snapshot): Snapshot {
if (!predicate.test(testClass, key, snapshot)) {
return snapshot
}
var current = snapshot
lenses.forEach { current = it.transform(testClass, key, snapshot) }
return current
}
override fun close() {
lenses.forEach(SnapshotPrism::close)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ data class Snapshot(
}
}

interface Snapshotter<T> {
interface Camera<T> {
fun snapshot(value: T): Snapshot
}
internal fun String.efficientReplace(find: String, replaceWith: String): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ object Selfie {
}

@JvmStatic
fun <T> expectSelfie(actual: T, snapshotter: Snapshotter<T>) =
DiskSelfie(snapshotter.snapshot(actual))
fun <T> expectSelfie(actual: T, camera: Camera<T>) = DiskSelfie(camera.snapshot(actual))

class StringSelfie(private val actual: String) : DiskSelfie(Snapshot.of(actual)) {
fun toBe(expected: String): String = TODO()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@
*/
package com.diffplug.selfie.junit5

import com.diffplug.selfie.CompoundPrism
import com.diffplug.selfie.SnapshotPrism
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

interface SelfieSettingsAPI {
/** Returns a prism train which will be used to transform snapshots. */
fun createPrismTrain(layout: SnapshotFileLayout): SnapshotPrism

/**
* Defaults to `__snapshot__`, null means that snapshots are stored at the same folder location as
* the test that created them.
Expand Down Expand Up @@ -50,16 +55,42 @@ interface SelfieSettingsAPI {
"src/test/scala",
"src/test/resources")
internal fun initialize(): SelfieSettingsAPI {
val settings = System.getProperty("selfie.settings")
if (settings != null && settings.trim().isNotEmpty()) {
try {
return instantiate(Class.forName(settings))
} catch (e: ClassNotFoundException) {
throw Error(
"The system property selfie.settings was set to $settings, but that class could not be found.",
e)
}
}
try {
val clazz = Class.forName("com.diffplug.selfie.SelfieSettings")
return clazz.getDeclaredConstructor().newInstance() as SelfieSettingsAPI
return instantiate(Class.forName("SelfieSettings"))
} catch (e: ClassNotFoundException) {
return StandardSelfieSettings()
return SelfieSettingsNoOp()
}
}
private fun instantiate(clazz: Class<*>): SelfieSettingsAPI {
try {
return clazz.getDeclaredConstructor().newInstance() as SelfieSettingsAPI
} catch (e: InstantiationException) {
throw AssertionError("Unable to instantiate dev.selfie.SelfieSettings, is it abstract?", e)
throw AssertionError(
"Unable to instantiate ${clazz.name}, is it abstract? Does it require arguments?", e)
}
}
}
}

open class StandardSelfieSettings : SelfieSettingsAPI
private class SelfieSettingsNoOp : StandardSelfieSettings() {
override fun setupPrismTrain(prismTrain: CompoundPrism) {}
}

abstract class StandardSelfieSettings : SelfieSettingsAPI {
protected abstract fun setupPrismTrain(prismTrain: CompoundPrism)
override fun createPrismTrain(layout: SnapshotFileLayout): SnapshotPrism {
val prismTrain = CompoundPrism()
setupPrismTrain(prismTrain)
return prismTrain
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ internal object Router {
val cm = classAndMethod()
val suffix = suffix(sub)
val callStack = recordCall()
val transformed =
cm.clazz.parent.prismTrain.transform(cm.clazz.className, "${cm.method}$suffix", actual)
return if (RW.isWrite) {
cm.clazz.write(cm.method, suffix, actual, callStack, cm.clazz.parent.layout)
ExpectedActual(actual, actual)
cm.clazz.write(cm.method, suffix, transformed, callStack, cm.clazz.parent.layout)
ExpectedActual(transformed, transformed)
} else {
ExpectedActual(cm.clazz.read(cm.method, suffix), actual)
ExpectedActual(cm.clazz.read(cm.method, suffix), transformed)
}
}
fun keep(subOrKeepAll: String?) {
Expand Down Expand Up @@ -199,6 +201,7 @@ internal class ClassProgress(val parent: Progress, val className: String) {
internal class Progress {
val settings = SelfieSettingsAPI.initialize()
val layout = SnapshotFileLayout.initialize(settings)
val prismTrain = settings.createPrismTrain(layout)

private var progressPerClass = ArrayMap.empty<String, ClassProgress>()
private fun forClass(className: String) = synchronized(this) { progressPerClass[className]!! }
Expand Down Expand Up @@ -240,6 +243,7 @@ internal class Progress {
written.add(path)
}
fun finishedAllTests() {
prismTrain.close()
val written =
checkForInvalidStale.getAndSet(null)
?: throw AssertionError("finishedAllTests() was called more than once.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ import org.opentest4j.AssertionFailedError
import org.w3c.dom.NodeList
import org.xml.sax.InputSource

open class Harness(subproject: String) {
open class Harness(subproject: String, val onlyRunThisTest: Boolean = false) {
val subprojectFolder: Path
var settings = ""

init {
var rootFolder = FileSystem.SYSTEM.canonicalize("".toPath())
Expand Down Expand Up @@ -195,22 +196,37 @@ open class Harness(subproject: String) {
.connect()
.use { connection ->
try {
val buildLauncher =
connection
.newBuild()
.setStandardError(System.err)
.setStandardOutput(System.out)
.forTasks(":${subprojectFolder.name}:$task")
.withArguments(
buildList<String> {
addAll(args)
add("--configuration-cache") // enabled vs disabled is 11s vs 24s
add("--stacktrace")
})
buildLauncher.run()
return null
if (onlyRunThisTest) {
connection
.newTestLauncher()
.setStandardError(System.err)
.setStandardOutput(System.out)
.withTaskAndTestClasses(
":${subprojectFolder.name}:$task", listOf("UT_${javaClass.simpleName}"))
.withArguments(
buildList<String> {
addAll(args)
add("--configuration-cache") // enabled vs disabled is 11s vs 24s
add("--stacktrace")
})
.run()
} else {
connection
.newBuild()
.setStandardError(System.err)
.setStandardOutput(System.out)
.forTasks(":${subprojectFolder.name}:$task")
.withArguments(
buildList<String> {
addAll(args)
add("--configuration-cache") // enabled vs disabled is 11s vs 24s
add("--stacktrace")
})
.run()
}
null
} catch (e: BuildException) {
return parseBuildException(task, e)
parseBuildException(task, e)
}
}
}
Expand Down Expand Up @@ -267,17 +283,17 @@ open class Harness(subproject: String) {
return error
}
fun gradleWriteSS() {
gradlew("underTest", "-Pselfie=write")?.let {
gradlew("underTest", "-Pselfie=write", "-Pselfie.settings=${settings}")?.let {
throw AssertionError("Expected write snapshots to succeed, but it failed", it)
}
}
fun gradleReadSS() {
gradlew("underTest", "-Pselfie=read")?.let {
gradlew("underTest", "-Pselfie=read", "-Pselfie.settings=${settings}")?.let {
throw AssertionError("Expected read snapshots to succeed, but it failed", it)
}
}
fun gradleReadSSFail(): AssertionFailedError {
val failure = gradlew("underTest", "-Pselfie=read")
val failure = gradlew("underTest", "-Pselfie=read", "-Pselfie.settings=${settings}")
if (failure == null) {
throw AssertionError("Expected read snapshots to fail, but it succeeded.")
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 kotlin.test.Test
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.TestMethodOrder
import org.junitpioneer.jupiter.DisableIfTestFails

/** Simplest test for verifying read/write of a snapshot. */
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
@DisableIfTestFails
class PrismTrainTest : Harness("undertest-junit5", onlyRunThisTest = true) {
@Test @Order(1)
fun noSelfie() {
ut_snapshot().deleteIfExists()
ut_snapshot().assertDoesNotExist()
}

@Test @Order(2)
fun noTrain() {
gradleWriteSS()
ut_snapshot()
.assertContent(
"""
╔═ selfie ═╗
apple
╔═ [end of file] ═╗

"""
.trimIndent())
gradleReadSS()
}

@Test @Order(3)
fun withTrain() {
settings = "undertest.junit5.SettingsLensCount"
gradleReadSSFail()
// now let's write it
gradleWriteSS()
ut_snapshot()
.assertContent(
"""
╔═ selfie ═╗
apple
╔═ selfie[count] ═╗
5
╔═ [end of file] ═╗

"""
.trimIndent())
gradleReadSS()
}
}
11 changes: 1 addition & 10 deletions undertest-junit-vintage/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,5 @@ tasks.register('underTest', Test) {
outputs.upToDateWhen { false }
// defaults to 'write'
systemProperty 'selfie', findProperty('selfie')
}
tasks.register('underTestRead', Test) {
useJUnitPlatform()
testClassesDirs = testing.suites.test.sources.output.classesDirs
classpath = testing.suites.test.sources.runtimeClasspath
testLogging.showStandardStreams = true
// the snapshots are both output and input, for this harness best if the test just always runs
outputs.upToDateWhen { false }
// read-only
systemProperty 'selfie', 'read'
systemProperty 'selfie.settings', findProperty('selfie.settings')
}
Loading