Skip to content

Commit 6911850

Browse files
authored
Add lens / prism infrastructure (#54)
2 parents 406174a + e92df4c commit 6911850

File tree

13 files changed

+286
-57
lines changed

13 files changed

+286
-57
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright (C) 2023 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie
17+
18+
/** Given a full [Snapshot], a lens returns either null or a single [SnapshotValue]. */
19+
@OptIn(ExperimentalStdlibApi::class)
20+
interface SnapshotLens : AutoCloseable {
21+
val defaultLensName: String
22+
fun transform(testClass: String, key: String, snapshot: Snapshot): SnapshotValue?
23+
override fun close() {}
24+
}
25+
26+
/**
27+
* A prism transforms a single [Snapshot] into a new [Snapshot], transforming / creating / removing
28+
* [SnapshotValue]s along the way.
29+
*/
30+
@OptIn(ExperimentalStdlibApi::class)
31+
interface SnapshotPrism : AutoCloseable {
32+
fun transform(className: String, key: String, snapshot: Snapshot): Snapshot
33+
override fun close() {}
34+
}
35+
fun interface SnapshotPredicate {
36+
fun test(testClass: String, key: String, snapshot: Snapshot): Boolean
37+
}
38+
39+
/** A prism with a fluent API for creating [LensHoldingPrism]s gated by predicates. */
40+
open class CompoundPrism : SnapshotPrism {
41+
private val prisms = mutableListOf<SnapshotPrism>()
42+
fun add(prism: SnapshotPrism): CompoundPrism {
43+
prisms.add(prism)
44+
return this
45+
}
46+
fun ifClassKeySnapshot(predicate: SnapshotPredicate): LensHoldingPrism {
47+
val prismWhere = LensHoldingPrism(predicate)
48+
add(prismWhere)
49+
return prismWhere
50+
}
51+
fun ifSnapshot(predicate: (Snapshot) -> Boolean) = ifClassKeySnapshot { _, _, snapshot ->
52+
predicate(snapshot)
53+
}
54+
fun forEverySnapshot(): LensHoldingPrism = ifSnapshot { true }
55+
fun ifString(predicate: (String) -> Boolean) = ifSnapshot {
56+
!it.value.isBinary && predicate(it.value.valueString())
57+
}
58+
fun ifStringIsProbablyHtml(): LensHoldingPrism {
59+
val regex = Regex("<\\/?[a-z][\\s\\S]*>")
60+
return ifString { regex.find(it) != null }
61+
}
62+
override fun transform(className: String, key: String, snapshot: Snapshot): Snapshot {
63+
var current = snapshot
64+
prisms.forEach { current = it.transform(className, key, current) }
65+
return current
66+
}
67+
override fun close() = prisms.forEach(SnapshotPrism::close)
68+
}
69+
70+
/** A prism which applies lenses to a snapshot. */
71+
open class LensHoldingPrism(val predicate: SnapshotPredicate) : SnapshotPrism {
72+
private val lenses = mutableListOf<SnapshotPrism>()
73+
private fun addLensOrReplaceRoot(name: String?, lens: SnapshotLens): LensHoldingPrism {
74+
lenses.add(
75+
object : SnapshotPrism {
76+
override fun transform(testClass: String, key: String, snapshot: Snapshot): Snapshot {
77+
val lensValue = lens.transform(testClass, key, snapshot)
78+
return if (lensValue == null) snapshot
79+
else {
80+
if (name == null) snapshot.withNewRoot(lensValue) else snapshot.lens(name, lensValue)
81+
}
82+
}
83+
override fun close() = lens.close()
84+
})
85+
return this
86+
}
87+
fun addLens(name: String, lens: SnapshotLens): LensHoldingPrism = addLensOrReplaceRoot(name, lens)
88+
fun addLens(lens: SnapshotLens): LensHoldingPrism =
89+
addLensOrReplaceRoot(lens.defaultLensName, lens)
90+
fun replaceRootWith(lens: SnapshotLens): LensHoldingPrism = addLensOrReplaceRoot(null, lens)
91+
override fun transform(testClass: String, key: String, snapshot: Snapshot): Snapshot {
92+
if (!predicate.test(testClass, key, snapshot)) {
93+
return snapshot
94+
}
95+
var current = snapshot
96+
lenses.forEach { current = it.transform(testClass, key, snapshot) }
97+
return current
98+
}
99+
override fun close() {
100+
lenses.forEach(SnapshotPrism::close)
101+
}
102+
}

selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/SnapshotFile.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ data class Snapshot(
7777
}
7878
}
7979

