001package org.hl7.fhir.r5.test.utils;
002
003import org.apache.commons.codec.binary.Base64;
004import org.apache.commons.lang3.StringUtils;
005import org.hl7.fhir.exceptions.FHIRException;
006import org.hl7.fhir.utilities.*;
007import org.hl7.fhir.utilities.filesystem.CSFile;
008import org.hl7.fhir.utilities.filesystem.ManagedFileAccess;
009import org.hl7.fhir.utilities.json.JsonUtilities;
010import org.hl7.fhir.utilities.json.model.JsonArray;
011import org.hl7.fhir.utilities.json.model.JsonElement;
012import org.hl7.fhir.utilities.json.model.JsonNull;
013import org.hl7.fhir.utilities.json.model.JsonObject;
014import org.hl7.fhir.utilities.json.model.JsonPrimitive;
015import org.hl7.fhir.utilities.json.model.JsonProperty;
016import org.hl7.fhir.utilities.json.parser.JsonParser;
017import org.hl7.fhir.utilities.settings.FhirSettings;
018import org.w3c.dom.Document;
019import org.w3c.dom.Element;
020import org.w3c.dom.NamedNodeMap;
021import org.w3c.dom.Node;
022
023import org.hl7.fhir.utilities.tests.BaseTestingUtilities;
024
025import javax.xml.parsers.DocumentBuilder;
026import javax.xml.parsers.DocumentBuilderFactory;
027import java.io.*;
028import java.util.ArrayList;
029import java.util.List;
030import java.util.Map;
031
032public class CompareUtilities extends BaseTestingUtilities {
033
034  private static final boolean SHOW_DIFF = false;
035  private JsonObject externals;
036
037  public String createNotEqualMessage(String id, final String message, final String expected, final String actual) {
038    return new StringBuilder()
039        .append(message).append('\n')
040        .append("Expected:").append(presentExpected(expected)).append(" for "+id).append('\n')
041        .append("Actual  :").append("\""+actual+"\"").toString();
042  }
043
044  private String presentExpected(String expected) {
045    if (expected == null) {
046      return "null";
047    } else if (expected.startsWith("$") && expected.endsWith("$")) {
048      if (expected.startsWith("$choice:")) {
049        return "Contains one of "+readChoices(expected.substring(8, expected.length()-1)).toString();
050      } else if (expected.startsWith("$fragments:")) {
051        List<String> fragments = readChoices(expected.substring(11, expected.length()-1));
052        return "Contains all of "+fragments.toString();
053      } else if (expected.startsWith("$external:")) {
054        String[] cmd = expected.substring(1, expected.length() - 1).split(":");
055        if (externals != null) {
056          String s = externals.asString(cmd[1]);
057          return "\""+s+"\" (Ext)";
058        } else {
059          List<String> fragments = readChoices(cmd[2]);
060          return "Contains all of "+fragments.toString()+" (because no external string provided for "+cmd[1]+")";
061        }
062      } else {
063        switch (expected) {
064        case "$$" : return "$$";
065        case "$instant$": return "\"An Instant\"";
066        case "$uuid$": return "\"A Uuid\"";
067        default: return "Unhandled template: "+expected;
068        }
069      }
070    } else {
071      return "\""+expected+"\"";
072    }
073  }
074
075  public static String checkXMLIsSame(String id, InputStream expected, InputStream actual) throws Exception {
076    CompareUtilities self = new CompareUtilities();
077    String result = self.compareXml(id, expected, actual);
078    return result;
079  }
080
081  public static String checkXMLIsSame(String id, String expected, String actual) throws Exception {
082    CompareUtilities self = new CompareUtilities();
083    String result = self.compareXml(id, expected, actual);
084    if (result != null && SHOW_DIFF) {
085      String diff = getDiffTool();
086      if (diff != null && ManagedFileAccess.file(diff).exists() || Utilities.isToken(diff)) {
087        Runtime.getRuntime().exec(new String[]{diff, expected, actual});
088      }
089    }
090    return result;
091  }
092
093  private static String getDiffTool() throws IOException {
094    if (FhirSettings.hasDiffToolPath()) {
095      return FhirSettings.getDiffToolPath();
096    } else if (System.getenv("ProgramFiles") != null) { 
097      return Utilities.path(System.getenv("ProgramFiles"), "WinMerge", "WinMergeU.exe");
098    } else {
099      return null;
100    }
101  }
102
103  private String compareXml(String id, InputStream expected, InputStream actual) throws Exception {
104    return compareElements(id, "", loadXml(expected).getDocumentElement(), loadXml(actual).getDocumentElement());
105  }
106
107  private String compareXml(String id, String expected, String actual) throws Exception {
108    return compareElements(id, "", loadXml(expected).getDocumentElement(), loadXml(actual).getDocumentElement());
109  }
110
111  private String compareElements(String id, String path, Element expectedElement, Element actualElement) {
112    if (!namespacesMatch(expectedElement.getNamespaceURI(), actualElement.getNamespaceURI()))
113      return createNotEqualMessage(id, "Namespaces differ at " + path, expectedElement.getNamespaceURI(), actualElement.getNamespaceURI());
114    if (!expectedElement.getLocalName().equals(actualElement.getLocalName()))
115      return createNotEqualMessage(id, "Names differ at " + path ,  expectedElement.getLocalName(), actualElement.getLocalName());
116    path = path + "/" + expectedElement.getLocalName();
117    String s = compareAttributes(id, path, expectedElement.getAttributes(), actualElement.getAttributes());
118    if (!Utilities.noString(s))
119      return s;
120    s = compareAttributes(id, path, expectedElement.getAttributes(), actualElement.getAttributes());
121    if (!Utilities.noString(s))
122      return s;
123
124    Node expectedChild = expectedElement.getFirstChild();
125    Node actualChild = actualElement.getFirstChild();
126    expectedChild = skipBlankText(expectedChild);
127    actualChild = skipBlankText(actualChild);
128    while (expectedChild != null && actualChild != null) {
129      if (expectedChild.getNodeType() != actualChild.getNodeType())
130        return createNotEqualMessage(id, "node type mismatch in children of " + path, Short.toString(expectedElement.getNodeType()), Short.toString(actualElement.getNodeType()));
131      if (expectedChild.getNodeType() == Node.TEXT_NODE) {
132        if (!normalise(expectedChild.getTextContent()).equals(normalise(actualChild.getTextContent())))
133          return createNotEqualMessage(id, "Text differs at " + path, normalise(expectedChild.getTextContent()).toString(), normalise(actualChild.getTextContent()).toString());
134      } else if (expectedChild.getNodeType() == Node.ELEMENT_NODE) {
135        s = compareElements(id, path, (Element) expectedChild, (Element) actualChild);
136        if (!Utilities.noString(s))
137          return s;
138      }
139
140      expectedChild = skipBlankText(expectedChild.getNextSibling());
141      actualChild = skipBlankText(actualChild.getNextSibling());
142    }
143    if (expectedChild != null)
144      return "node mismatch - more nodes in actual in children of " + path;
145    if (actualChild != null)
146      return "node mismatch - more nodes in expected in children of " + path;
147    return null;
148  }
149
150  private boolean namespacesMatch(String ns1, String ns2) {
151    return ns1 == null ? ns2 == null : ns1.equals(ns2);
152  }
153
154  private String normalise(String text) {
155    String result = text.trim().replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
156    while (result.contains("  "))
157      result = result.replace("  ", " ");
158    return result;
159  }
160
161  private String compareAttributes(String id, String path, NamedNodeMap expected, NamedNodeMap actual) {
162    for (int i = 0; i < expected.getLength(); i++) {
163
164      Node expectedNode = expected.item(i);
165      String expectedNodeName = expectedNode.getNodeName();
166      if (!(expectedNodeName.equals("xmlns") || expectedNodeName.startsWith("xmlns:"))) {
167        Node actualNode = actual.getNamedItem(expectedNodeName);
168        if (actualNode == null)
169          return "Attributes differ at " + path + ": missing attribute " + expectedNodeName;
170        if (!normalise(expectedNode.getTextContent()).equals(normalise(actualNode.getTextContent()))) {
171          byte[] b1 = unBase64(expectedNode.getTextContent());
172          byte[] b2 = unBase64(actualNode.getTextContent());
173          if (!sameBytes(b1, b2))
174            return createNotEqualMessage(id, "Attributes differ at " + path, normalise(expectedNode.getTextContent()).toString(), normalise(actualNode.getTextContent()).toString()) ;
175        }
176      }
177    }
178    return null;
179  }
180
181  private boolean sameBytes(byte[] b1, byte[] b2) {
182    if (b1.length == 0 || b2.length == 0)
183      return false;
184    if (b1.length != b2.length)
185      return false;
186    for (int i = 0; i < b1.length; i++)
187      if (b1[i] != b2[i])
188        return false;
189    return true;
190  }
191
192  private byte[] unBase64(String text) {
193    return Base64.decodeBase64(text);
194  }
195
196  private Node skipBlankText(Node node) {
197    while (node != null && (((node.getNodeType() == Node.TEXT_NODE) && StringUtils.isWhitespace(node.getTextContent())) || (node.getNodeType() == Node.COMMENT_NODE)))
198      node = node.getNextSibling();
199    return node;
200  }
201
202  private Document loadXml(String fn) throws Exception {
203    return loadXml(ManagedFileAccess.inStream(fn));
204  }
205
206  private Document loadXml(InputStream fn) throws Exception {
207    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
208    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
209    factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
210    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
211    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
212    factory.setXIncludeAware(false);
213    factory.setExpandEntityReferences(false);
214
215    factory.setNamespaceAware(true);
216    DocumentBuilder builder = factory.newDocumentBuilder();
217    return builder.parse(fn);
218  }
219
220  public static String checkJsonSrcIsSame(String id, String expected, String actual, JsonObject externals) throws FileNotFoundException, IOException {
221    return checkJsonSrcIsSame(id, expected, actual, true, externals);
222  }
223
224  public static String checkJsonSrcIsSame(String id, String expectedString, String actualString, boolean showDiff, JsonObject externals) throws FileNotFoundException, IOException {
225    CompareUtilities self = new CompareUtilities();
226    self.externals = externals;
227    String result = self.compareJsonSrc(id, expectedString, actualString);
228    if (result != null && SHOW_DIFF && showDiff) {
229      String diff = null;
230      if (System.getProperty("os.name").contains("Linux"))
231        diff = Utilities.path("/", "usr", "bin", "meld");
232      else if (System.getenv("ProgramFiles(X86)") != null) {
233        if (Utilities.checkFile("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"), "\\WinMergeU.exe", null))
234          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
235        else if (Utilities.checkFile("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"), "\\Meld.exe", null))
236          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
237      }
238      if (diff == null || diff.isEmpty())
239        return result;
240
241      List<String> command = new ArrayList<String>();
242      String expected = Utilities.path("[tmp]", "expected" + expectedString.hashCode() + ".json");
243      String actual = Utilities.path("[tmp]", "actual" + actualString.hashCode() + ".json");
244      TextFile.stringToFile(expectedString, expected);
245      TextFile.stringToFile(actualString, actual);
246      command.add(diff);
247      if (diff.toLowerCase().contains("meld"))
248        command.add("--newtab");
249      command.add(expected);
250      command.add(actual);
251
252      ProcessBuilder builder = new ProcessBuilder(command);
253      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
254      builder.start();
255
256    }
257    return result;
258  }
259
260  public static String checkJsonIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
261    CompareUtilities self = new CompareUtilities();
262    String result = self.compareJson(id, expected, actual);
263    if (result != null && SHOW_DIFF) {
264      String diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
265      List<String> command = new ArrayList<String>();
266      command.add("\"" + diff + "\" \"" + expected +  "\" \"" + actual + "\"");
267
268      ProcessBuilder builder = new ProcessBuilder(command);
269      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
270      builder.start();
271
272    }
273    return result;
274  }
275
276  private String compareJsonSrc(String id, String expected, String actual) throws FileNotFoundException, IOException {
277    JsonObject actualJsonObject = JsonParser.parseObject(actual);
278    JsonObject expectedJsonObject = JsonParser.parseObject(expected);
279    return compareObjects(id, "", expectedJsonObject, actualJsonObject);
280  }
281
282  private String compareJson(String id, String expected, String actual) throws FileNotFoundException, IOException {
283    JsonObject actualJsonObject = JsonParser.parseObject(TextFile.fileToString(actual));
284    JsonObject expectedJsonObject = JsonParser.parseObject(TextFile.fileToString(expected));
285    return compareObjects(id, "", expectedJsonObject, actualJsonObject);
286  }
287
288  private String compareObjects(String id, String path, JsonObject expectedJsonObject, JsonObject actualJsonObject) {
289    List<String> optionals = listOptionals(expectedJsonObject);
290    List<String> countOnlys = listCountOnlys(expectedJsonObject);
291    for (JsonProperty en : actualJsonObject.getProperties()) {
292      String n = en.getName();
293      if (!n.equals("fhir_comments")) {
294        if (expectedJsonObject.has(n)) {
295          String s = compareNodes(id, path + '.' + n, expectedJsonObject.get(n), en.getValue(), countOnlys.contains(n));
296          if (!Utilities.noString(s))
297            return s;
298        } else
299          return "properties differ at " + path + ": missing property " + n;
300      }
301    }
302    for (JsonProperty en : expectedJsonObject.getProperties()) {
303      String n = en.getName();
304      if (!n.equals("fhir_comments") && !n.equals("$optional$") && !optionals.contains(n)) {
305        if (!actualJsonObject.has(n))
306          return "properties differ at " + path + ": missing property " + n;
307      }
308    }
309    return null;
310  }
311
312  private List<String> listOptionals(JsonObject expectedJsonObject) {
313    List<String> res = new ArrayList<>();
314    if (expectedJsonObject.has("$optional-properties$")) {
315      res.add("$optional-properties$");
316      res.add("$count-arrays$");
317      for (String s : expectedJsonObject.getStrings("$optional-properties$")) {
318        res.add(s);
319      }
320    }
321    return res;
322  }
323
324  private List<String> listCountOnlys(JsonObject expectedJsonObject) {
325    List<String> res = new ArrayList<>();
326    if (expectedJsonObject.has("$count-arrays$")) {
327      for (String s : expectedJsonObject.getStrings("$count-arrays$")) {
328        res.add(s);
329      }
330    }
331    return res;
332  }
333
334  private String compareNodes(String id, String path, JsonElement expectedJsonElement, JsonElement actualJsonElement, boolean countOnly) {
335    if (!(expectedJsonElement instanceof JsonPrimitive && actualJsonElement instanceof JsonPrimitive)) {
336      if (actualJsonElement.getClass() != expectedJsonElement.getClass()) {
337        return createNotEqualMessage(id, "properties differ at " + path, expectedJsonElement.getClass().getName(), actualJsonElement.getClass().getName());
338      }
339    }
340    if (actualJsonElement instanceof JsonPrimitive) {
341      JsonPrimitive actualJsonPrimitive = (JsonPrimitive) actualJsonElement;
342      JsonPrimitive expectedJsonPrimitive = (JsonPrimitive) expectedJsonElement;
343      if (actualJsonPrimitive.isJsonBoolean() && expectedJsonPrimitive.isJsonBoolean()) {
344        if (actualJsonPrimitive.asBoolean() != expectedJsonPrimitive.asBoolean())
345          return createNotEqualMessage(id, "boolean property values differ at " + path , expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
346      } else if (actualJsonPrimitive.isJsonString() && expectedJsonPrimitive.isJsonString()) {
347        String actualJsonString = actualJsonPrimitive.asString();
348        String expectedJsonString = expectedJsonPrimitive.asString();
349        if (!(actualJsonString.contains("<div") && expectedJsonString.contains("<div")))
350          if (!matches(actualJsonString, expectedJsonString))
351            if (!sameBytes(unBase64(actualJsonString), unBase64(expectedJsonString)))
352              return createNotEqualMessage(id, "string property values differ at " + path, expectedJsonString, actualJsonString);
353      } else if (actualJsonPrimitive.isJsonNumber() && expectedJsonPrimitive.isJsonNumber()) {
354        if (!actualJsonPrimitive.asString().equals(expectedJsonPrimitive.asString()))
355          return createNotEqualMessage(id, "number property values differ at " + path, expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
356      } else if (expectedJsonElement instanceof JsonNull) {
357        return actualJsonPrimitive instanceof JsonNull ? null : createNotEqualMessage(id, "null Properties differ at " + path, "null", actualJsonPrimitive.asString());
358      } else {
359        return createNotEqualMessage(id, "property types differ at " + path, expectedJsonPrimitive.asString(), actualJsonPrimitive.asString());
360      }
361    } else if (actualJsonElement instanceof JsonObject) {
362      String s = compareObjects(id, path, (JsonObject) expectedJsonElement, (JsonObject) actualJsonElement);
363      if (!Utilities.noString(s))
364        return s;
365    } else if (actualJsonElement instanceof JsonArray) {
366      JsonArray actualArray = (JsonArray) actualJsonElement;
367      JsonArray expectedArray = (JsonArray) expectedJsonElement;
368
369      int as = actualArray.size();
370      int es = expectedArray.size();
371      if (countOnly) {
372        if (as != es) {
373          return createNotEqualMessage(id, "array item count differs at " + path, Integer.toString(es), Integer.toString(as));
374        }
375      } else {
376        int expectedMin = countExpectedMin(expectedArray);
377        int oc = optionalCount(expectedArray);
378
379        if (as > es || as < expectedMin)
380          return createNotEqualMessage(id, "array item count differs at " + path, Integer.toString(es), Integer.toString(as));
381        int c = 0;
382        for (int i = 0; i < es; i++) {
383          if (c >= as) {
384            if (i >= es - oc && isOptional(expectedArray.get(i))) {
385              return null; // this is OK 
386            } else {
387              return "One or more array items did not match at "+path+" starting at index "+i;
388            }
389          }
390          String s = compareNodes(id, path + "[" + Integer.toString(i) + "]", expectedArray.get(i), actualArray.get(c), false);
391          if (!Utilities.noString(s) && !isOptional(expectedArray.get(i))) {
392            return s;
393          }
394          if (Utilities.noString(s)) {
395            c++;
396          }
397        }
398        if (c < as) {
399          return "Unexpected Node found in array at '"+path+"' at index "+c;
400        }
401      }
402    } else
403      return "unhandled property " + actualJsonElement.getClass().getName();
404    return null;
405  }
406
407  private int optionalCount(JsonArray arr) {
408    int c = 0;
409    for (JsonElement e : arr) {
410      if (e.isJsonObject()) {
411        JsonObject j = e.asJsonObject();
412        if (j.has("$optional$") && j.asBoolean("$optional$")) {
413          c++;
414        }
415      }
416    }
417    return c;
418  }
419
420  private boolean isOptional(JsonElement e) {
421    return e.isJsonObject() && e.asJsonObject().has("$optional$");
422  }
423
424  private int countExpectedMin(JsonArray array) {
425    int count = array.size();
426    for (JsonElement e : array) {
427      if (isOptional(e)) {
428        count--;
429      }
430    }
431    return count;
432  }
433
434  private boolean matches(String actualJsonString, String expectedJsonString) {
435    if (expectedJsonString.startsWith("$") && expectedJsonString.endsWith("$")) {
436      if (expectedJsonString.startsWith("$choice:")) {
437        return Utilities.existsInList(actualJsonString, readChoices(expectedJsonString.substring(8, expectedJsonString.length()-1)));
438
439      } else if (expectedJsonString.startsWith("$fragments:")) {
440        List<String> fragments = readChoices(expectedJsonString.substring(11, expectedJsonString.length()-1));
441        for (String f : fragments) {
442          if (!actualJsonString.toLowerCase().contains(f.toLowerCase())) {
443            return false;
444          }
445        }
446        return true;
447      } else if (expectedJsonString.startsWith("$external:")) {
448        String[] cmd = expectedJsonString.substring(1, expectedJsonString.length() - 1).split("\\:");
449        if (externals != null) {
450          String s = externals.asString(cmd[1]);
451          return actualJsonString.equals(s);
452        } else if (cmd.length <= 2) {
453          return true;
454        } else {
455          List<String> fragments = readChoices(cmd[2]);
456          for (String f : fragments) {
457            if (!actualJsonString.toLowerCase().contains(f.toLowerCase())) {
458              return false;
459            }
460          }
461          return true;
462        }
463      } else {
464        switch (expectedJsonString) {
465        case "$$" : return true;
466        case "$instant$": return actualJsonString.matches("([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))");
467        case "$uuid$": return actualJsonString.matches("urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
468        default: 
469          throw new Error("Unhandled template: "+expectedJsonString);
470        }
471      }
472    } else {
473      return actualJsonString.equals(expectedJsonString);
474    }
475  }
476
477  private List<String> readChoices(String s) {
478    List<String> list = new ArrayList<>();
479    for (String p : s.split("\\|")) {
480      list.add(p);
481    }
482    return list;
483  }
484
485  public static String checkTextIsSame(String id, String expected, String actual) throws FileNotFoundException, IOException {
486    return checkTextIsSame(id, expected, actual, true);
487  }
488
489  public static String checkTextIsSame(String id, String expectedString, String actualString, boolean showDiff) throws FileNotFoundException, IOException {
490    CompareUtilities self = new CompareUtilities();
491    String result = self.compareText(id, expectedString, actualString);
492    if (result != null && SHOW_DIFF && showDiff) {
493      String diff = null;
494      if (System.getProperty("os.name").contains("Linux"))
495        diff = Utilities.path("/", "usr", "bin", "meld");
496      else {
497        if (Utilities.checkFile("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge"), "\\WinMergeU.exe", null))
498          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "WinMerge", "WinMergeU.exe");
499        else if (Utilities.checkFile("WinMerge", Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld"), "\\Meld.exe", null))
500          diff = Utilities.path(System.getenv("ProgramFiles(X86)"), "Meld", "Meld.exe");
501      }
502      if (diff == null || diff.isEmpty())
503        return result;
504
505      List<String> command = new ArrayList<String>();
506      String actual = Utilities.path("[tmp]", "actual" + actualString.hashCode() + ".json");
507      String expected = Utilities.path("[tmp]", "expected" + expectedString.hashCode() + ".json");
508      TextFile.stringToFile(expectedString, expected);
509      TextFile.stringToFile(actualString, actual);
510      command.add(diff);
511      if (diff.toLowerCase().contains("meld"))
512        command.add("--newtab");
513      command.add(expected);
514      command.add(actual);
515
516      ProcessBuilder builder = new ProcessBuilder(command);
517      builder.directory(ManagedFileAccess.csfile(Utilities.path("[tmp]")));
518      builder.start();
519
520    }
521    return result;
522  }
523
524
525  private String compareText(String id, String expectedString, String actualString) {
526    for (int i = 0; i < Integer.min(expectedString.length(), actualString.length()); i++) {
527      if (expectedString.charAt(i) != actualString.charAt(i))
528        return createNotEqualMessage(id, "Strings differ at character " + Integer.toString(i), charWithContext(expectedString, i), charWithContext(actualString, i));
529    }
530    if (expectedString.length() != actualString.length())
531      return createNotEqualMessage(id, "Strings differ in length but match to the end of the shortest.", Integer.toString(expectedString.length()), Integer.toString(actualString.length()));
532    return null;
533  }
534
535  private String charWithContext(String s, int i) {
536    String result = s.substring(i, i+1);
537    if (i > 7) {
538      i = i - 7;
539    }
540    int e = i + 20;
541    if (e > s.length()) {
542      e = s.length();
543    }
544    if (e > i+1) {
545      result = result + " with context '"+s.substring(i, e)+"'";
546    }
547    return result;
548  }
549
550}