4
\$\begingroup\$

I have the requirement to validate product codes which include EAN-8, EAN-13 and UPC-A encoded as EAN-13. I have implemented a use-case to perform this check by comparing the actual check digit with the computed one. I followed this explanation for EAN-13 and that one for EAN-8.

The reason I'm asking for a review is because I haven't worked with EANs before and also noticed that my previous versions that had logical flaws still passed the tests with some codes, so the error wasn't obvious.

Example: in an earlier version, I did not drop the last digit (which is the check digit), so it was mistakenly used in the calculation. The result was that the tests passed for the UPC-A (wrapped as EAN-13) code that can be seen in the test, but failed for a "real" EAN-13 and EAN-8, adding to my confusion.

Another example of a bad idea was giving ChatGPT a shot on solving the problem because the thing often contradicted itself and produced code that worked with some codes and didn't work with others - so be careful with that one because you might go from "I somewhat know what I'm doing and want to save time" to "I have no idea, throw everything away and start from scratch".

Note: the code can be seen in action on the Kotlin Playground

Description: the code format is valid if it consists of exactly 8 or 13 digits.

  • take the last digit - this is the reference check digit
  • take the input digits
    • remove the check digit
    • convert the rest to a List of Ints
    • compute the check digit

In the sumForEAN8() and sumForEAN13() extensions, you may notice that the calculation is exactly opposite of the specification, e.g. for EAN-13, I multiply odd digits by 3, whereby the spec says one should do that for even digits. The reason is that the spec divides all digits in even and odd based on the complete number including the check digit. Since we remove the check digit before proceeding with the calculation, the "odd" and "even" numbering basically shifts by one. now I realize that this explanation is wrong (because digits are iterated left-to-right, so dropping the last one cannot affect even/odd): the swap is still needed, but the real reason is that we have 0-based indices in programming, while the spec uses a 1-based index)

internal class ValidateEANUseCase() {

    private val eanFormatRegex = Regex("^\\d{8}|\\d{13}$")

    fun run(ean: String): Boolean = when {
        ean.hasInvalidFormat() -> false
        else -> {
            val actualCheckDigit = ean.last()
                .digitToInt()

            val computedCheckDigit = ean
                .dropLast(1)
                .map { char -> char.digitToInt() }
                .computeEANCheckDigit()

            actualCheckDigit == computedCheckDigit
        }
    }

    private fun String.hasInvalidFormat(): Boolean = !this.matches(eanFormatRegex)

    private fun List<Int>.computeEANCheckDigit(): Int {
        val sum = if (size == 7) sumForEAN8() else sumForEAN13()
        return (10 - (sum % 10)) % 10
    }

    private fun List<Int>.sumForEAN8(): Int = withIndex()
        .sumOf { (index, digit) ->
            if (index % 2 == 0) digit * 3 else digit
        }

    private fun List<Int>.sumForEAN13(): Int = withIndex()
        .sumOf { (index, digit) ->
            if (index % 2 == 0) digit else digit * 3
        }

}

Below are the unit tests (all passing; tested refers to the instance of the class being tested). Any further inputs to consider?

@Test
fun `GIVEN a valid EAN-8 number THEN the validation result is TRUE`() {
    val result = tested.run(VALID_EAN_8)
    assertThat(result).isTrue
}

@Test
fun `GIVEN a valid EAN-13 number THEN the validation result is TRUE`() {
    val result = tested.run(VALID_EAN_13)
    assertThat(result).isTrue
}

@Test
fun `GIVEN a valid UPC-A number encoded as EAN-13 THEN the validation result is TRUE`() {
    val result = tested.run(VALID_UPC_A_ENCODED_AS_EAN_13)
    assertThat(result).isTrue
}

@Test
fun `GIVEN ANY invalid input THEN the validation result is FALSE`() {
    val results = mutableListOf<Boolean>()
    invalidInputs.forEach {
        results.add(tested.run(it))
    }
    assertThat(results).allMatch { it == false }
}

private companion object {
    const val VALID_EAN_13 = "5060762541604"
    const val VALID_EAN_8 = "42307167"
    const val VALID_UPC_A_ENCODED_AS_EAN_13 = "0670367004760"
    val invalidInputs = listOf(
        "", " ", "\n\t", "\\§$%&()=?ß`+´ü-!äö'#*´;¿\"@<>", "123", "12345678",
        "5060962541604", "00123456789012", "hello EAN", "привет EAN", "你好 EAN"
    )
}

Thank you.

