Skip to content

Commit

Permalink
Diff effects in NextPredicate (#177)
Browse files Browse the repository at this point in the history
* Diff effects in NextPredicate
* Test closestDiff
* Update hasEffects failure message to explain diff signs
* Test visual diffing in hasEffects
* Prefix missing effect's dump with "-"
* Fix SwiftLint violation
* Prevent matched diffs from being reused
* Move the generalised fuzzy diff logic to DebugDiff.swift
* Diff results in `hasExactlyEffects` and `hasOnlyEffects`
* Diff effects in FirstMatchers
* Indent diff prefix and dump by 3 spaces
The aim is to make the diff marks more clearly separated from actual dump string, as sometimes diff mark `–` might visually collide with dump() mark `-`
* Fix SwiftLint issues
Co-authored-by: Jens Ayton <[email protected]>
  • Loading branch information
zvonicek authored Sep 1, 2021
1 parent 681ff65 commit b06ed67
Show file tree
Hide file tree
Showing 7 changed files with 421 additions and 106 deletions.
108 changes: 101 additions & 7 deletions MobiusTest/Source/DebugDiff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,59 @@
// specific language governing permissions and limitations
// under the License.

/// Diff two values by comparing their dumps by line by line.
/// - Parameters:
/// - lhs: Old value
/// - rhs: New value
/// - Returns: formatted diff string
func dumpDiff<T>(_ lhs: T, _ rhs: T) -> String {
let lhsLines = dumpUnwrapped(lhs).split(separator: "\n")
let rhsLines = dumpUnwrapped(rhs).split(separator: "\n")
let lhsLines = dumpUnwrapped(lhs).lines
let rhsLines = dumpUnwrapped(rhs).lines

let diffList = diff(lhs: ArraySlice(lhsLines), rhs: ArraySlice(rhsLines))
let diffList = diff(lhs: lhsLines, rhs: rhsLines)

return diffList.flatMap { diff in
diff.string.map { "\(diff.prefix)\($0)" }
}.joined(separator: "\n")
return diffList.diffString
}

private func dumpUnwrapped<T>(_ value: T) -> String {
/// Diff two collections of items by picking the most similar value from `actual` for each of the items
/// in `expected`. Matched values are diffed by comparing their dumps line by line.
///
/// “Similar” is defined as starting with at least one matching line – in the common case of enums with
/// associated types, this means the same case. “Best match” is defined as the one with the smallest number of
/// line differences.
///
/// - Parameters:
/// - expected: Values that are expected to be found in `actual`
/// - actual: Values that the `expected` values are diffed against
/// - withUnmatchedActual: Whether the unmatched values from `actual` should be included in the diff
/// - Returns: formatted diff string
func dumpDiffFuzzy<T>(expected: [T], actual: [T], withUnmatchedActual: Bool) -> String where T: Equatable {
var actual = actual

let diffItem = { (item: T) -> [Difference] in
let closestResult = closestDiff(
for: item,
in: actual,
predicate: { $0.first?.isSame ?? false } // Only use diff if first line (typically case name) matches
)

if let diffList = closestResult.0,
let matchedCandidate = closestResult.1,
let matchedIndex = actual.firstIndex(of: matchedCandidate) {
actual.remove(at: matchedIndex)
return diffList
} else {
return [Difference.delete(dumpUnwrapped(item).lines)]
}
}

let expectedDifference = expected.flatMap(diffItem)
let unmatchedActualDifference = withUnmatchedActual ? actual.map { Difference.insert(dumpUnwrapped($0).lines) } : []

return (expectedDifference + unmatchedActualDifference).diffString
}

func dumpUnwrapped<T>(_ value: T) -> String {
var valueDump: String = ""
let mirror = Mirror(reflecting: value)

Expand All @@ -40,3 +81,56 @@ private func dumpUnwrapped<T>(_ value: T) -> String {

return valueDump
}

func closestDiff<T, S: Sequence>(
for value: T,
in sequence: S,
predicate: ([Difference]) -> Bool = { _ in true }
) -> ([Difference]?, T?) where S.Element == T {
var closestDiff: [Difference]?
var closestDistance = Int.max
var closestCandidate: T?

let unwrappedValue = dumpUnwrapped(value).lines

sequence.forEach { candidate in
let unwrappedCandidate = dumpUnwrapped(candidate).lines
let diffList = diff(lhs: unwrappedValue, rhs: unwrappedCandidate)

let distance = diffList.diffCount
if distance < closestDistance && predicate(diffList) {
closestDiff = diffList
closestDistance = distance
closestCandidate = candidate
}
}

return (closestDiff, closestCandidate)
}

private extension String {
var lines: ArraySlice<Substring> {
split(separator: "\n")[...]
}
}

private extension Array where Element == Difference {
// Return the number of entries that are differences
var diffCount: Int {
reduce(0) { count, element in
switch element {
case .insert(let lines), .delete(let lines):
return count + lines.count
case .same:
return count
}
}
}

var diffString: String {
flatMap { diff in
diff.string.map { "\(diff.prefix) \($0)" }
}
.joined(separator: "\n")
}
}
57 changes: 44 additions & 13 deletions MobiusTest/Source/FirstMatchers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,21 @@ public func hasEffects<Model, Effect: Equatable>(
line: UInt = #line
) -> FirstPredicate<Model, Effect> {
return { (first: First<Model, Effect>) in
if !expected.allSatisfy(first.effects.contains) {
return .failure(message: "Expected <\(first.effects)> to contain <\(expected)>", file: file, line: line)
}
return .success
let actual = first.effects
let unmatchedExpected = expected.filter { !actual.contains($0) }
guard !unmatchedExpected.isEmpty else { return .success }

// Find the effects that were produced but not expected - this is permitted, but there might be a close match
// there
let unmatchedActual = actual.filter { !expected.contains($0) }

return .failure(
message: "Missing \(countedEffects(unmatchedExpected, label: "expected")) (−), got (+)" +
" (with \(countedEffects(unmatchedActual, label: "actual")) unmatched):\n" +
dumpDiffFuzzy(expected: unmatchedExpected, actual: unmatchedActual, withUnmatchedActual: false),
file: file,
line: line
)
}
}

Expand All @@ -110,15 +121,25 @@ public func hasOnlyEffects<Model, Effect: Equatable>(
line: UInt = #line
) -> FirstPredicate<Model, Effect> {
return { (first: First<Model, Effect>) in
var unmatchedActual = first.effects
var unmatchedExpected = expected
zip(first.effects, expected).forEach {
_ = unmatchedActual.firstIndex(of: $1).map { unmatchedActual.remove(at: $0) }
_ = unmatchedExpected.firstIndex(of: $0).map { unmatchedExpected.remove(at: $0) }
}
if !unmatchedActual.isEmpty || !unmatchedExpected.isEmpty {
return .failure(message: "Expected <\(first.effects)> to contain only <\(expected)>", file: file, line: line)
let actual = first.effects
let unmatchedExpected = expected.filter { !actual.contains($0) }
let unmatchedActual = actual.filter { !expected.contains($0) }

var errorString = [
!unmatchedExpected.isEmpty ? "missing \(countedEffects(unmatchedExpected, label: "expected")) (−)" : nil,
!unmatchedActual.isEmpty ? "got \(countedEffects(unmatchedActual, label: "actual unmatched")) (+)" : nil,
].compactMap { $0 }.joined(separator: ", ")
errorString = errorString.prefix(1).capitalized + errorString.dropFirst()

if !errorString.isEmpty {
return .failure(
message: "\(errorString):\n" +
dumpDiffFuzzy(expected: unmatchedExpected, actual: unmatchedActual, withUnmatchedActual: true),
file: file,
line: line
)
}

return .success
}
}
Expand All @@ -134,8 +155,18 @@ public func hasExactlyEffects<Model, Effect: Equatable>(
) -> FirstPredicate<Model, Effect> {
return { (first: First<Model, Effect>) in
if first.effects != expected {
return .failure(message: "Expected <\(first.effects)> to equal <\(expected)>", file: file, line: line)
return .failure(
message: "Different effects than expected (−), got (+): \n" +
"\(dumpDiff(expected, first.effects))",
file: file,
line: line
)
}
return .success
}
}

private func countedEffects<T>(_ effects: [T], label: String) -> String {
let count = effects.count
return count == 1 ? "1 \(label) effect" : "\(count) \(label) effects"
}
57 changes: 44 additions & 13 deletions MobiusTest/Source/NextMatchers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,21 @@ public func hasEffects<Model, Effect: Equatable>(
line: UInt = #line
) -> NextPredicate<Model, Effect> {
return { (next: Next<Model, Effect>) in
if !expected.allSatisfy(next.effects.contains) {
return .failure(message: "Expected <\(next.effects)> to contain <\(expected)>", file: file, line: line)
}
return .success
let actual = next.effects
let unmatchedExpected = expected.filter { !actual.contains($0) }
guard !unmatchedExpected.isEmpty else { return .success }

// Find the effects that were produced but not expected - this is permitted, but there might be a close match
// there
let unmatchedActual = actual.filter { !expected.contains($0) }

return .failure(
message: "Missing \(countedEffects(unmatchedExpected, label: "expected")) (−), got (+)" +
" (with \(countedEffects(unmatchedActual, label: "actual")) unmatched):\n" +
dumpDiffFuzzy(expected: unmatchedExpected, actual: unmatchedActual, withUnmatchedActual: false),
file: file,
line: line
)
}
}

Expand All @@ -149,15 +160,25 @@ public func hasOnlyEffects<Model, Effect: Equatable>(
line: UInt = #line
) -> NextPredicate<Model, Effect> {
return { (next: Next<Model, Effect>) in
var unmatchedActual = next.effects
var unmatchedExpected = expected
zip(next.effects, expected).forEach {
_ = unmatchedActual.firstIndex(of: $1).map { unmatchedActual.remove(at: $0) }
_ = unmatchedExpected.firstIndex(of: $0).map { unmatchedExpected.remove(at: $0) }
}
if !unmatchedActual.isEmpty || !unmatchedExpected.isEmpty {
return .failure(message: "Expected <\(next.effects)> to contain only <\(expected)>", file: file, line: line)
let actual = next.effects
let unmatchedExpected = expected.filter { !actual.contains($0) }
let unmatchedActual = actual.filter { !expected.contains($0) }

var errorString = [
!unmatchedExpected.isEmpty ? "missing \(countedEffects(unmatchedExpected, label: "expected")) (−)" : nil,
!unmatchedActual.isEmpty ? "got \(countedEffects(unmatchedActual, label: "actual unmatched")) (+)" : nil,
].compactMap { $0 }.joined(separator: ", ")
errorString = errorString.prefix(1).capitalized + errorString.dropFirst()

if !errorString.isEmpty {
return .failure(
message: "\(errorString):\n" +
dumpDiffFuzzy(expected: unmatchedExpected, actual: unmatchedActual, withUnmatchedActual: true),
file: file,
line: line
)
}

return .success
}
}
Expand All @@ -173,8 +194,18 @@ public func hasExactlyEffects<Model, Effect: Equatable>(
) -> NextPredicate<Model, Effect> {
return { (next: Next<Model, Effect>) in
if next.effects != expected {
return .failure(message: "Expected <\(next.effects)> to equal <\(expected)>", file: file, line: line)
return .failure(
message: "Different effects than expected (−), got (+): \n" +
"\(dumpDiff(expected, next.effects))",
file: file,
line: line
)
}
return .success
}
}

private func countedEffects<T>(_ effects: [T], label: String) -> String {
let count = effects.count
return count == 1 ? "1 \(label) effect" : "\(count) \(label) effects"
}
9 changes: 8 additions & 1 deletion MobiusTest/Source/SimpleDiff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
//
// 3. This notice may not be removed or altered from any source distribution.

enum Difference {
enum Difference: Equatable {
case insert(ArraySlice<Substring>)
case delete(ArraySlice<Substring>)
case same(ArraySlice<Substring>)
Expand All @@ -33,6 +33,13 @@ enum Difference {
}
}

var isSame: Bool {
switch self {
case .same: return true
default: return false
}
}

var prefix: String {
switch self {
case .insert:
Expand Down
Loading

0 comments on commit b06ed67

Please sign in to comment.