diff --git a/src/main/java/net/datafaker/idnumbers/ItalianIdNumber.java b/src/main/java/net/datafaker/idnumbers/ItalianIdNumber.java new file mode 100644 index 000000000..ffee56188 --- /dev/null +++ b/src/main/java/net/datafaker/idnumbers/ItalianIdNumber.java @@ -0,0 +1,144 @@ +package net.datafaker.idnumbers; + +import static java.lang.Integer.parseInt; +import static java.util.Locale.ROOT; +import static java.util.Map.entry; +import static net.datafaker.idnumbers.Utils.birthday; +import static net.datafaker.idnumbers.Utils.gender; +import static net.datafaker.idnumbers.Utils.isConsonant; +import static net.datafaker.idnumbers.Utils.join; +import static net.datafaker.providers.base.PersonIdNumber.Gender.FEMALE; + +import java.time.LocalDate; +import java.util.Map; +import java.util.stream.IntStream; + +import net.datafaker.providers.base.BaseProviders; +import net.datafaker.providers.base.IdNumber.IdNumberRequest; +import net.datafaker.providers.base.PersonIdNumber; +import net.datafaker.providers.base.PersonIdNumber.Gender; +import net.datafaker.providers.base.Text.TextSymbolsBuilder; + +/** + * See Codice fiscale + */ +public class ItalianIdNumber implements IdNumberGenerator { + + private static final int CHECKSUM_DIVIDER = 26; + private static final String REGION_CODE_FIRST_LETTERS = "ABCDEFGHIJKLM"; + + private static final String MONTH_LETTER = "_ABCDEHLMPRST"; + + private static final Map ODD_ALPHANUMERIC_CHARACTERS = Map.ofEntries( + entry('0', 1), entry('C', 5), entry('O', 11), + entry('1', 0), entry('D', 7), entry('P', 3), + entry('2', 5), entry('E', 9), entry('Q', 6), + entry('3', 7), entry('F', 13), entry('R', 8), + entry('4', 9), entry('G', 15), entry('S', 12), + entry('5', 13), entry('H', 17), entry('T', 14), + entry('6', 15), entry('I', 19), entry('U', 16), + entry('7', 17), entry('J', 21), entry('V', 10), + entry('8', 19), entry('K', 2), entry('W', 22), + entry('9', 21), entry('L', 4), entry('X', 25), + entry('A', 1), entry('M', 18), entry('Y', 24), + entry('B', 0), entry('N', 20), entry('Z', 23) + ); + + private static final Map EVEN_ALPHANUMERIC_CHARACTERS = Map.ofEntries( + entry('0', 0), entry('C', 2), entry('O', 14), + entry('1', 1), entry('D', 3), entry('P', 15), + entry('2', 2), entry('E', 4), entry('Q', 16), + entry('3', 3), entry('F', 5), entry('R', 17), + entry('4', 4), entry('G', 6), entry('S', 18), + entry('5', 5), entry('H', 7), entry('T', 19), + entry('6', 6), entry('I', 8), entry('U', 20), + entry('7', 7), entry('J', 9), entry('V', 21), + entry('8', 8), entry('K', 10), entry('W', 22), + entry('9', 9), entry('L', 11), entry('X', 23), + entry('A', 0), entry('M', 12), entry('Y', 24), + entry('B', 1), entry('N', 13), entry('Z', 25) + ); + + private static final Map CONVERSION_RESULT_CHARACTERS = Map.ofEntries( + entry(0, 'A'), entry(10, 'K'), entry(20, 'U'), + entry(1, 'B'), entry(11, 'L'), entry(21, 'V'), + entry(2, 'C'), entry(12, 'M'), entry(22, 'W'), + entry(3, 'D'), entry(13, 'N'), entry(23, 'X'), + entry(4, 'E'), entry(14, 'O'), entry(24, 'Y'), + entry(5, 'F'), entry(15, 'P'), entry(25, 'Z'), + entry(6, 'G'), entry(16, 'Q'), + entry(7, 'H'), entry(17, 'R'), + entry(8, 'I'), entry(18, 'S'), + entry(9, 'J'), entry(19, 'T') + ); + + @Override + public String countryCode() { + return "IT"; + } + + @Override + public PersonIdNumber generateValid(BaseProviders faker, IdNumberRequest request) { + LocalDate birthday = birthday(faker, request); + Gender gender = gender(faker, request); + String firstName = faker.name().firstName().toUpperCase(ROOT); + String lastName = faker.name().lastName().toUpperCase(ROOT); + String basePart = encodeName(firstName) + encodeName(lastName) + encodeYear(birthday) + encodeMonth(birthday) + encodeDayAndGender(birthday, gender) + encodeRegion(faker); + return new PersonIdNumber(basePart + checksum(basePart), birthday, gender); + } + + /** + * The first three letters are taken from the consonants of the name. + * In case of insufficient consonants the vowels are also taken, but they always come after the consonants. + * In case of too short names, "X" letter is appended. + * + * @param name input string + * @return first n constants + */ + String encodeName(String name) { + String latinLetters = name.replaceAll("[^A-Z]", ""); + IntStream consonants = latinLetters.chars().filter(c -> isConsonant(c)); + IntStream vowels = latinLetters.chars().filter(c -> !isConsonant(c)); + IntStream placeholder = "XXX".chars(); + return join(consonants, vowels, placeholder, 3); + } + + private String encodeYear(LocalDate birthday) { + return String.valueOf(birthday.getYear()).substring(2); + } + + private char encodeMonth(LocalDate birthday) { + return MONTH_LETTER.charAt(birthday.getMonthValue()); + } + + private String encodeDayAndGender(LocalDate birthday, Gender gender) { + String birthdayValue = "%02d".formatted(birthday.getDayOfMonth()); + return "" + (gender.equals(FEMALE) ? parseInt(birthdayValue) + 40 : birthdayValue); + } + + private String encodeRegion(BaseProviders faker) { + String regionLetter = faker.text().text(TextSymbolsBuilder.builder().len(1).with(REGION_CODE_FIRST_LETTERS).build()); + return "%s%03d".formatted(regionLetter, faker.number().numberBetween(1, 1000)); + } + + @Override + public String generateInvalid(BaseProviders faker) { + String valid = generateValid(faker); + return valid.substring(0, valid.length() - 1) + '9'; + } + + char checksum(String basePart) { + char[] chars = basePart.toCharArray(); + + int sum = 0; + for (int i = 0; i < chars.length; i += 2) { + sum += ODD_ALPHANUMERIC_CHARACTERS.get(chars[i]); + } + for (int i = 1; i < chars.length; i += 2) { + sum += EVEN_ALPHANUMERIC_CHARACTERS.get(chars[i]); + } + + return CONVERSION_RESULT_CHARACTERS.get(sum % CHECKSUM_DIVIDER); + } + +} diff --git a/src/main/java/net/datafaker/idnumbers/Utils.java b/src/main/java/net/datafaker/idnumbers/Utils.java index 156e84a2d..54120101c 100644 --- a/src/main/java/net/datafaker/idnumbers/Utils.java +++ b/src/main/java/net/datafaker/idnumbers/Utils.java @@ -1,16 +1,21 @@ package net.datafaker.idnumbers; +import static java.util.stream.Collectors.joining; +import static java.util.stream.IntStream.concat; +import static net.datafaker.providers.base.PersonIdNumber.Gender.FEMALE; +import static net.datafaker.providers.base.PersonIdNumber.Gender.MALE; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.stream.IntStream; + import net.datafaker.providers.base.BaseProviders; import net.datafaker.providers.base.IdNumber; import net.datafaker.providers.base.IdNumber.IdNumberRequest; import net.datafaker.providers.base.PersonIdNumber.Gender; -import java.time.LocalDate; - -import static net.datafaker.providers.base.PersonIdNumber.Gender.FEMALE; -import static net.datafaker.providers.base.PersonIdNumber.Gender.MALE; - public class Utils { + private static final char[] VOWELS = {'A', 'E', 'I', 'O', 'U', 'Y'}; static LocalDate birthday(BaseProviders faker, IdNumberRequest request) { return faker.timeAndDate().birthday(request.minAge(), request.maxAge()); @@ -44,4 +49,21 @@ static int multiply(String text, int[] weights) { } return checksum; } + + static String join(IntStream chars1, IntStream chars2, IntStream chars3, int maxLength) { + return concat(chars1, concat(chars2, chars3)) + .limit(maxLength) + .mapToObj(c -> (char) c) + .map(Object::toString) + .collect(joining()); + } + + static boolean isConsonant(int c) { + return isConsonant((char) c); + } + + static boolean isConsonant(char c) { + return Arrays.binarySearch(VOWELS, c) < 0; + } + } diff --git a/src/main/resources/META-INF/services/net.datafaker.idnumbers.IdNumberGenerator b/src/main/resources/META-INF/services/net.datafaker.idnumbers.IdNumberGenerator index 4a78d7fb0..4f5be0476 100644 --- a/src/main/resources/META-INF/services/net.datafaker.idnumbers.IdNumberGenerator +++ b/src/main/resources/META-INF/services/net.datafaker.idnumbers.IdNumberGenerator @@ -5,6 +5,7 @@ net.datafaker.idnumbers.ChineseIdNumber net.datafaker.idnumbers.EstonianIdNumber net.datafaker.idnumbers.FrenchIdNumber net.datafaker.idnumbers.GeorgianIdNumber +net.datafaker.idnumbers.ItalianIdNumber net.datafaker.idnumbers.MacedonianIdNumber net.datafaker.idnumbers.MexicanIdNumber net.datafaker.idnumbers.MoldovanIdNumber diff --git a/src/test/java/net/datafaker/helpers/IdNumberPatterns.java b/src/test/java/net/datafaker/helpers/IdNumberPatterns.java index 7cfce2c60..874d21d7c 100644 --- a/src/test/java/net/datafaker/helpers/IdNumberPatterns.java +++ b/src/test/java/net/datafaker/helpers/IdNumberPatterns.java @@ -8,5 +8,6 @@ public class IdNumberPatterns { public static final Pattern UKRAINIAN = Pattern.compile("\\d{8}-\\d{5}"); public static final Pattern SOUTH_AFRICAN = Pattern.compile("[0-9]{10}([01])8[0-9]"); public static final Pattern FRENCH = Pattern.compile("[12]\\d{2}(1[0-2]|0[1-9])\\d[0-9a-zA-Z]\\d{8}"); + public static final Pattern ITALIAN = Pattern.compile("[A-Z]{6}\\d{2}[ABCDEHLMPRST]\\d{2}[\\dA-Z]{5}"); } diff --git a/src/test/java/net/datafaker/idnumbers/ItalianIdNumberTest.java b/src/test/java/net/datafaker/idnumbers/ItalianIdNumberTest.java new file mode 100644 index 000000000..aeb665c68 --- /dev/null +++ b/src/test/java/net/datafaker/idnumbers/ItalianIdNumberTest.java @@ -0,0 +1,69 @@ +package net.datafaker.idnumbers; + +import static net.datafaker.providers.base.IdNumber.GenderRequest.ANY; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Locale; + +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import net.datafaker.Faker; +import net.datafaker.providers.base.IdNumber.IdNumberRequest; +import net.datafaker.providers.base.PersonIdNumber; + +class ItalianIdNumberTest { + + private static final Locale LOCALE = new Locale("it", "IT"); + private final ItalianIdNumber impl = new ItalianIdNumber(); + + @Test + void consonants() { + assertThat(impl.encodeName("OOOOOP")).isEqualTo("POO"); + assertThat(impl.encodeName("HELLO")).isEqualTo("HLL"); + assertThat(impl.encodeName("HELLO")).isEqualTo("HLL"); + assertThat(impl.encodeName("ANNA")).isEqualTo("NNA"); + assertThat(impl.encodeName("AIROLI")).isEqualTo("RLA"); + assertThat(impl.encodeName("AIROUE")).isEqualTo("RAI"); + assertThat(impl.encodeName("FO")).isEqualTo("FOX"); + assertThat(impl.encodeName("OF")).isEqualTo("FOX"); + assertThat(impl.encodeName("F")).isEqualTo("FXX"); + assertThat(impl.encodeName("D'AMICO")).isEqualTo("DMC"); + assertThat(impl.encodeName("D`AMICO")).isEqualTo("DMC"); + assertThat(impl.encodeName("D_AMICO")).isEqualTo("DMC"); + assertThat(impl.encodeName("DE ROSA")).isEqualTo("DRS"); + assertThat(impl.encodeName("ÜÜLAR ÄHO")).isEqualTo("LRH"); + assertThat(impl.encodeName("")).isEqualTo("XXX"); + } + + /** + * Example from web + */ + @Test + void checksum() { + assertThat(impl.checksum("BBBTTT20H12X122")).isEqualTo('H'); + assertThat(impl.checksum("AAAAAAAAAAAAAAA")) + .as("'A' is 1 on odd positions, 'A' is 0 on even positions. So, 1*8 + 7*0 = 8 -> 'I'") + .isEqualTo('I'); + assertThat(impl.checksum("101010101010101")) + .as("'1' is 0 on odd positions, '0' is 0 on even positions. So, 0*8 + 0*0 = 0 -> 'A'") + .isEqualTo('A'); + assertThat(impl.checksum("H01010101010101")) + .as("'H' is 17 on odd positions, '0' is 0 on even positions. So, 17 + 0*7 + 0*0 = 17 -> 'R'") + .isEqualTo('R'); + } + + @RepeatedTest(1000) + void checksumShouldMatchForValidCodes() { + PersonIdNumber personIdNumber = impl.generateValid(new Faker(LOCALE), new IdNumberRequest(1, 200, ANY)); + String idNumber = personIdNumber.idNumber(); + assertThat(impl.checksum(idNumber.substring(0, 15))).isEqualTo(idNumber.charAt(15)); + } + + @RepeatedTest(10) + void checksumShouldNotMatchForInvalidCodes() { + String idNumber = impl.generateInvalid(new Faker(LOCALE)); + assertThat(impl.checksum(idNumber.substring(0, 15))).isNotEqualTo(idNumber.charAt(15)); + } + +} diff --git a/src/test/java/net/datafaker/providers/base/IdNumberTest.java b/src/test/java/net/datafaker/providers/base/IdNumberTest.java index 627ebd90d..eb694071e 100644 --- a/src/test/java/net/datafaker/providers/base/IdNumberTest.java +++ b/src/test/java/net/datafaker/providers/base/IdNumberTest.java @@ -28,6 +28,7 @@ class IdNumberTest extends BaseFakerTest { private static final Faker ROMANIAN = new Faker(new Locale("ro", "RO")); private static final Faker UKRAINIAN = new Faker(new Locale("uk", "UA")); private static final Faker FRENCH = new Faker(new Locale("fr", "FR")); + private static final Faker ITALIAN = new Faker(new Locale("it", "IT")); @Test void testValid() { @@ -198,6 +199,17 @@ void frenchIdNumber() { assertThat(actual).matches(IdNumberPatterns.FRENCH); } + @Test + void italianIdNumberSample() { + assertThatPin("PTRJHN89T04Z222B").matches(IdNumberPatterns.ITALIAN); + assertThatPin("BBBTTT20H12X122H").matches(IdNumberPatterns.ITALIAN); + } + + @RepeatedTest(100) + void italianIdNumber() { + assertThatPin(ITALIAN.idNumber().valid()).matches(IdNumberPatterns.ITALIAN); + } + private static AbstractStringAssert assertThatPin(String pin) { return assertThat(pin) .as(() -> "PIN: %s".formatted(pin));