Compare commits
3
Commits
f553e79c75
...
7f8e0d2d2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8e0d2d2e | ||
|
|
c25ab9c99f | ||
|
|
c1a1319088 |
@@ -10,19 +10,41 @@ import java.util.Locale;
|
||||
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
|
||||
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
|
||||
* для них сравнение идёт по основе без неё.
|
||||
*
|
||||
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
|
||||
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
|
||||
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
|
||||
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
|
||||
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
|
||||
* именительный там не встречается вообще.
|
||||
*/
|
||||
final class Declension {
|
||||
|
||||
/**
|
||||
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
|
||||
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
|
||||
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
|
||||
*/
|
||||
private static final String[] ADJECTIVE_ENDINGS = {
|
||||
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
|
||||
};
|
||||
|
||||
private Declension() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбрасывает у основы конечную гласную, которая меняется по падежам.
|
||||
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
|
||||
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
|
||||
* Слова короче четырёх букв не трогает — короткая основа и так шире
|
||||
* большинства падежных форм.
|
||||
*/
|
||||
static String withoutInflectedEnding(String word) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
for (String ending : ADJECTIVE_ENDINGS) {
|
||||
if (lower.length() > ending.length() && lower.endsWith(ending)) {
|
||||
return lower.substring(0, lower.length() - ending.length() + 2);
|
||||
}
|
||||
}
|
||||
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
||||
return lower.substring(0, lower.length() - 1);
|
||||
}
|
||||
|
||||
@@ -210,13 +210,16 @@ public class RuleRegistry {
|
||||
.anchoredBy("срок действия", "действительн", "exp"),
|
||||
|
||||
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
|
||||
// ещё и правилом ОГРН.
|
||||
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
|
||||
// числа рядом со словом — раньше якоря было достаточно самого по себе.
|
||||
Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::ogrnip)
|
||||
.anchoredBy("огрнип"),
|
||||
|
||||
Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::ogrn)
|
||||
.anchoredBy("огрн"),
|
||||
|
||||
Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
|
||||
@@ -378,11 +381,17 @@ public class RuleRegistry {
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY),
|
||||
|
||||
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
||||
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
|
||||
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
|
||||
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
|
||||
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
|
||||
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
|
||||
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
||||
.groups(1)
|
||||
.validatedBy(ToponymDictionary::isKnownCity)
|
||||
.validatedBy(ToponymDictionary::isKnownSettlement)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("г.", "гор", "город"),
|
||||
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
|
||||
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
|
||||
|
||||
Rule.of(ADDRESS_STREET,
|
||||
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
|
||||
|
||||
@@ -11,9 +11,16 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Словарь городов России — проверка того, что значение, пойманное правилом
|
||||
* {@code ADDRESS_CITY}, действительно похоже на существующий город, а не на
|
||||
* произвольное слово с заглавной буквы после «г.».
|
||||
* Словарь населённых пунктов России — проверка того, что значение, пойманное
|
||||
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
|
||||
* село, посёлок или другой населённый пункт, а не на произвольное слово с
|
||||
* заглавной буквы после якоря.
|
||||
*
|
||||
* <p>Не только официальные города (~1100 по классификатору): перепись
|
||||
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
|
||||
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
|
||||
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
|
||||
* а не этот словарь — он только подтверждает, что название реальное.
|
||||
*
|
||||
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
||||
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
|
||||
@@ -21,7 +28,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
public final class ToponymDictionary {
|
||||
|
||||
private static final Set<String> CITY_STEMS = load("/names/cities.txt").stream()
|
||||
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
|
||||
.map(Declension::withoutInflectedEnding)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
@@ -29,22 +36,23 @@ public final class ToponymDictionary {
|
||||
}
|
||||
|
||||
/**
|
||||
* Похоже ли значение на название города из словаря в любом падеже.
|
||||
* Похоже ли значение на название населённого пункта из словаря в любом
|
||||
* падеже.
|
||||
*
|
||||
* <p>Города на согласную склоняются добавлением окончания («Тамбов» →
|
||||
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
|
||||
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
|
||||
* Города на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||
* них сравнение идёт по основе без неё — так же, как с личными именами
|
||||
* в {@link NameDictionary}.
|
||||
*
|
||||
* <p>Проверяются префиксы значения по множеству, а не каждая из 1111+
|
||||
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
|
||||
* основ по значению: перебор списка на каждое совпадение правила был бы
|
||||
* в тысячу раз дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
||||
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
||||
*/
|
||||
public static boolean isKnownCity(String value) {
|
||||
public static boolean isKnownSettlement(String value) {
|
||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||
for (int length = lower.length(); length > 0; length--) {
|
||||
if (CITY_STEMS.contains(lower.substring(0, length))) {
|
||||
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,32 @@ public final class Validators {
|
||||
return control == d[9] * 10 + d[10];
|
||||
}
|
||||
|
||||
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
|
||||
public static boolean ogrn(String value) {
|
||||
int[] d = digits(value);
|
||||
return d.length == 13 && d[12] == modReduce(d, 12, 11);
|
||||
}
|
||||
|
||||
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
|
||||
public static boolean ogrnip(String value) {
|
||||
int[] d = digits(value);
|
||||
return d.length == 15 && d[14] == modReduce(d, 14, 13);
|
||||
}
|
||||
|
||||
/**
|
||||
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
|
||||
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
|
||||
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
|
||||
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
|
||||
*/
|
||||
private static int modReduce(int[] d, int count, int divisor) {
|
||||
long remainder = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
remainder = (remainder * 10 + d[i]) % divisor;
|
||||
}
|
||||
return (int) (remainder % 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
|
||||
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -59,3 +59,34 @@
|
||||
Дерипаска
|
||||
Абрамович
|
||||
Усманов
|
||||
|
||||
# Фамилии, в честь которых чаще всего называют улицы в России (Росреестр,
|
||||
# Яндекс.Исследования). Упоминание «улица Ленина» само по себе не задевает
|
||||
# распознавание ФИО — для него нужны два слова, — но составные названия
|
||||
# («Феликса Дзержинского», «Александра Матросова») попадают под то же
|
||||
# правило, что и «Богдана Хмельницкого»: в честь человека, а не клиент.
|
||||
Ленин
|
||||
Киров
|
||||
Свердлов
|
||||
Дзержинский
|
||||
Фрунзе
|
||||
Куйбышев
|
||||
Ворошилов
|
||||
Будённый
|
||||
Буденный
|
||||
Чапаев
|
||||
Жуков
|
||||
Чкалов
|
||||
Терешкова
|
||||
Мичурин
|
||||
Шевченко
|
||||
Орджоникидзе
|
||||
Калинин
|
||||
Энгельс
|
||||
Маркс
|
||||
Островский
|
||||
Матросов
|
||||
Волошина
|
||||
Сусанин
|
||||
Димитров
|
||||
Донской
|
||||
|
||||
@@ -56,6 +56,26 @@ class BankTypesTest {
|
||||
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
|
||||
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
|
||||
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
|
||||
*/
|
||||
@Test
|
||||
void doesNotMaskOgrnWithBrokenChecksum() {
|
||||
String text = "ОГРН 1027700132190 организации";
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"число с неверной контрольной суммой не является настоящим ОГРН");
|
||||
}
|
||||
|
||||
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
|
||||
@Test
|
||||
void doesNotMaskOgrnipWithBrokenChecksum() {
|
||||
String text = "ОГРНИП 304500116000150 предпринимателя";
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"число с неверной контрольной суммой не является настоящим ОГРНИП");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksKpp() {
|
||||
assertHidden("КПП 770101001 указан в реквизитах", "770101001");
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/**
|
||||
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями
|
||||
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь
|
||||
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево»
|
||||
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что
|
||||
* искать было негде.
|
||||
*/
|
||||
class SettlementTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksVillage() {
|
||||
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksWorkersSettlement() {
|
||||
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksUrbanTypeSettlement() {
|
||||
assertHidden("Доставка курьером в пгт. Энем", "Энем");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksHamlet() {
|
||||
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksFarmstead() {
|
||||
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCossackStanitsa() {
|
||||
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAul() {
|
||||
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSloboda() {
|
||||
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAal() {
|
||||
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
|
||||
}
|
||||
|
||||
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
|
||||
@Test
|
||||
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
|
||||
String text = "Клиент проживает в с. Мнимогорск";
|
||||
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
|
||||
}
|
||||
|
||||
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
|
||||
@Test
|
||||
void doesNotMaskOrganisationAddressInVillage() {
|
||||
String text = "Ближайшее отделение банка находится в с. Кукуево";
|
||||
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorsAreCaseInsensitive() {
|
||||
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
|
||||
}
|
||||
|
||||
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
|
||||
@Test
|
||||
void masksSettlementStreetAndHouseTogether() {
|
||||
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
|
||||
assertFalse(masked.contains("Аксеновка"), masked);
|
||||
assertFalse(masked.contains("Садовая"), masked);
|
||||
assertFalse(masked.contains("д. 7"), masked);
|
||||
}
|
||||
|
||||
/**
|
||||
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
|
||||
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
|
||||
* букву сразу после якоря, а не цифру.
|
||||
*/
|
||||
@Test
|
||||
void pageReferenceIsNotMistakenForSettlement() {
|
||||
String text = "См. с. 25 договора";
|
||||
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/**
|
||||
* Составные названия улиц в честь людей — тот же класс ложных срабатываний,
|
||||
* что и «Богдана Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО
|
||||
* без ролевого слова ловит «имя + фамилия по словообразованию», а улица в
|
||||
* честь исторической фигуры выглядит точно так же. Одиночная фамилия («улица
|
||||
* Ленина») под это правило не подпадает вообще — ему нужны два слова, поэтому
|
||||
* все примеры здесь двухсловные, реальные названия улиц.
|
||||
*/
|
||||
class StreetDenylistTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private void assertUnmasked(String text) {
|
||||
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskNikolaiOstrovskyStreet() {
|
||||
assertUnmasked("Живу на улице Николая Островского уже десять лет");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskAlexanderMatrosovStreet() {
|
||||
assertUnmasked("Магазин находится на улице Александра Матросова");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskValeryChkalovStreet() {
|
||||
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskVeraVoloshinaStreet() {
|
||||
assertUnmasked("Новый дом построили на улице Веры Волошиной");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskIvanSusaninStreet() {
|
||||
assertUnmasked("Школа расположена на улице Ивана Сусанина");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskSergeyKirovStreet() {
|
||||
assertUnmasked("Парковка есть на улице Сергея Кирова");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskGeorgiDimitrovStreet() {
|
||||
assertUnmasked("Остановка на улице Георгия Димитрова");
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
|
||||
* применяется — если в тексте всё-таки есть настоящие персональные данные,
|
||||
* совпадение с историческим именем их не прикрывает.
|
||||
*/
|
||||
@Test
|
||||
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
|
||||
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
|
||||
assertFalse(masked.contains("Николая Островского"), masked);
|
||||
}
|
||||
|
||||
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
|
||||
@Test
|
||||
void stillMasksRealClientNameWithSimilarPattern() {
|
||||
String masked = mask("Живёт на улице, зовут Николая Смирнова");
|
||||
assertFalse(masked.contains("Николая Смирнова"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskDmitryDonskoyStreet() {
|
||||
assertUnmasked("Дом стоит на улице Дмитрия Донского");
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,28 @@ class NameDictionaryTest {
|
||||
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
|
||||
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
|
||||
* правила нужны два слова, — но денилист должен покрывать и составные
|
||||
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
|
||||
* «Буденный»).
|
||||
*/
|
||||
@Test
|
||||
void bundledListCoversCommemorativeStreetNames() {
|
||||
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
|
||||
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
|
||||
// основа обрезается сразу до «ск» — см. Declension.
|
||||
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
|
||||
assertTrue(NameDictionary.isWellKnown("улица Донского"));
|
||||
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
|
||||
// известная граница приёма, родительный падеж («Будённого») им не ловится.
|
||||
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
|
||||
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
|
||||
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
|
||||
assertTrue(NameDictionary.isWellKnown("улица Островского"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("well-known.txt");
|
||||
|
||||
@@ -5,38 +5,58 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Словарь городов — проверка вместо любого слова с заглавной буквы после «г.». */
|
||||
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
|
||||
class ToponymDictionaryTest {
|
||||
|
||||
@Test
|
||||
void recognisesNominativeCase() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("Москва"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("Казань"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("Санкт-Петербург"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognisesInflectedForms() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("Москве"), "дательный падеж города на гласную");
|
||||
assertTrue(ToponymDictionary.isKnownCity("Тамбове"), "предложный падеж города на согласную");
|
||||
assertTrue(ToponymDictionary.isKnownCity("Казани"), "родительный падеж");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognisesCisCapitals() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("Минск"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("Алматы"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMadeUpWord() {
|
||||
assertFalse(ToponymDictionary.isKnownCity("Ерунда"));
|
||||
assertFalse(ToponymDictionary.isKnownCity("Бла-бла"));
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isCaseInsensitive() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("МОСКВА"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("москва"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
|
||||
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
|
||||
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
|
||||
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
|
||||
* населённого пункта.
|
||||
*/
|
||||
@Test
|
||||
void recognisesSettlementsFromCensusNotJustOfficialCities() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
|
||||
class ValidatorsTest {
|
||||
|
||||
@Test
|
||||
void validOgrnPassesChecksum() {
|
||||
assertTrue(Validators.ogrn("1027700123450"));
|
||||
assertTrue(Validators.ogrn("1025000678900"));
|
||||
assertTrue(Validators.ogrn("1045002233440"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidOgrnChecksumFails() {
|
||||
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ogrnWrongLengthFails() {
|
||||
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
|
||||
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
|
||||
}
|
||||
|
||||
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||
@Test
|
||||
void ogrnRemainderTenMapsToZero() {
|
||||
assertTrue(Validators.ogrn("1000000000000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validOgrnipPassesChecksum() {
|
||||
assertTrue(Validators.ogrnip("304500116000157"));
|
||||
assertTrue(Validators.ogrnip("312500000000013"));
|
||||
assertTrue(Validators.ogrnip("305500112233041"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidOgrnipChecksumFails() {
|
||||
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ogrnipWrongLengthFails() {
|
||||
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
|
||||
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
|
||||
}
|
||||
|
||||
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||
@Test
|
||||
void ogrnipRemainderTenMapsToZero() {
|
||||
assertTrue(Validators.ogrnip("100000000000000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonDigitCharactersAreIgnored() {
|
||||
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyValueIsNotValid() {
|
||||
assertFalse(Validators.ogrn(""));
|
||||
assertFalse(Validators.ogrnip(""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user