80-
interface Snapshotter<T> {
80+
interface Camera<T> {
8181
fun snapshot(value: T): Snapshot
8282
}
8383
internal fun String.efficientReplace(find: String, replaceWith: String): String {

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/Selfie.kt

+1-2
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ object Selfie {
4545
}
4646

4747
@JvmStatic
48-
fun <T> expectSelfie(actual: T, snapshotter: Snapshotter<T>) =
49-
DiskSelfie(snapshotter.snapshot(actual))
48+
fun <T> expectSelfie(actual: T, camera: Camera<T>) = DiskSelfie(camera.snapshot(actual))
5049

5150
class StringSelfie(private val actual: String) : DiskSelfie(Snapshot.of(actual)) {
5251
fun toBe(expected: String): String = TODO()

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieSettingsAPI.kt

+36-5
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@
1515
*/
1616
package com.diffplug.selfie.junit5
1717

18+
import com.diffplug.selfie.CompoundPrism
19+
import com.diffplug.selfie.SnapshotPrism
1820
import java.nio.file.Files
1921
import java.nio.file.Path
2022
import java.nio.file.Paths
2123

2224
interface SelfieSettingsAPI {
25+
/** Returns a prism train which will be used to transform snapshots. */
26+
fun createPrismTrain(layout: SnapshotFileLayout): SnapshotPrism
27+
2328
/**
2429
* Defaults to `__snapshot__`, null means that snapshots are stored at the same folder location as
2530
* the test that created them.
@@ -50,16 +55,42 @@ interface SelfieSettingsAPI {
5055
"src/test/scala",
5156
"src/test/resources")
5257
internal fun initialize(): SelfieSettingsAPI {
58+
val settings = System.getProperty("selfie.settings")
59+
if (settings != null && settings.trim().isNotEmpty()) {
60+
try {
61+
return instantiate(Class.forName(settings))
62+
} catch (e: ClassNotFoundException) {
63+
throw Error(
64+
"The system property selfie.settings was set to $settings, but that class could not be found.",
65+
e)
66+
}
67+
}
5368
try {
54-
val clazz = Class.forName("com.diffplug.selfie.SelfieSettings")
55-
return clazz.getDeclaredConstructor().newInstance() as SelfieSettingsAPI
69+
return instantiate(Class.forName("SelfieSettings"))
5670
} catch (e: ClassNotFoundException) {
57-
return StandardSelfieSettings()
71+
return SelfieSettingsNoOp()
72+
}
73+
}
74+
private fun instantiate(clazz: Class<*>): SelfieSettingsAPI {
75+
try {
76+
return clazz.getDeclaredConstructor().newInstance() as SelfieSettingsAPI
5877
} catch (e: InstantiationException) {
59-
throw AssertionError("Unable to instantiate dev.selfie.SelfieSettings, is it abstract?", e)
78+
throw AssertionError(
79+
"Unable to instantiate ${clazz.name}, is it abstract? Does it require arguments?", e)
6080
}
6181
}
6282
}
6383
}
6484

65-
open class StandardSelfieSettings : SelfieSettingsAPI
85+
private class SelfieSettingsNoOp : StandardSelfieSettings() {
86+
override fun setupPrismTrain(prismTrain: CompoundPrism) {}
87+
}
88+
89+
abstract class StandardSelfieSettings : SelfieSettingsAPI {
90+
protected abstract fun setupPrismTrain(prismTrain: CompoundPrism)
91+
override fun createPrismTrain(layout: SnapshotFileLayout): SnapshotPrism {
92+
val prismTrain = CompoundPrism()
93+
setupPrismTrain(prismTrain)
94+
return prismTrain
95+
}
96+
}

selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieTestExecutionListener.kt

+7-3
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@ internal object Router {
4343
val cm = classAndMethod()
4444
val suffix = suffix(sub)
4545
val callStack = recordCall()
46+
val transformed =
47+
cm.clazz.parent.prismTrain.transform(cm.clazz.className, "${cm.method}$suffix", actual)
4648
return if (RW.isWrite) {
47-
cm.clazz.write(cm.method, suffix, actual, callStack, cm.clazz.parent.layout)
48-
ExpectedActual(actual, actual)
49+
cm.clazz.write(cm.method, suffix, transformed, callStack, cm.clazz.parent.layout)
50+
ExpectedActual(transformed, transformed)
4951
} else {
50-
ExpectedActual(cm.clazz.read(cm.method, suffix), actual)
52+
ExpectedActual(cm.clazz.read(cm.method, suffix), transformed)
5153
}
5254
}
5355
fun keep(subOrKeepAll: String?) {
@@ -199,6 +201,7 @@ internal class ClassProgress(val parent: Progress, val className: String) {
199201
internal class Progress {
200202
val settings = SelfieSettingsAPI.initialize()
201203
val layout = SnapshotFileLayout.initialize(settings)
204+
val prismTrain = settings.createPrismTrain(layout)
202205

203206
private var progressPerClass = ArrayMap.empty<String, ClassProgress>()
204207
private fun forClass(className: String) = synchronized(this) { progressPerClass[className]!! }
@@ -240,6 +243,7 @@ internal class Progress {
240243
written.add(path)
241244
}
242245
fun finishedAllTests() {
246+
prismTrain.close()
243247
val written =
244248
checkForInvalidStale.getAndSet(null)
245249
?: throw AssertionError("finishedAllTests() was called more than once.")

selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/Harness.kt

+35-19
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ import org.opentest4j.AssertionFailedError
3030
import org.w3c.dom.NodeList
3131
import org.xml.sax.InputSource
3232

33-
open class Harness(subproject: String) {
33+
open class Harness(subproject: String, val onlyRunThisTest: Boolean = false) {
3434
val subprojectFolder: Path
35+
var settings = ""
3536

3637
init {
3738
var rootFolder = FileSystem.SYSTEM.canonicalize("".toPath())
@@ -195,22 +196,37 @@ open class Harness(subproject: String) {
195196
.connect()
196197
.use { connection ->
197198
try {
198-
val buildLauncher =
199-
connection
200-
.newBuild()
201-
.setStandardError(System.err)
202-
.setStandardOutput(System.out)
203-
.forTasks(":${subprojectFolder.name}:$task")
204-
.withArguments(
205-
buildList<String> {
206-
addAll(args)
207-
add("--configuration-cache") // enabled vs disabled is 11s vs 24s
208-
add("--stacktrace")
209-
})
210-
buildLauncher.run()
211-
return null
199+
if (onlyRunThisTest) {
200+
connection
201+
.newTestLauncher()
202+
.setStandardError(System.err)
203+
.setStandardOutput(System.out)
204+
.withTaskAndTestClasses(
205+
":${subprojectFolder.name}:$task", listOf("UT_${javaClass.simpleName}"))
206+
.withArguments(
207+
buildList<String> {
208+
addAll(args)
209+
add("--configuration-cache") // enabled vs disabled is 11s vs 24s
210+
add("--stacktrace")
211+
})
212+
.run()
213+
} else {
214+
connection
215+
.newBuild()
216+
.setStandardError(System.err)
217+
.setStandardOutput(System.out)
218+
.forTasks(":${subprojectFolder.name}:$task")
219+
.withArguments(
220+
buildList<String> {
221+
addAll(args)
222+
add("--configuration-cache") // enabled vs disabled is 11s vs 24s
223+
add("--stacktrace")
224+
})
225+
.run()
226+
}
227+
null
212228
} catch (e: BuildException) {
213-
return parseBuildException(task, e)
229+
parseBuildException(task, e)
214230
}
215231
}
216232
}
@@ -267,17 +283,17 @@ open class Harness(subproject: String) {
267283
return error
268284
}
269285
fun gradleWriteSS() {
270-
gradlew("underTest", "-Pselfie=write")?.let {
286+
gradlew("underTest", "-Pselfie=write", "-Pselfie.settings=${settings}")?.let {
271287
throw AssertionError("Expected write snapshots to succeed, but it failed", it)
272288
}
273289
}
274290
fun gradleReadSS() {
275-
gradlew("underTest", "-Pselfie=read")?.let {
291+
gradlew("underTest", "-Pselfie=read", "-Pselfie.settings=${settings}")?.let {
276292
throw AssertionError("Expected read snapshots to succeed, but it failed", it)
277293
}
278294
}
279295
fun gradleReadSSFail(): AssertionFailedError {
280-
val failure = gradlew("underTest", "-Pselfie=read")
296+
val failure = gradlew("underTest", "-Pselfie=read", "-Pselfie.settings=${settings}")
281297
if (failure == null) {
282298
throw AssertionError("Expected read snapshots to fail, but it succeeded.")
283299
} else {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright (C) 2023 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie.junit5
17+
18+
import kotlin.test.Test
19+
import org.junit.jupiter.api.MethodOrderer
20+
import org.junit.jupiter.api.Order
21+
import org.junit.jupiter.api.TestMethodOrder
22+
import org.junitpioneer.jupiter.DisableIfTestFails
23+
24+
/** Simplest test for verifying read/write of a snapshot. */
25+
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
26+
@DisableIfTestFails
27+
class PrismTrainTest : Harness("undertest-junit5", onlyRunThisTest = true) {
28+
@Test @Order(1)
29+
fun noSelfie() {
30+
ut_snapshot().deleteIfExists()
31+
ut_snapshot().assertDoesNotExist()
32+
}
33+
34+
@Test @Order(2)
35+
fun noTrain() {
36+
gradleWriteSS()
37+
ut_snapshot()
38+
.assertContent(
39+
"""
40+
╔═ selfie ═╗
41+
apple
42+
╔═ [end of file] ═╗
43+
44+
"""
45+
.trimIndent())
46+
gradleReadSS()
47+
}
48+
49+
@Test @Order(3)
50+
fun withTrain() {
51+
settings = "undertest.junit5.SettingsLensCount"
52+
gradleReadSSFail()
53+
// now let's write it
54+
gradleWriteSS()
55+
ut_snapshot()
56+
.assertContent(
57+
"""
58+
╔═ selfie ═╗
59+
apple
60+
╔═ selfie[count] ═╗
61+
5
62+
╔═ [end of file] ═╗
63+
64+
"""
65+
.trimIndent())
66+
gradleReadSS()
67+
}
68+
}

undertest-junit-vintage/build.gradle

+1-10
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,5 @@ tasks.register('underTest', Test) {
3535
outputs.upToDateWhen { false }
3636
// defaults to 'write'
3737
systemProperty 'selfie', findProperty('selfie')
38-
}
39-
tasks.register('underTestRead', Test) {
40-
useJUnitPlatform()
41-
testClassesDirs = testing.suites.test.sources.output.classesDirs
42-
classpath = testing.suites.test.sources.runtimeClasspath
43-
testLogging.showStandardStreams = true
44-
// the snapshots are both output and input, for this harness best if the test just always runs
45-
outputs.upToDateWhen { false }
46-
// read-only
47-
systemProperty 'selfie', 'read'
38+
systemProperty 'selfie.settings', findProperty('selfie.settings')
4839
}

0 commit comments

Comments
 (0)