\$\endgroup\$
4
  • \$\begingroup\$ "previous versions that had logical flaws still passed the tests with some codes" Excellent. This is a site about the practice of software engineering as much as it is about the theory. War stories are very informative to practitioners. You have already been brave enough to show us your code. Would you show us your mistake? Please? The fact that a line of code fooled someone smart is interesting, and can show others what to be on the lookout for. \$\endgroup\$
    – J_H
    Mar 7 at 16:42
  • \$\begingroup\$ I haven't spent much time debugging flaky code and decided to redo everything (which is the solution in that question), but I've added a couple of words about the experience that I made \$\endgroup\$
    – Droidman
    Mar 7 at 17:26
  • \$\begingroup\$ zomg, so your unlucky test case happened to hit the 10% accidental "it works!" digit?!? How frightening. Thanks for sharing. Motivation for using multiple examples, I suppose. \$\endgroup\$
    – J_H
    Mar 7 at 17:29
  • \$\begingroup\$ Incorporating advice from an answer into the question violates the question-and-answer nature of this site. You could post improved code as a new question, as an answer, or as a link to an external site - as described in I improved my code based on the reviews. What next?. I have rolled back the edit, so the answers make sense again. \$\endgroup\$ Mar 14 at 16:29

2 Answers 2

2
\$\begingroup\$

You cite e.g. open.edu. I would feel better about a citation to the (500 page!) EAN-{8,13} spec: § 7.9.1 and 7.9.5 (They are named GITIN-{8,13}, there.)


internal class ValidateEANUseCase() {

I really like the initial portion of that identifier. The second half seems awkward, but maybe y'all have some convention this is conforming to, where a restricted vocabulary that includes UseCase is always used as a suffix.


Regex("^\\d{8}|\\d{13}$")

I don't understand that expression. Or rather, I believe that instead of saying ^X|Y$ you intended ^(X|Y)$

BTW, the instinct to anchor the expression is a good one, I applaud it. If you're going to use just a single anchor, the ^ caret offers the bigger win, as it will typically do more to limit expensive backtracking.

The run function is laudably clear, thank you. Maybe breaking out a hasInvalidFormat helper was overkill? Maybe choose to define it in the positive, hasValidFormat, and let caller negate if needed? Humans tend to reason about the positive case better than they do about not not negatives.


In computeEANCheckDigit this expression is interesting:

        return (10 - (sum % 10)) % 10

It's not explicit in the code, but I assume we could assert sum >= 0. I confess I do not understand this expression. We have an integer 0 .. 9 inclusive, which we subtract from ten. And then we modulo it again? Why? I have to believe that the difference is already within range. Consider the 10 - 0 case. Sorry, I was wrong, I withdraw the remark. It's not like the spec asks for nine minus something.

I would very much appreciate seeing a unit test that draws attention to this case. Whenever we make a mistake, it is useful to memorialize it in a (now Green!) test.


I'm sure that sumForEAN8 and sumForEAN13 are correct; please ship them as-is. But I will just say that I'm not super happy with them, and I guess that feeling started with your wonderfully forthright description up top about "exactly opposite".

Here's what I was looking for: a direct linkage to the spec. In the language of the spec, we're talking about N1 .. N7 for EAN8, and N1 .. N12 for EAN13. And then the spec offers integer multipliers, either 1 or 3, in the "Multiply value of each position by" section. Like I said, I believe the correct quantity winds up being calculated. But I wouldn't mind seeing more direct traceability to what the standards body actually specifies.

Also, given a pair of multiplier vectors, I believe we could implement these with just a single function. Look into the future for a moment. Maybe a use case will come down the pike that requires EAN-14 support? Common code that exploits commonality in the spec seems desirable.


    val result = tested.run(VALID_EAN_8)
    assertThat(result).isTrue

This impresses me as being about twice as verbose as needed, but fine, whatever, I'm sure there's some cultural Kotlin thing going on here that I'm unfamiliar with. Always follow the cultural norms. I would have just asserted the expression, without defining a temp var.

The bigger issue is that, while I really like the helpfully descriptive VALID_EAN_8 identifier, I'm sad that the value is so far away in a companion object. I would much rather see a line defining it right here in the test function, given that the value isn't shared with other functions. I like the immediacy of it, no scrolling back and forth to see what's happening. But again, if we're conforming to some cultural norm here, good, so be it. Kind of like the practice of putting SPACE characters in function names, I guess.

There's an opportunity to include more than a single example of each kind of valid product code, here.


You wrote

        "5060962541604", "00123456789012", ...

I ran out of fingers to count on. I would much rather see those constants expressed as

