Compare commits

...
3 Commits
Author SHA1 Message Date
Максименко Никита ВладимировичandClaude Sonnet 5 7f8e0d2d2e feat: denylist streets named after people, fix adjectival surname declension
"Богдана Хмельницкого" and "Великие Луки" (a real city, misread as the
given name "Лука") were already handled by the ORGANISATION_NEARBY veto
added earlier — but only when an organization word is nearby. A street
name with no such word close by ("Живу на улице Карла Маркса") had no
protection, since single-word commemorative streets ("улица Ленина")
never matched the two-word FIO patterns to begin with — it's only the
two-word ones (person's first + last name) that are at risk. Added
~24 of Russia's most common commemorative street-name surnames
(Rosreestr's own ranking: Lenin is #1, Gagarin #2, Kirov #3, ...) to
the existing well-known-people denylist, reusing the mechanism already
built for Pushkin/Tolstoy.

Testing it surfaced a real gap in Declension: surnames declining as an
adjective ("Дзержинский" → "Дзержинского", "-ий" replaced by "-ого"
wholesale, not extended) weren't matching at all in genitive case —
and genitive is the ONLY case a street name is ever written in
("улица Дзержинского", never "улица Дзержинский"). Single-vowel
stripping fixed regular surnames (Пушкин → Пушкина) but not this
pattern. Now strips the whole "-ск-" ending down to the "ск" stem
before falling back to the vowel-strip. Adjectives without "-ск-"
("Толстой" → "Толстого") remain a known, documented limitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:22:22 +03:00
Максименко Никита ВладимировичandClaude Sonnet 5 c25ab9c99f feat: validate OGRN/OGRNIP checksum instead of trusting anchor + digit count
Unlike INN, OGRN and OGRNIP had no checksum check — any 13/15-digit
number next to the word was accepted. Both have a public mod-11/mod-13
algorithm (first 12/14 digits, remainder mod divisor mod 10 = the last
digit), same shape as the existing INN/SNILS checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:21:52 +03:00
Максименко Никита ВладимировичandClaude Sonnet 5 c1a1319088 feat: settlements from the 2020-2021 census, not just the 1111 official cities
The city dictionary only covered cities with formal status (~1100).
Real client addresses are village/settlement, and those never had
an anchor to match on: "рп. Ильинское", "с. Кукуево" from the spec
were undetectable no matter how good the dictionary got, because
ADDRESS_CITY only recognized "г./город".

Replaces cities.txt with settlements.txt (79,362 names, tochno.st's
processing of Rosstat's 2020-2021 census + CIS capitals) and extends
the rule's anchors to рп./пгт/с./д./дер./х./ст-ца/станица/аул/сл./
слобода/аал. isKnownCity renamed to isKnownSettlement to match what
it actually validates now.

Verified: native image startup time and idle memory unchanged despite
the ~70x larger dictionary (prefix-set lookup from the last pass is
O(word length), not O(dictionary size)); no regression in existing
benchmark false-positive thresholds or load-test latency at 1000/1800/
3000 RPS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:21:27 +03:00
13 changed files with 79852 additions and 1157 deletions
@@ -10,19 +10,41 @@ import java.util.Locale;
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на * «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») — * гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
* для них сравнение идёт по основе без неё. * для них сравнение идёт по основе без неё.
*
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
* именительный там не встречается вообще.
*/ */
final class Declension { final class Declension {
/**
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
*/
private static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
};
private Declension() { private Declension() {
} }
/** /**
* Отбрасывает у основы конечную гласную, которая меняется по падежам. * Отбрасывает у основы окончание, которое меняется по падежам: гласную —
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
* Слова короче четырёх букв не трогает — короткая основа и так шире * Слова короче четырёх букв не трогает — короткая основа и так шире
* большинства падежных форм. * большинства падежных форм.
*/ */
static String withoutInflectedEnding(String word) { static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT); 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) { if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
return lower.substring(0, lower.length() - 1); return lower.substring(0, lower.length() - 1);
} }
@@ -210,13 +210,16 @@ public class RuleRegistry {
.anchoredBy("срок действия", "действительн", "exp"), .anchoredBy("срок действия", "действительн", "exp"),
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
// ещё и правилом ОГРН. // ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
// числа рядом со словом — раньше якоря было достаточно самого по себе.
Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83) Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
.groups(1) .groups(1)
.validatedBy(Validators::ogrnip)
.anchoredBy("огрнип"), .anchoredBy("огрнип"),
Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
.groups(1) .groups(1)
.validatedBy(Validators::ogrn)
.anchoredBy("огрн"), .anchoredBy("огрн"),
Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83) Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
@@ -378,11 +381,17 @@ public class RuleRegistry {
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY), .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) .groups(1)
.validatedBy(ToponymDictionary::isKnownCity) .validatedBy(ToponymDictionary::isKnownSettlement)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("г.", "гор", "город"), .anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(ADDRESS_STREET, Rule.of(ADDRESS_STREET,
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*" "(?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; import java.util.stream.Collectors;
/** /**
* Словарь городов России — проверка того, что значение, пойманное правилом * Словарь населённых пунктов России — проверка того, что значение, пойманное
* {@code ADDRESS_CITY}, действительно похоже на существующий город, а не на * правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
* произвольное слово с заглавной буквы после «г.». * село, посёлок или другой населённый пункт, а не на произвольное слово с
* заглавной буквы после якоря.
*
* <p>Не только официальные города (~1100 по классификатору): перепись
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
* а не этот словарь — он только подтверждает, что название реальное.
* *
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания * <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора * («в Москве», «из Казани») тем самым покрываются без отдельного разбора
@@ -21,7 +28,7 @@ import java.util.stream.Collectors;
*/ */
public final class ToponymDictionary { 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) .map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet()); .collect(Collectors.toUnmodifiableSet());
@@ -29,22 +36,23 @@ public final class ToponymDictionary {
} }
/** /**
* Похоже ли значение на название города из словаря в любом падеже. * Похоже ли значение на название населённого пункта из словаря в любом
* падеже.
* *
* <p>Города на согласную склоняются добавлением окончания («Тамбов» → * <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак. * «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
* Города на гласную меняют последнюю букву («Москва» → «Москве»), для * Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
* них сравнение идёт по основе без неё — так же, как с личными именами * них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}. * в {@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); String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) { 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; return true;
} }
} }
@@ -63,6 +63,32 @@ public final class Validators {
return control == d[9] * 10 + d[10]; 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 12.05.1985},
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме * {@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
+31
View File
@@ -59,3 +59,34 @@
Дерипаска Дерипаска
Абрамович Абрамович
Усманов Усманов
# Фамилии, в честь которых чаще всего называют улицы в России (Росреестр,
# Яндекс.Исследования). Упоминание «улица Ленина» само по себе не задевает
# распознавание ФИО — для него нужны два слова, — но составные названия
# («Феликса Дзержинского», «Александра Матросова») попадают под то же
# правило, что и «Богдана Хмельницкого»: в честь человека, а не клиент.
Ленин
Киров
Свердлов
Дзержинский
Фрунзе
Куйбышев
Ворошилов
Будённый
Буденный
Чапаев
Жуков
Чкалов
Терешкова
Мичурин
Шевченко
Орджоникидзе
Калинин
Энгельс
Маркс
Островский
Матросов
Волошина
Сусанин
Димитров
Донской
@@ -56,6 +56,26 @@ class BankTypesTest {
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked); 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 @Test
void masksKpp() { void masksKpp() {
assertHidden("КПП 770101001 указан в реквизитах", "770101001"); 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("Заявление Смирнова")); 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 @Test
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException { void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
Path file = dir.resolve("well-known.txt"); 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
/** Словарь городов — проверка вместо любого слова с заглавной буквы после «г.». */ /** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
class ToponymDictionaryTest { class ToponymDictionaryTest {
@Test @Test
void recognisesNominativeCase() { void recognisesNominativeCase() {
assertTrue(ToponymDictionary.isKnownCity("Москва")); assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
assertTrue(ToponymDictionary.isKnownCity("Казань")); assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
assertTrue(ToponymDictionary.isKnownCity("Санкт-Петербург")); assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
} }
@Test @Test
void recognisesInflectedForms() { void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownCity("Москве"), "дательный падеж города на гласную"); assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(ToponymDictionary.isKnownCity("Тамбове"), "предложный падеж города на согласную"); assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(ToponymDictionary.isKnownCity("Казани"), "родительный падеж"); assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
} }
@Test @Test
void recognisesCisCapitals() { void recognisesCisCapitals() {
assertTrue(ToponymDictionary.isKnownCity("Минск")); assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
assertTrue(ToponymDictionary.isKnownCity("Алматы")); assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
} }
@Test @Test
void rejectsMadeUpWord() { void rejectsMadeUpWord() {
assertFalse(ToponymDictionary.isKnownCity("Ерунда")); assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
assertFalse(ToponymDictionary.isKnownCity("Бла-бла")); assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
} }
@Test @Test
void isCaseInsensitive() { void isCaseInsensitive() {
assertTrue(ToponymDictionary.isKnownCity("МОСКВА")); assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
assertTrue(ToponymDictionary.isKnownCity("москва")); 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(""));
}
}