4
\$\begingroup\$

This is my second follow-up. Major changes made:

  • Use a Map instead of an enum for the Morse characters
  • Added (space) in Morse characters
  • Added method play() to play the Morse code
  • Comments are now JavaDoc comments
  • Moved CHAR_SEPARATOR and WORD_SEPARATOR to the class that contains the Map of characters

MorseString.java:

import java.util.Objects;
import java.util.regex.Pattern;

import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;

public class MorseString {

    /**
     * This class represents a Morse Code String. It uses the International
     * alphabet (M.1677).
     */

    public static final char DOT = '.';
    public static final char DASH = '-';

    private final String string;
    private final String codeString;

    private int speed = 50; // default

    /**
     * Constructor that takes the Morse Code as a String as a parameter
     */

    private MorseString(String codeString) {
        if (!isValidMorse(codeString)) {
            throw new IllegalArgumentException("\"" + codeString
                    + "\" is not a valid Morse Code");
        }
        this.string = translate(codeString);
        this.codeString = codeString;
    }

    private static final Pattern VALID_MORSE_PATTERN = Pattern.compile("("
            + Pattern.quote(Character.toString(DASH)) + "|"
            + Pattern.quote(Character.toString(DOT)) + "|"
            + Pattern.quote(Character.toString(MorseCharacter.WORD_SEPARATOR))
            + "|\\s)*");

    /**
     * Checks if it is a valid Morse Code
     * 
     * @param ch
     *            the charSequence to check
     * @return true if it is a valid Morse Pattern, false otherwise.
     */

    public boolean isValidMorse(CharSequence ch) {
        return VALID_MORSE_PATTERN.matcher(ch).matches();
    }

    /**
     * Traslates from Morse in a String to a String e.g. ".... .." to "hi"
     */

    private String translate(String code) {
        if (code.isEmpty()) {
            return code;
        }
        String[] words = code.split(Character
                .toString(MorseCharacter.WORD_SEPARATOR));
        StringBuilder result = new StringBuilder();
        for (String word : words) {
            String[] letters = word.trim().split(
                    Character.toString(MorseCharacter.CHAR_SEPARATOR));
            for (String letter : letters) {
                result.append(MorseCharacter.decode(letter));
            }
            result.append(MorseCharacter.CHAR_SEPARATOR);
        }
        return result.toString().substring(0, result.length() - 1);
    }

    /**
     * Parses text to create a Morse String
     * 
     * @param text
     *            the text to parse from
     * @return a MorseString representing the parameter
     */

