diff --git a/spock-core/src/main/java/org/spockframework/runtime/FailedStringComparisonRenderer.java b/spock-core/src/main/java/org/spockframework/runtime/FailedStringComparisonRenderer.java index 2369087027..2a0174342c 100644 --- a/spock-core/src/main/java/org/spockframework/runtime/FailedStringComparisonRenderer.java +++ b/spock-core/src/main/java/org/spockframework/runtime/FailedStringComparisonRenderer.java @@ -8,7 +8,10 @@ import java.util.Locale; public class FailedStringComparisonRenderer implements ExpressionComparisonRenderer { - public static final long MAX_EDIT_DISTANCE_MEMORY = 50 * 1024; + // The edit distance computation needs O(N + M) memory since its linear-space rewrite, so this + // limit only bounds the computation effort: MAX cells cost milliseconds and a few MB, large + // enough to render diffs for differing sections of ~1000 characters directly. + public static final long MAX_EDIT_DISTANCE_MEMORY = 1024 * 1024; @Override public String render(ExpressionInfo expr) { if (!(Boolean.FALSE.equals(expr.getValue()))) return null; @@ -43,14 +46,14 @@ private String tryReduceStringSizes(String str1, String str2) { end1++; end2++; - if (((long) end1-commonStart) * (end2-commonStart) > MAX_EDIT_DISTANCE_MEMORY) { + if (((long) end1 - commonStart) * (end2 - commonStart) > MAX_EDIT_DISTANCE_MEMORY) { return "false\nStrings too large to calculate edit distance."; } else { - // Check if we can add some context - if (((long) end1 - commonStart + 20) * (end2 - commonStart + 20) < MAX_EDIT_DISTANCE_MEMORY){ - commonStart = Math.max(0, commonStart - 10); - end1 = Math.min(str1.length(), end1 + 10); - end2 = Math.min(str2.length(), end2 + 10); + // Check if we can add some context around the differing section + if (((long) end1 - commonStart + 500) * (end2 - commonStart + 500) < MAX_EDIT_DISTANCE_MEMORY){ + commonStart = Math.max(0, commonStart - 250); + end1 = Math.min(str1.length(), end1 + 250); + end2 = Math.min(str2.length(), end2 + 250); } return createAndRenderEditDistance(str1, str2, commonStart, end1, end2); } diff --git a/spock-core/src/main/java/org/spockframework/runtime/condition/EditDistance.java b/spock-core/src/main/java/org/spockframework/runtime/condition/EditDistance.java index e499615a3a..f040367356 100644 --- a/spock-core/src/main/java/org/spockframework/runtime/condition/EditDistance.java +++ b/spock-core/src/main/java/org/spockframework/runtime/condition/EditDistance.java @@ -4,7 +4,9 @@ * 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. @@ -14,53 +16,51 @@ package org.spockframework.runtime.condition; -import java.util.*; +import java.util.ArrayList; +import java.util.List; import static org.spockframework.runtime.condition.EditOperation.Kind.*; /** * Calculates Levenshtein distance and corresponding edit path between two character sequences. - * Inspired from: https://etorreborre.blogspot.com/2008/06/edit-distance-in-scala_245.html - * - * Ideas for improvements: - * - Favor fewer EditOperationS when calculating distance and/or path - * - Use algorithm with lower time and/or space complexity - * - * @author Peter Niederwieser + *

+ * Uses a linear-space divide-and-conquer algorithm ("Optimal Alignments in Linear Space", + * Eugene W. Myers & Webb Miller, 1988), a.k.a. Hirschberg's technique applied to the + * Levenshtein dynamic program: space is O(N + M) and time O(N · M). This replaces the + * previous dense distance matrix, which needed O(N · M) space and therefore could + * not handle large inputs at all. Common prefixes and suffixes are trimmed away first, so for + * near-identical inputs — the typical case in a failed equality condition — only the + * small differing middle section is processed. + *

+ * The edit path groups consecutive edits into runs and pairs up deletions and insertions into + * {@link EditOperation.Kind#SUBSTITUTE} runs, matching the output shape of the previous + * matrix-based implementation. The distance is derived from the edit path, so the two are always + * consistent. */ public class EditDistance { private final CharSequence seq1; private final CharSequence seq2; - private final int[][] matrix; + private final int distance; + private final List path; public EditDistance(CharSequence seq1, CharSequence seq2) { this.seq1 = seq1; this.seq2 = seq2; - matrix = new int[seq1.length() + 1][]; - calculateMatrix(); - } - private void calculateMatrix() { - for (int i = 0; i < seq1.length() + 1; i++) { - matrix[i] = new int[seq2.length() + 1]; - for (int j = 0; j < seq2.length() + 1; j++) { - if (i == 0) matrix[i][j] = j; // j insertions - else if (j == 0) matrix[i][j] = i; // i deletions - else matrix[i][j] = min( - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j] + 1, // deletion - matrix[i - 1][j - 1] + (seq1.charAt(i - 1) == seq2.charAt(j - 1) ? 0 : 1)); // substitution - } - } - } + List operations = new ArrayList<>(); + collectOperations(operations, 0, 0, seq1.length(), seq2.length()); + path = normalize(operations); - public int[][] getMatrix() { - return matrix; + int result = 0; + for (EditOperation operation : path) { + if (operation.getKind() != SKIP) result += operation.getLength(); + } + distance = result; } public int getDistance() { - return matrix[seq1.length()][seq2.length()]; + return distance; } public int getSimilarityInPercent() { @@ -69,50 +69,185 @@ public int getSimilarityInPercent() { } public List calculatePath() { - LinkedList ops = new LinkedList<>(); - int i = seq1.length(); - int j = seq2.length(); - int dist = matrix[i][j]; - - while (i > 0 && j > 0 && dist > 0) { - int ins = matrix[i][j - 1]; - int del = matrix[i - 1][j]; - int sub = matrix[i - 1][j - 1]; - - if (dist == ins + 1) { - addOrUpdate(ops, INSERT, 1); - j--; - } else if (dist == del + 1) { - addOrUpdate(ops, DELETE, 1); - i--; + List result = new ArrayList<>(path.size()); + for (EditOperation operation : path) { + result.add(new EditOperation(operation.getKind(), operation.getLength())); + } + return result; + } + + /** + * Collects the edit operations for the region {@code [from1, to1) x [from2, to2)} in path order, + * splitting the region at an optimal midpoint. + */ + private void collectOperations(List operations, int from1, int from2, int to1, int to2) { + int start = from1; + while (start < to1 && from2 < to2 && seq1.charAt(start) == seq2.charAt(from2)) { + start++; + from2++; + } + addOperation(operations, SKIP, start - from1); + + int end1 = to1; + int end2 = to2; + while (end1 > start && end2 > from2 && seq1.charAt(end1 - 1) == seq2.charAt(end2 - 1)) { + end1--; + end2--; + } + + int remaining1 = end1 - start; + int remaining2 = end2 - from2; + + if (remaining1 == 0) { + addOperation(operations, INSERT, remaining2); + } else if (remaining2 == 0) { + addOperation(operations, DELETE, remaining1); + } else if (remaining1 == 1) { + // the one remaining seq1 character either matches one seq2 character (move it) or not; + // both choices cost the same for every matching position, so the first one is as good as any + int match = indexOf(seq2, from2, end2, seq1.charAt(start)); + if (match >= 0) { + addOperation(operations, INSERT, match - from2); + addOperation(operations, SKIP, 1); + addOperation(operations, INSERT, end2 - match - 1); } else { - if (dist == sub) addOrUpdate(ops, SKIP, 1); - else addOrUpdate(ops, SUBSTITUTE, 1); - i--; j--; + addOperation(operations, SUBSTITUTE, 1); + addOperation(operations, INSERT, end2 - from2 - 1); } + } else if (remaining2 == 1) { + // the one remaining seq2 character either matches one seq1 character (move it) or not; + // deletions are emitted before insertions, like in the remaining1 == 1 case above, so that + // neighboring regions normalize into as many substitution runs as possible + int match = indexOf(seq1, start, end1, seq2.charAt(from2)); + if (match >= 0) { + addOperation(operations, DELETE, match - start); + addOperation(operations, SKIP, 1); + addOperation(operations, DELETE, end1 - match - 1); + } else { + addOperation(operations, DELETE, end1 - start - 1); + addOperation(operations, SUBSTITUTE, 1); + } + } else { + // bisect seq1 and find the column an optimal path crosses the bisection row in + int mid1 = start + remaining1 / 2; + int mid2 = findMidpoint(start, from2, mid1, end1, end2); - dist = matrix[i][j]; + collectOperations(operations, start, from2, mid1, mid2); + collectOperations(operations, mid1, mid2, end1, end2); } - if (i == 0) addOrUpdate(ops, INSERT, j); - else if (j == 0) addOrUpdate(ops, DELETE, i); - else addOrUpdate(ops, SKIP, i); + if (end1 < to1) addOperation(operations, SKIP, to1 - end1); + } - return ops; + private int findMidpoint(int from1, int from2, int mid1, int to1, int to2) { + int[] forward = forwardCosts(from1, from2, mid1, to2); + int[] backward = backwardCosts(mid1, from2, to1, to2); + + int midpoint = from2; + int minCost = Integer.MAX_VALUE; + for (int j = 0; j < forward.length; j++) { + int cost = forward[j] + backward[j]; + if (cost < minCost) { + minCost = cost; + midpoint = from2 + j; + } + } + return midpoint; } - private void addOrUpdate(LinkedList ops, EditOperation.Kind kind, int length) { - if (length == 0) return; + /** + * Computes the costs of converting {@code seq1[from1..to1)} to the prefixes + * {@code seq2[from2..from2 + j)} for all j, using O(length) space. + */ + private int[] forwardCosts(int from1, int from2, int to1, int to2) { + int length = to2 - from2; + int[] previous = new int[length + 1]; + int[] current = new int[length + 1]; + for (int j = 0; j <= length; j++) { + previous[j] = j; + } + for (int i = from1; i < to1; i++) { + current[0] = i - from1 + 1; + for (int j = 1; j <= length; j++) { + current[j] = min(current[j - 1] + 1, + previous[j] + 1, + previous[j - 1] + (seq1.charAt(i) == seq2.charAt(from2 + j - 1) ? 0 : 1)); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous; + } - if (!ops.isEmpty() && ops.getFirst().getKind() == kind) - ops.getFirst().incLength(length); - else - ops.addFirst(new EditOperation(kind, length)); + /** + * Computes the costs of converting {@code seq1[from1..to1)} to the suffixes + * {@code seq2[from2 + j..to2)} for all j, using O(length) space. + */ + private int[] backwardCosts(int from1, int from2, int to1, int to2) { + int length = to2 - from2; + int[] previous = new int[length + 1]; + int[] current = new int[length + 1]; + for (int j = 0; j <= length; j++) { + previous[j] = length - j; + } + for (int i = to1 - 1; i >= from1; i--) { + current[length] = to1 - i; + for (int j = length - 1; j >= 0; j--) { + current[j] = min(current[j + 1] + 1, + previous[j] + 1, + previous[j + 1] + (seq1.charAt(i) == seq2.charAt(from2 + j) ? 0 : 1)); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous; } private static int min(int a, int b, int c) { return Math.min(a, Math.min(b, c)); } -} + private static int indexOf(CharSequence seq, int from, int to, char c) { + for (int i = from; i < to; i++) { + if (seq.charAt(i) == c) return i; + } + return -1; + } + private static void addOperation(List operations, EditOperation.Kind kind, int length) { + if (length == 0) return; + + if (!operations.isEmpty() && operations.get(operations.size() - 1).getKind() == kind) { + operations.get(operations.size() - 1).incLength(length); + } else { + operations.add(new EditOperation(kind, length)); + } + } + + /** + * Pairs up adjacent deletions and insertions into {@link EditOperation.Kind#SUBSTITUTE} runs, + * which is how the previous matrix-based implementation rendered fully-differing regions. + */ + private static List normalize(List operations) { + List result = new ArrayList<>(operations.size()); + for (int i = 0; i < operations.size(); i++) { + EditOperation operation = operations.get(i); + EditOperation.Kind kind = operation.getKind(); + boolean deletionFirst = kind == DELETE; + if ((deletionFirst || kind == INSERT) && i + 1 < operations.size() + && operations.get(i + 1).getKind() == (deletionFirst ? INSERT : DELETE)) { + EditOperation deletion = deletionFirst ? operation : operations.get(++i); + EditOperation insertion = deletionFirst ? operations.get(++i) : operation; + int substituted = Math.min(deletion.getLength(), insertion.getLength()); + addOperation(result, DELETE, deletion.getLength() - substituted); + addOperation(result, SUBSTITUTE, substituted); + addOperation(result, INSERT, insertion.getLength() - substituted); + } else { + addOperation(result, kind, operation.getLength()); + } + } + return result; + } +} diff --git a/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceCrossCheckSpec.groovy b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceCrossCheckSpec.groovy new file mode 100644 index 0000000000..da1356354d --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceCrossCheckSpec.groovy @@ -0,0 +1,108 @@ +/* + * Copyright 2026 the original author or authors. + * + * 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 org.spockframework.runtime.condition + +import spock.lang.Specification + +import java.util.Random + +/** + * Cross-checks {@link EditDistance} against a straightforward dynamic-programming Levenshtein + * implementation on many random inputs. The binary alphabet maximizes ties between equally + * expensive alignments, i.e. the degenerate cases a divide-and-conquer diff algorithm has to + * survive. + */ +class EditDistanceCrossCheckSpec extends Specification { + private static final char[] ALPHABET = 'ab'.toCharArray() + + def "path cost matches Levenshtein distance for random inputs (seed #@seed)"() { + given: + Random random = new Random(seed) + + expect: "for every pair, path cost and rendered alignment match the reference distance" + 200.times { + String str1 = randomString(random, random.nextInt(13)) + String str2 = randomString(random, random.nextInt(13)) + def dist = new EditDistance(str1, str2) + int expectedDistance = levenshtein(str1, str2) + + assert pathCost(dist.calculatePath()) == expectedDistance + assert dist.getDistance() == expectedDistance + assert rendersToOriginalStrings(str1, str2, dist.calculatePath()) + } + + where: + seed << (0..4) + } + + def "structured edge cases match Levenshtein distance"() { + given: + List> cases = [ + ["", ""], ["", "a"], ["a", ""], ["a", "a"], ["a", "b"], + ["ab", "ba"], ["abc", "cba"], ["aa", "aa"], ["aa", "aaa"], ["aaa", "a"], + ["abab", "baba"], ["aaaa", "aa"], ["ab", "baab"], ["aabb", "bbaa"], + ["aaaaaaaaaa", "bbbbbbbbbb"], ["aaaaaaaaaa", "aaaaabaaaa"], + ["ab" * 10, "ba" * 10], ["mississippi", "mispissippi"] + ] + + expect: + cases.each { pair -> + def dist = new EditDistance(pair[0], pair[1]) + assert dist.getDistance() == levenshtein(pair[0], pair[1]) + assert pathCost(dist.calculatePath()) == levenshtein(pair[0], pair[1]) + } + } + + private static int pathCost(List path) { + path.sum(0) { it.kind == EditOperation.Kind.SKIP ? 0 : it.length } + } + + private static boolean rendersToOriginalStrings(String str1, String str2, List path) { + def renderer = new EditPathRenderer() + String[] lines = renderer.render(str1, str2, path).split('\n', -1) + stripRendering(lines[0]) == str1 && stripRendering(lines[1]) == str2 + } + + private static String stripRendering(String line) { + line.replace('-', '').replace('(', '').replace(')', '').replace('~', '') + } + + private static String randomString(Random random, int length) { + StringBuilder result = new StringBuilder(length) + length.times { result.append(ALPHABET[random.nextInt(ALPHABET.length)]) } + result.toString() + } + + private static int levenshtein(String str1, String str2) { + int[] prev = new int[str2.length() + 1] + int[] cur = new int[str2.length() + 1] + for (int j = 0; j <= str2.length(); j++) { + prev[j] = j + } + for (int i = 1; i <= str1.length(); i++) { + cur[0] = i + for (int j = 1; j <= str2.length(); j++) { + cur[j] = Math.min(prev[j] + 1, + Math.min(cur[j - 1] + 1, prev[j - 1] + (str1.charAt(i - 1) == str2.charAt(j - 1) ? 0 : 1))) + } + int[] swap = prev + prev = cur + cur = swap + } + prev[str2.length()] + } +} diff --git a/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceLargeInputSpec.groovy b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceLargeInputSpec.groovy new file mode 100644 index 0000000000..3d1880e94b --- /dev/null +++ b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceLargeInputSpec.groovy @@ -0,0 +1,96 @@ +/* + * Copyright 2026 the original author or authors. + * + * 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 org.spockframework.runtime.condition + +import spock.lang.Shared +import spock.lang.Specification + +import java.util.Random + +import static org.spockframework.runtime.condition.EditOperation.Kind.SUBSTITUTE + +/** + * Verifies that {@link EditDistance} handles large inputs, which requires an algorithm with a better + * time and space complexity than the dense-matrix Levenshtein it replaces: for two 10,000-character + * near-identical strings the old implementation allocated more than 400 MB for the distance matrix + * and could die with {@link OutOfMemoryError} before returning anything. + */ +class EditDistanceLargeInputSpec extends Specification { + private static final int LENGTH = 10_000 + // 'z' is excluded here so that substituting it below always changes exactly one character + private static final char[] ALPHABET = 'abcdefghijklmnopqrstuv'.toCharArray() + + @Shared + String large1 = randomString(LENGTH) + + @Shared + String large2 = substituteOneChar(large1) + + def "distance between large near-identical strings is computed"() { + expect: + new EditDistance(large1, large2).getDistance() == 1 + } + + def "edit path between large near-identical strings has exactly one substitution"() { + expect: + new EditDistance(large1, large2).calculatePath() == [ + new EditOperation(EditOperation.Kind.SKIP, LENGTH.intdiv(2)), + new EditOperation(SUBSTITUTE, 1), + new EditOperation(EditOperation.Kind.SKIP, LENGTH.intdiv(2) - 1) + ] + } + + def "rendering the edit path of large near-identical strings works"() { + given: + def rendered = new EditPathRenderer().render(large1, large2, new EditDistance(large1, large2).calculatePath()) + + expect: + rendered.split('\n').length == 2 + // the one differing region is delimited by '(' and ')' on each of the two lines + rendered.count('(') == 2 + rendered.contains("(z)") + } + + def "completely different large strings are handled"() { + given: + String s1 = 'a' * LENGTH + String s2 = randomString(LENGTH) + + when: + def dist = new EditDistance(s1, s2) + + then: + dist.getDistance() >= LENGTH / 2 + + and: "the path cost equals the distance" + dist.calculatePath().sum(0, { op -> op.getKind() == EditOperation.Kind.SKIP ? 0 : op.getLength() }) == + dist.getDistance() + } + + private static String randomString(int length) { + Random random = new Random(0) + StringBuilder result = new StringBuilder(length) + length.times { result.append(ALPHABET[random.nextInt(ALPHABET.length)]) } + result.toString() + } + + private static String substituteOneChar(String str) { + char[] chars = str.toCharArray() + chars[chars.length.intdiv(2)] = 'z' // not in ALPHABET, so exactly one character changes + new String(chars) + } +} diff --git a/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceSpec.groovy b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceSpec.groovy index c36b216593..3239e7d9a8 100644 --- a/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceSpec.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditDistanceSpec.groovy @@ -26,54 +26,6 @@ import static org.spockframework.runtime.condition.EditOperation.Kind.* class EditDistanceSpec extends Specification { @Shared chars = ('a'..'z') + ('A'..'Z') + ('0'..'9') + [' '] * 10 - def "matrix for 'sitting' and 'kitten'"() { - def matrix = new EditDistance("sitting", "kitten").matrix - - expect: - matrix.size() == 8 - matrix[0] == [0, 1, 2, 3, 4, 5, 6] - matrix[1] == [1, 1, 2, 3, 4, 5, 6] - matrix[2] == [2, 2, 1, 2, 3, 4, 5] - matrix[3] == [3, 3, 2, 1, 2, 3, 4] - matrix[4] == [4, 4, 3, 2, 1, 2, 3] - matrix[5] == [5, 5, 4, 3, 2, 2, 3] - matrix[6] == [6, 6, 5, 4, 3, 3, 2] - matrix[7] == [7, 7, 6, 5, 4, 4, 3] - } - - def "matrix for 'Sunday' and 'Saturday'"() { - def matrix = new EditDistance("Sunday", "Saturday").matrix - - expect: - matrix.size() == 7 - matrix[0] == [0, 1, 2, 3, 4, 5, 6, 7, 8] - matrix[1] == [1, 0, 1, 2, 3, 4, 5, 6, 7] - matrix[2] == [2, 1, 1, 2, 2, 3, 4, 5, 6] - matrix[3] == [3, 2, 2, 2, 3, 3, 4, 5, 6] - matrix[4] == [4, 3, 3, 3, 3, 4, 3, 4, 5] - matrix[5] == [5, 4, 3, 4, 4, 4, 4, 3, 4] - matrix[6] == [6, 5, 4, 4, 5, 5, 5, 4, 3] - } - - def "matrix for 'levenshtein' and 'meilenstein'"() { - def matrix = new EditDistance("levenshtein", "meilenstein").matrix - - expect: - matrix.size() == 12 - matrix[0] == [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] - matrix[1] == [ 1, 1, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10] - matrix[2] == [ 2, 2, 1, 2, 3, 3, 4, 5, 6, 7, 8, 9] - matrix[3] == [ 3, 3, 2, 2, 3, 4, 4, 5, 6, 7, 8, 9] - matrix[4] == [ 4, 4, 3, 3, 3, 3, 4, 5, 6, 6, 7, 8] - matrix[5] == [ 5, 5, 4, 4, 4, 4, 3, 4, 5, 6, 7, 7] - matrix[6] == [ 6, 6, 5, 5, 5, 5, 4, 3, 4, 5, 6, 7] - matrix[7] == [ 7, 7, 6, 6, 6, 6, 5, 4, 4, 5, 6, 7] - matrix[8] == [ 8, 8, 7, 7, 7, 7, 6, 5, 4, 5, 6, 7] - matrix[9] == [ 9, 9, 8, 8, 8, 7, 7, 6, 5, 4, 5, 6] - matrix[10] == [10, 10, 9, 8, 9, 8, 8, 7, 6, 5, 4, 5] - matrix[11] == [11, 11, 10, 9, 9, 9, 8, 8, 7, 6, 5, 4] - } - def "path from 'sitting' to 'kitten'"() { def path = new EditDistance("sitting", "kitten").calculatePath() @@ -135,6 +87,19 @@ class EditDistanceSpec extends Specification { str2 = editedString(str1) } + def "calculated paths can be modified independently"() { + given: + def dist = new EditDistance("sitting", "kitten") + def expected = dist.calculatePath() + + when: + dist.calculatePath().clear() + dist.calculatePath().first().incLength(1) + + then: + dist.calculatePath() == expected + } + def computeDistance(List operations) { operations.sum 0, { it.getKind() == EditOperation.Kind.SKIP ? 0 : it.getLength() } } diff --git a/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditPathRendererSpec.groovy b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditPathRendererSpec.groovy index 0413045714..f2a6eee623 100644 --- a/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditPathRendererSpec.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/runtime/condition/EditPathRendererSpec.groovy @@ -67,10 +67,13 @@ class EditPathRendererSpec extends Specification { expect: renderer.render(str, seq, dist.calculatePath()) == "$out1\n$out2" + // the last case pins one specific alignment; several alignments with the same minimal + // edit distance exist and the current algorithm pairs up "row"/"-ur" instead of + // "-"/"u" and "ow"/"--" where: str = "the quick brown" seq << ["${"the"} quark ${"burn"}", new StringBuilder("the quark burn"), CharBuffer.wrap("the quark burn".toCharArray())] - out1 = "the qu(ic)k b(-)r(ow)n" - out2 = "the qu(ar)k b(u)r(--)n" + out1 = "the qu(ic)k b(row)n" + out2 = "the qu(ar)k b(-ur)n" } } diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/condition/StringComparisonRendering.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/condition/StringComparisonRendering.groovy index 28750ca1db..30fafb3df7 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/condition/StringComparisonRendering.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/condition/StringComparisonRendering.groovy @@ -93,7 +93,7 @@ null == "foo" renderedConditionContains({ assert a == b }, - "1 difference (90% similarity) (comparing subset start: 0, end1: 11, end2: 11)", + "1 difference (99% similarity) (comparing subset start: 0, end1: 251, end2: 251)", "(a)aaaaaaaaaa", "(b)aaaaaaaaaa" ) @@ -110,7 +110,7 @@ null == "foo" renderedConditionContains({ assert a == b }, - "1 difference (95% similarity) (comparing subset start: 12789, end1: 12811, end2: 12811)", + "1 difference (99% similarity) (comparing subset start: 261893, end1: 262395, end2: 262395)", "aaaaaaaaaaa(a)aaaaaaaaaa", "aaaaaaaaaaa(b)aaaaaaaaaa" ) @@ -127,7 +127,7 @@ null == "foo" renderedConditionContains({ assert a == b }, - "1 difference (91% similarity) (comparing subset start: 25588, end1: 25600, end2: 25600)", + "1 difference (99% similarity) (comparing subset start: 524036, end1: 524288, end2: 524288)", "aaaaaaaaaaa(a)", "aaaaaaaaaaa(b)") } @@ -208,8 +208,8 @@ null == "foo" | false 4 differences (20% similarity) - (foo)\\n(-~) - (bar)\\n(\\n) + (foo-~)\\n + (bar\\n)\\n ''', { assert """foo """ == """bar @@ -290,8 +290,8 @@ $b | false 7 differences (95% similarity) - Lorem ipsum\\n(\\n)Lorem ipsum (-)dolor sit amet, (-)consetetur sadipscing elitr, sed (-)diam nonumy eirmod tempor (-)invidunt ut labore et (-)dolore magna aliquyam erat, sed diam voluptua.(-~) - Lorem ipsum\\n(-~)Lorem ipsum ( )dolor sit amet, ( )consetetur sadipscing elitr, sed ( )diam nonumy eirmod tempor ( )invidunt ut labore et ( )dolore magna aliquyam erat, sed diam voluptua.(\\n) + Lorem ipsum\\n(\\n)Lorem ipsum(-) dolor sit amet,(-) consetetur sadipscing elitr, sed(-) diam nonumy eirmod tempor (-)invidunt ut labore et(-) dolore magna aliquyam erat, sed diam voluptua.(-~) + Lorem ipsum\\n(-~)Lorem ipsum( ) dolor sit amet,( ) consetetur sadipscing elitr, sed( ) diam nonumy eirmod tempor ( )invidunt ut labore et( ) dolore magna aliquyam erat, sed diam voluptua.(\\n) ''', { assert """\ Lorem ipsum @@ -336,4 +336,38 @@ dolore magna aliquyam erat, sed diam voluptua.\ } return sb } + + def "large near-identical strings render a full inline diff instead of giving up"() { + given: + String a = largeStringBuilder("the quick brown fox ", 6000) + String b = a[0..<-1] + "z" + + expect: + renderedConditionContains({ + assert a == b + }, "1 difference (99% similarity)", "(z)") + } + + def "huge completely-different strings still fall back to the no-diff message"() { + given: + int length = 4 * Math.sqrt(FailedStringComparisonRenderer.MAX_EDIT_DISTANCE_MEMORY) + String a = largeStringBuilder("a", length) + String b = largeStringBuilder("b", length) + + expect: + renderedConditionContains({ + assert a == b + }, "false", "Strings too large to calculate edit distance.") + } + + def "gstring comparison of large strings renders a real diff"() { + given: + String a = largeStringBuilder("hello world ", 6000) + GString b = "j${5}${a[1..-1]}" + + expect: + renderedConditionContains({ + assert a == b + }, "2 differences (99% similarity)", "(j5)") + } } diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/mock/TooFewInvocations.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/TooFewInvocations.groovy index d64a64fb0c..42f324a123 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/mock/TooFewInvocations.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/mock/TooFewInvocations.groovy @@ -66,7 +66,7 @@ methodName == "add" remove false 6 differences (0% similarity) (remove) - (add---) + (---add) 1 * list2.add(2) instance == target @@ -93,7 +93,7 @@ methodName == "add" size false 4 differences (0% similarity) (size) - (add-) + (-add) ''') @@ -163,7 +163,7 @@ methodName == "add" remove false 6 differences (0% similarity) (remove) - (add---) + (---add) One or more arguments(s) didn't match: 0: argument == expected