        "5060962541604",
        "00123456789012", ...

to visually emphasize their relative lengths.


Ok, we have been diving down into the nitty gritty. Let's up-level for a moment, and critique the whole unit testing approach. There's a testing aspect that I am not yet satisfied with.

We could do amplification of our test inputs.

That is, for each known-valid product code, we also know how to trivially create nine invalid codes. So let's do it, it's cheap! And verify that each one fails.


Overall?

A regex should be fixed, and perhaps a modulo cleaned up.

This code has been carefully implemented, and achieves its design objectives.

I would gladly delegate or accept maintenance tasks on this codebase.

\$\endgroup\$
4
  • \$\begingroup\$ Thanks for the detailed answer. I don't recall where I read this, but it was something along the lines "check what we need to add to round it up to the nearest 10", thus the additional mod 10. Looks like a valid case: for the sample UPC-A encoded as EAN-13 "0670367004760", the sum is 90. And 10 - (90 mod 10) is 10, so the test fails. Here the wording is pretty similar: upczilla.com/online-upc-validation-tool/… \$\endgroup\$
    – Droidman
    Mar 7 at 19:08
  • \$\begingroup\$ Yep, we do have a convention on DoSomethingUseCase. Those are part of our domain layer, do not hold any state, have a single run() method and have exactly 1 responsibility that must be easy to infer from the name. Having spaces is test method names is also a common thing in Kotlin since it allows for more readable names, I usually go with GIVEN/WHEN/IF .... THEN ..... Constants in the companion is common as well (const val is only allowed in static context), the naming must be good enough to eliminate the need for immediate scrolling to understand the code on a higher level. \$\endgroup\$
    – Droidman
    Mar 7 at 19:16
  • \$\begingroup\$ Uggh, OBOB, I lose! Thank you for your gracious explanation. I say many things, some of them wrong. Updated the answer text. \$\endgroup\$
    – J_H
    Mar 7 at 20:20
  • 1
    \$\begingroup\$ np, looks like I was also wrong in my explanation on why I did "invert" the digit weights :D please see the edited version. The funny part is that this inversion is still needed. I think that from the programming perspective, it is still the best approach because it requires just one iteration to compute the sum. I agree with Uncle Bob that comments should be avoided where possible, but this looks like an appropriate case for them, so I added this explanation to the code in order for other devs to avoid some WTFs if they view the code and check it against the EAN spec. \$\endgroup\$
    – Droidman
    Mar 8 at 22:50
1
\$\begingroup\$

For those interested in the final solution I came up with: I have tweaked the implementation a bit, the logic remained the same though. The code is provided without any liability, warranty or license, feel free to use it for any purpose.

Reasoning (in addition to implementing the suggestions from J_H's answer - fixing the regex, getting rid of 2 separate sum calculation functions and referring to the official spec): I have analyzed how the code fits into the broader codebase and came to the conclusion that we'd need to inject and invoke the validator from my initial solution at multiple places. We could do the validation once and then just pass the value around "knowing" that it's valid, but this approach is far from clean and would likely lead to errors down the road (e.g. someone forgets to invoke the validator at some place).

I have asked myself "what do we really need?" and the answer was "passing around an 'EAN' object that is valid by definition". So I have implemented exactly that - this solution makes it impossible to have an invalid EAN because it would crash immediately, making the debugging straight-forward.

/**
 * Represents an international article number
 *
 * @param input the numeric string representing this EAN
 *
 * @throws IllegalArgumentException if the given input value is not a valid EAN
 *
 */
internal class EAN(input: String) {

    private enum class Format(
        val pattern: Regex,
        val digitWeights: DigitWeights
    ) {
        // Note: the weights used in this implementation are the exact opposite of the official spec -
        // this is correct. The reason is that the spec enumerates all digits as even / odd starting
        // at index 1, see https://ref.gs1.org/standards/genspecs, section 7.9 "Standard check digit
        // calculations for GS1 data structures", Fig. 7.9.1-1 "check digit algorithm").
        // Since we have 0-based indices in programming, the even / odd enumeration shifts by one
        // when we iteratively calculate the sum - so we "invert" the weights to account for that.

        EAN_8(Regex("^(\\d{8})$"), DigitWeights(even = 3, odd = 1)),
        EAN_13(Regex("^(\\d{13})$"), DigitWeights(even = 1, odd = 3))
    }

    private data class DigitWeights(
        val even: Int,
        val odd: Int
    )

    private val format: Format = getFormatOrThrow(input)
    private val digits: List<Int> = input.map { char -> char.digitToInt() }
    private val checkDigit = digits.last()

    val value: String = digits.joinToString("")

    init {
        validateCheckDigit()
    }

    private fun getFormatOrThrow(input: String): Format = Format.values()
        .firstOrNull { it.pattern.matches(input) }
        ?: throw IllegalArgumentException("\"$input\" does not match any valid EAN format")

    private fun validateCheckDigit() {
        if (checkDigit != computeCheckDigit())
            throw IllegalArgumentException("\"$value\" is not an EAN (check digit validation failed)")
    }

    private fun computeCheckDigit(): Int = digits.computeValueSum()
        .let { sum ->
            (10 - (sum % 10)) % 10
        }

    private fun List<Int>.computeValueSum(): Int = dropLast(1)
        .withIndex()
        .sumOf { (index, digit) ->
            if (index % 2 == 0)
                digit * format.digitWeights.even
            else
                digit * format.digitWeights.odd
        }

}
\$\endgroup\$
0

Your Answer

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

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