    public static MorseString parseFromText(String text) {
        int length = text.length();
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < length; i++) {
            result.append(MorseCharacter.encode(text.charAt(i))).append(
                    MorseCharacter.CHAR_SEPARATOR);
        }
        return new MorseString(result.toString().trim());
    }

    /**
     * Parses a Morse sequence from String
     * 
     * @param morse
     *            the morse code in a String
     * @return a MorseString representing the parameter
     */

    public static MorseString parseFromMorse(String morse) {
        return new MorseString(morse);
    }

    /**
     * Returns the code as a String e.g. if the object represents "hi" in Morse,
     * it returns ".... .."
     */

    @Override
    public String toString() {
        return codeString;
    }

    @Override
    public int hashCode() {
        return Objects.hash(string, codeString);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof MorseString)) {
            return false;
        }
        MorseString other = (MorseString) obj;
        return !(codeString == null && other.codeString != null)
                && codeString.equals(other.codeString)
                && !(string == null && other.string != null)
                && string.equals(other.string);
    }

    /**
     * Returns the result of the translations e.g. if the object represents "hi"
     * in Morse, it returns "hi"
     * 
     * @return the result of the translations
     */

    public String asText() {
        return string;
    }

    /**
     * Plays the Morse Code. Uses the following: - A dot is 100 ms - A dash is 3
     * * dot - Between each dot or dash is dot ms - Between each letter is dash
     * ms - Between each word is 7 * dot ms
     * 
     * @throws LineUnavailableException
     */

    public void play() throws LineUnavailableException {
        char[] code = codeString.toCharArray();
        for (char c : code) {
            if (c == DOT) {
                sound(840, speed);
            } else if (c == DASH) {
                sound(840, 3 * speed);
            } else if (c == MorseCharacter.CHAR_SEPARATOR) {
                try {
                    Thread.sleep(3 * speed);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } else if (c == MorseCharacter.WORD_SEPARATOR) {
                // Since a word separator always has a char separator before and
                // after, only sleep 100 ms is required.
                try {
                    Thread.sleep(speed);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

    }

    /**
     * Sets the speed for playing the Morse Code
     * 
     * @param speed
     *            the speed, 5 - 25
     */

    public void setSpeed(int speed) {
        if (speed > 25) {
            throw new IllegalArgumentException("Speed too fast");
        } else if (speed < 5) {
            throw new IllegalArgumentException("Speed too slow");
        }
        this.speed = 1000 / speed;
    }

    private void sound(int hz, int msecs) throws LineUnavailableException {
        byte[] buf = new byte[msecs * 8];
        for (int i = 0; i < buf.length; i++) {
            double angle = i / (8000.0 / hz) * 2.0 * Math.PI;
            buf[i] = (byte) (Math.sin(angle) * 127.0);
        }
        for (int i = 0; i < 80 && i < buf.length / 2; i++) {
            buf[i] = (byte) (buf[i] * i / 80.0);
            buf[buf.length - 1 - i] = (byte) (buf[buf.length - 1 - i] * i / 80.0);
        }
        AudioFormat af = new AudioFormat(8000f, 8, 1, true, false);
        SourceDataLine sdl = AudioSystem.getSourceDataLine(af);
        sdl.open(af);
        sdl.start();
        sdl.write(buf, 0, buf.length);
        sdl.drain();
        sdl.close();
    }
}

MorseCharacter.java:

import java.util.HashMap;
import java.util.Map;

public class MorseCharacter {

    /**
     * The International Alphabet (M.1677)
     */

    private static Map<Character, String> codes = new HashMap<>();
    private static Map<String, Character> chars = new HashMap<>();

    public static final char CHAR_SEPARATOR = ' ';
    public static final char WORD_SEPARATOR = '/';

    static {
        codes.put('A', ".-");
        codes.put('B', "-...");
        codes.put('C', "-.-.");
        codes.put('D', "-..");
        codes.put('E', ".");
        codes.put('F', "..-.");
        codes.put('G', "--.");
        codes.put('H', "....");
        codes.put('I', "..");
        codes.put('J', ".---");
        codes.put('K', "-.-");
        codes.put('L', ".-..");
        codes.put('M', "--");
        codes.put('N', "-.");
        codes.put('O', "---");
        codes.put('P', ".--.");
        codes.put('Q', "--.-");
        codes.put('R', ".-.");
        codes.put('S', "...");
        codes.put('T', "-");
        codes.put('U', ".--");
        codes.put('V', "...-");
        codes.put('W', ".--");
        codes.put('X', "-..-");
        codes.put('Y', "-.--");
        codes.put('Z', "--..");
        codes.put('0', "-----");
        codes.put('1', ".----");
        codes.put('2', "..---");
        codes.put('3', "...--");
        codes.put('4', "....-");
        codes.put('5', ".....");
        codes.put('6', "-....");
        codes.put('7', "--...");
        codes.put('8', "---..");
        codes.put('9', "----.");
        codes.put('.', ".-.-.-");
        codes.put(',', "--..--");
        codes.put(' ', Character.toString(CHAR_SEPARATOR) + WORD_SEPARATOR);
        for (char c : codes.keySet()) {
            chars.put(codes.get(c), c);
        }
    }

    public static char decode(String s) {
        return chars.get(s);
    }

    public static String encode(char c) {
        return codes.get(Character.toUpperCase(c));
    }

}
\$\endgroup\$
1
  • \$\begingroup\$ Correctness: The regex check is insufficient (it accepts a string of dots of any length for example) and can be easily done during the actual parsing. Personally I find DASH and DOT constants pretty pointless and making the code harder to read but that's pretty subjective and could easily be argued differently. \$\endgroup\$
    – Voo
    Commented Dec 28, 2014 at 22:08

1 Answer 1

6
\$\begingroup\$

Tests? Where are the tests?

Where are the unit tests?

MorseString import javax.sound.sampled looks like violation of SRP - Single Responsibility Principle

The import statements are already hinting at possibly misplaced responsibility, knowing the MorseString class from the previous review. More about this further down.

Correct usage of JavaDoc

In the source code fragment ...

public class MorseString {

    /**
     * This class represents a Morse Code String. It uses the International
     * alphabet (M.1677).
     */

    public static final char DOT = '.';
}

... the JavaDoc comment syntactically does not describe class MorseString, as intended, but the field char DOT.

JavaDoc comments are always for the one next thing that follows, that's how JavaDoc works.

More hints:

  • Avoid redundant text like "this class" or "this method". The reader knows that it's a class or a method.
  • Keep in mind that the first sentence is also extracted by JavaDoc as a brief for summary views.
  • Write one sentence per line and do NOT use a hard limit for the line length. Because if you use a hard limit for the line length and do not use one sentence per line, you keep on reformatting text. Even worse, in a team, you end up with merge conflicts in JavaDoc comments, which just sucks and drives everyone crazy. New sentence - new line. Just like with normal source statements.
  • No empty line between a JavaDoc comment and what it documents.
  • Make use of the proper JavaDoc tags.
  • Do not repeat / explain the obvious. Explain the non-obvious, give a holistic guide, i.e. use hyperlinks.
  • The first sentence is automatically used by JavaDoc as brief description. Therefore, placing the first full stop correctly matters a lot. It's a good habit to always care even about such seemingly minor details like not forgetting the full stop.

Here's how it could look like for class MorseString and constructor MorseString(String):

/**
 * A Morse String according to International alphabet M.1677.
 * @see <a href="http://www.itu.int/dms_pubrec/itu-r/rec/m/R-REC-M.1677-1-200910-I!!PDF-E.pdf">M.1677 (PDF version)</a>.
 * @see <a href="http://en.wikipedia.org/wiki/Morse_code">Morse code (Wikipedia)</a>
 */
public class MorseString {
    // ...

    /** Create a {@link MorseString} from a {@code String codeString}.
     * The {@link #string} will be derived from {@code codeString}.
     *
     * @param codeString
     *      String with morse code from which to create this {@link MorseString}.
     * @see #parseFromText(String) for a factory method that creates a {@link MorseString} from plain text.
     * @see #parseFromMorse(String) for a factory method that creates a {@link MorseString} from a code string.
     */
    private MorseString(String codeString) {
    }
}

You may want to use a little bit less tags if readability is more important. Fact is JavaDoc still does not yet support markdown out-of-the-box (sigh).

However, if you favor Markdown (like me), you may simply want to Google for Markdown support for JavaDoc, there are solutions available.

DOT and DASH are probably not required.

We like to introduce constants for magic numbers because the names of these constants add meaningful information, like i.e.

private static final int SAMPLING_FREQUENCY_IN_HZ = 44100;

In the case of

public static final char DOT = '.';

I don't really see a value add, I think in this particular case it rather complicates things.

Except, of course, if you plan to replace these constants with variables and allow users to create different codings than just ".-", i.e. "._" instead.

DOT and DASH should probably be private.

I don't see how other classes would currently benefit from DOT and DASH. Oh yes, they could reference it. But we should keep in mind that they are public static final <primitive>s, which are replaced by the compiler at compile-time. If class Foo uses MorseString.DOT, and MorseString.DOT changes, class Foo needs to be recompiled.

If the intention of DOT and DASH being public is that other classes could use these constants, and that these constants might have different values in different setups, then these constants need to be hidden with accessor methods, otherwise the independent deployability of the users of MorseString would not work as expected.

Names string and codeString

How about renaming them to plainText and morseText? I think that these names would communicate the intent better than the current names. Especially, string itself, standing on its own, doesn't communicate any intent at all, it needs the presence of its sibling codeString to communicate its own intent.

string and codeString are final - good!

This tells me that instances of MorseString are immutable. If I want a different MorseString, I need a new object. With which I'm perfectly happy. This is how similar classes like String, Integer etc. work as well. This is how Date actually should have been designed, too. And finally in Java 8 the new stuff in java.time is.

speed looks like a violation of SRP - Single Responsibility Principle

What is the relation between speed and MorseString - other than speed was put in the same class?

I think that speed is unrelated to MorseString, it is related to how to play a MorseString, and I could play the same MorseString with different speeds on different audio channels at the same time. Or wait, I can't, because the design doesn't allow me, I need to create a second MorseString with the same content first!

That's not how it should be.

If the responsibility of playing a Morse tune would be separated from the MorseString, one could play separate Morse tunes at the same time.

Meaning of speed unclear.

Is it characters per minute? Words per minute? Does a higher value indicate higher speed or lower speed?

Factory methods parseFromText and parseFromMorse - good!

These method names make it clear what they do, and I cannot get confused about what they take for input.

Constructor MorseString and factory methods parseFromText and parseFromMorse, lack of design symmetry.

In the case of parseFromText(), the responsibility for the "hard work" is with praseFromText(). In the case of praseFromMorse(), the responsibility for the "hard work" is with the constructor MorseString().

This asymmetry in responsibility has no obvious reason (I need to behave as if I knew not the history of the code).

I'd move the logic completely from the constructor into the factory methods and leave the constructor as dumb as it could be:

private MorseString(final String morseText, final String plainText) {
    this.morseText = morseText;
    this.plainText = plainText;
}

Position of VALID_MORSE_PATTERN

It's usual in Java to do the following sequence inside classes:

  1. fields
    1. static final fields.
    2. static fields.
    3. non-static fields
  2. constructors
  3. methods

The position of VALID_MORSE_PATTERN is after the constructor and thus violates this rule.

Name VALID_MORSE_PATTERN

Although the variable is never re-assigned, it is not in the nature of constants in the sense that it does not replace a primitive value constant or some similar constant object.

In such cases, it is not mandatory to use UPPER_CASE, lowerCamelCase would be accepted instead as well. I personally prefer lowerCamelCase because UPPER_CASE feels like the source is SHOUTING AT ME.

Construction of VALID_MORSE_PATTERN - overly complex Regex

I think there are two group of people, those who understand regexes and those who don't.

For those who don't understand regexes, they're cryptic anyway, and Pattern.quote() probably makes no difference to them.

For those who understand regexes, Pattern.quote() makes it unnecessarily complicated. I'd use Pattern.quote() in cases where the source code has no control over the String that needs to be quote()d. In cases where the source code has control over the String, I would use the String directly.

The following should be an equivalent Pattern object, just that it's constructed in a simpler way:

private static final Pattern VALID_MORSE_PATTERN = Pattern.compile("[-.\\s]*");

SRP - Single Responsibility Principle violation in VALID_MORSE_PATTERN / isValidMorse()

This duplicates functionality from MorseCharacter. And it's inconsistent because it claims that "------------------------" is a valid morse code, which is not. If MorseCharacter would be the sole responsible for the task, there would be no such problem.

translate(String) overly complex.

I think the method is overly complex. It has 15 lines of method body. That's three times as much as I normally accept in my production code. I'd consider simplifying or splitting it.

code.isEmpty() check in translate(String) should not be required.

If the algorithm is perfect, it will not need to treat code.isEmpty() as a special case. The algorithm would automatically yield an empty plainText for an empty morseText.

Inconsistent parameter name code in translate(String).

The parameter code is named inconsistently from other places in the code, where a variable of the same semantic class would be named codeString. And as mentioned above, I'd rather go for morseText (and plainText).

Spelling mistake in JavaDoc of translate(String)

Traslates -> Translates

Choose consistent names and stick to them.

The same type of data is called codeString in some places (field, constructor) and code in other places (translate()) and morse in yet other places (parseFromMorse()). Stick to one name and use it only. As said I suggest morseText and plainText.

JavaDoc of overridden toString()

When you override a method and provide new JavaDoc, you should make a conscious decision about whether you want to replace the overridden method's JavaDoc or whether you want to amend / extend it.

The way you wrote your JavaDoc means you replace the documentation. However, the text suggests that you would rather want to extend the documentation.

In that case, use the following JavaDoc:

/**
 * {@inheritDoc}
 * Returns the code as a String, i.e. if this MorseString represents "hi", it returns ".... ..".
 */
@Override
public String toString() {
    return codeString;
}

No blank line between a JavaDoc comment and what it documents.

It's a convention that there is no blank line between a JavaDoc comment and what it documents. Actually that convention is there for all comments.

Overly-complex equals() method

The guard if (this == obj) is a performance optimization. As it is a usual pattern, you can keep it, but I wouldn't.

However, the null checks are redundant, you've programmed MorseString in a way that string and codeString will never be null. You can directly go for

return codeString.equals(other.codeString) && string.equals(other.string);

Methods toString() and asText()

I would

  • keep toString() as it is.
  • Rename method asText() to getPlainText() which would return the string / plainText.
  • Add a new method getMorseText() which would return the codeString / morseText.

Method play() misplaced responsibility / mix of responsibilities.

The class MorseString contains business logic. The method play() contains presentation (a delivery mechanism as Uncle Bob calls it).

Business Logic and presentation should be in separate classes, even in separate components (in IntelliJ IDEA: separate modules, in Eclipse: separate projects).

Move everything related to getting noisy to a separate class MorsePlayer or so.

Method play() could use a switch-case

There is a chain of four if-checks on the very same primitive variable c. The advantage of a switch-case in this situation is that it makes it more obvious that it's switching on the same variable. Actually, we could call this chain of four ifs a "switch-case in disguise".

Duplicate code in play()

The lines

try {
    Thread.sleep(speed);
} catch (InterruptedException e) {
    e.printStackTrace();
}

are duplicate code and can be refactored into a funtion - if taking the speed argument into a parameter.

private static void silence(final int durationInMilliSeconds) {
    try {
        Thread.sleep(durationInMilliSeconds);
    } catch (final InterruptedException e) {
        e.printStackTrace();
    }
}

Then the code for play() would become 8 lines shorter.

Further refactoring of play() and sound()

Actually what you really want to do is some milliseconds of silence. You can get those by sleeping. But you can also get those by playing silence. If you would add a double volume parameter, you could use sound() not only for the noise part of the Morse signal, but also for the silence part. Then you could just check if you need noise:

public void play() throws LineUnavailableException {
    for (final char c : morseText.toCharArray())
        play(c);
}
private static void play(final char c) throws LineUnavailableException {
    final boolean isNoisy = c == DOT || c == DASH;
    final int units = c == MorseCharacter.WORD_SEPARATOR || c == DASH ? 3 : 1;
    final sound(840, units * speed, isNoisy ? 1.0 : 0.0);
}

Contract of method setSpeed() regarding parameter speed unclear

How do I know what the meaning of speed is? Even worse, the meaning of field speed and paramter speed are different. What is speed? Is it a time? A velocity? A frequency? What unit? Is it words per minute or characters per minute? Is it on CODEX or on PARIS?

Method sound() creates the same byte[] contents over and over again.

Consider caching the byte[] for the sound. Basically there are only two sounds - dit and dah, and maybe a third one for one unit of silence. In future class MorsePlayer, you could create these byte[] whenever the speed or frequency are set.

Parameter names in method sound()

What I already like a lot about the parameter names in sound() is that they tell me the units. In this case, I can even guess that hz is probably the frequency for the tone and msecs is the duration of the tone.

Still, I would go for frequencyInHz and durationInMilliSeconds or durationIn_ms.

I wouldn't go for durationInMs because would that be milliseconds or megaseconds? SI prefixes are case-sensitive and that conflicts with camel case. Of course megaseconds makes no sense here, however, I always seek for rules which are universally applicable.

Responsibilities of sound()

sound() currently has the following responsibility structure:

  • Create the sound
    • Allocate the sound buffer
    • Create the base wave
    • Soften the attack
    • Soften the release (which by the way I think is obfuscated already)
  • Play the sound
    • Allocate audio resources
    • Play sound using audio resources
    • Release audio resources

That's an awful lot for a single method!

I'd go, as already mentioned above, for a class MorsePlayer. You could make it allocate the audio resources in the constructor or an open() method, and release the audio resources in a close() method. The methods for creating the sounds could be createSound(), createWave(), smoothenAttack() and smoothenRelease(). They would be called whenever the parameters for sound creation are changed.

Magic numbers in sound()

The method sound uses two magic numbers, 8000 and 80. I'd introduce constants of variables for them, like

private static final int SAMPLING_FREQUENCY_IN_HZ = 8000;
private static final int NUMBER_OF_FADE_SAMPLES = 80;

For 127, you could use Byte.MAX_VALUE.

For the 8 in AudioFormat(), you could use Byte.SIZE.

boolean arguments to AudioFormat() in sound()

I hate boolean arguments. In most cases you cannot tell what they mean and you cannot remember in which order they were supposed to come.

In that case, I suggest introducing variables for readability.

Like this:

final boolean isSigned = true;
final boolean isBigEndian = false;
new AudioFormat(..., isSigned, isBigEndian);

Or constants, like this:

private static final boolean AUDIOFORMAT_SIGNED = true;
private static final boolean AUDIOFORMAT_UNSIGNED = false;
private static final boolean AUDIOFORMAT_BIG_ENDIAN = true;
private static final boolean AUDIOFORMAT_LITTLE_ENDIAN = false;

new AudioFormat(..., AUDIOFORMAT_SIGNED, AUDIOFORMAT_LITTLE_ENDIAN);

Or at least comments, like this:

new AudioFormat(..., /*signed: yes*/ true, /*little endian: no*/ false);

I'd go for the constants.

Thread.sleep()

It throws InterruptedException, which sucks but is a necessity. That however leaves us with code that sucks. You could play an inaudible sound instead by extending sound with a third parameter double volumeFactor which would be 1.0 for full volume and 0.0 for silence.

Wrong comment in play()

In play() you say // ... only sleep 100 ms is required. But you do not do Thread.sleep(100);, you do Thread.sleep(speed);. The code is correct, the comment is wrong. If I knew less about Morse all I could tell is the code and the comment are inconsistent and would be left puzzled and baffled about which of those two is right and which is wrong and what to change, code or comment.

We could speak of units, then it becomes clear and independent of speed settings.

Is MorseCharacter still the right name?

MorseCharacter does not represent a single character, instead it is for translating characters using the codes.

How about renaming MorseCharacter to MorseAlphabet?

MorseCharacter: Use ResourceBundle for data?

Consider moving the data used by codes.put into a data file so that the source code needs no change when you extend the alphabet. The class which would typically be used for this is ResourceBundle, which effectively resolves to a .properties file loaded via ClassLoader and Properties.

How about a consistency check in MorseCharacter?

The maps codes and chars should have the same number of elements.

assert chars.size() == codes.size();

MorseCharacter.decode() might throw NullPointerException

When the Map chars has no entry for String s, the Character returned by chars.get(s) is null. When converting null from Character into char it will throw a NullPointerException.

I'd rather check for null and throw IllegalArgumentException, which would be more informative.

MorseCharacter.encode() might return null

I think that's worth mentioning, or changing the contract to throw IllegalArgumentException instead.

Ideas for further programming

I'm sure you will not run out of ideas. Still allow me to share what came into my mind.

  • Complete the Morse alphabet with more punctuation and international characters.
  • Create a MidiMorsePlayer which would use MIDI instead of samples for playing the Morse code.
  • Make multiple Morse alphabets available, with M.1677 being the default.
  • Create a MorseSoundDecoder which takes sampled data and decodes it into a MorseString by analyzing the sounds and gaps.
  • Extend the MorsePlayer to create an entire audio sample for a MorseString.
  • Create a GUI which the user can use to play around with Morse stuff.
  • Add binary morse (1 for one unit of sound, 0 for one unit of gap, i.e. SOS being 101010001110111011100010101). The binary morse is, by the way, the perfect input for MorsePlayers to play sound.
  • Add the prosigns like starting signal and end of work.
  • Add a configurable stereo effect which pans dit to one side and dahs to the other.
  • Add support for Wabun code as well as switching between Morse code and Wabun code using the DO and SN prosigns.
\$\endgroup\$
3
  • \$\begingroup\$ Wow, that's a great review. Seriously, total respect for the efforts made. \$\endgroup\$ Commented Dec 29, 2014 at 7:49
  • \$\begingroup\$ Awesome review. Would have to keep track of your reviews to learn! \$\endgroup\$
    – yadav_vi
    Commented Dec 31, 2014 at 6:09
  • \$\begingroup\$ I know this is several years late but....What an incredible review! \$\endgroup\$ Commented Mar 3, 2018 at 21:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.