Files
Vladimir Bryzgalov 714b4712ad fix: wire is_hard_drop_enabled + drop_score_threshold settings into Classifier filter chain
Settings::register_classifier_filters() registers priority-5 callbacks on
cf7stg_classifier_thresholds and cf7stg_hard_drop_rules so the UI options
actually influence Classifier behaviour. Called from Plugin::boot(). User
filters at priority 10 override these defaults as expected. Adds 4 tests
covering the wired behaviour and override priority. Also adds dry_run_drop?
to MessageFormatter::build() @param docblock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:25:20 +05:00

339 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
use Cf7stg\Classifier;
use Cf7stg\Settings;
final class ClassifierDropTest extends Cf7stgTestCase
{
public function test_has_valid_email_anywhere_finds_gmail(): void
{
$fields = ['some-key' => 'pasha@gmail.com'];
self::assertTrue(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_valid_email_anywhere_finds_in_array_value(): void
{
$fields = ['x' => ['noise', 'info@some-domain.com']];
self::assertTrue(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_valid_email_anywhere_returns_false_for_invalid(): void
{
$fields = ['email' => 'not-an-email', 'x' => 'AB'];
self::assertFalse(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_valid_email_anywhere_returns_false_when_empty(): void
{
self::assertFalse(Classifier::has_valid_email_anywhere([]));
}
public function test_has_valid_email_anywhere_finds_email_embedded_in_text(): void
{
$fields = ['your-message' => 'reach me at user@domain.com thanks'];
self::assertTrue(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_valid_email_anywhere_finds_second_email_when_first_invalid(): void
{
// First candidate is "alice@in" which is not a valid email; second is valid.
// Without preg_match_all, the method would return false here.
$fields = ['msg' => 'try alice@in or bob@example.com'];
self::assertTrue(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_short_latin_name_true_for_two_letters(): void
{
$fields = ['your-name' => 'OB'];
self::assertTrue(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_true_for_three_alnum(): void
{
$fields = ['name' => 'A1B'];
self::assertTrue(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_for_long_name(): void
{
$fields = ['your-name' => 'Melissa'];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_for_cyrillic(): void
{
$fields = ['your-name' => 'Иван'];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_when_no_name_field(): void
{
// Только телефон, нет name-полей. FieldDetector::find_name пропустит +79991234567 (digits)
$fields = ['tel-1' => '+79991234567'];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_picks_via_field_detector(): void
{
// text-211 не в списке известных name-ключей, FieldDetector найдёт 'XY' (no digits, len=2)
$fields = ['text-211' => 'XY', 'text-212' => '+79991234567'];
self::assertTrue(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_true_for_single_letter_boundary(): void
{
// Lower boundary of {1,3}: 1 char accepted
$fields = ['your-name' => 'A'];
self::assertTrue(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_for_four_letters_boundary(): void
{
// Upper boundary of {1,3}: 4 chars rejected
$fields = ['your-name' => 'ABCD'];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_for_array_value_with_space_join(): void
{
// pick_field joins array values with a space → "OB MU" is 5 chars → no false positive.
$fields = ['your-name' => ['OB', 'MU']];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_count_placeholder_fields_screenshot_1_exact_count(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-1.php';
// text-785: OB(2), text-787: 70(2), text-786: BP(2), text-788: ZZ(2), text-789: QV(2) → 5 placeholders.
// text-790: 10 цифр — не placeholder. acceptance-data: служебное → исключено.
self::assertSame(5, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_screenshot_2_exact_count(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-2.php';
// Same shape as screenshot-1: 5 placeholder fields.
self::assertSame(5, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_screenshot_3_exact_count(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-3.php';
// text-347: MU(2) is the only placeholder; tel-185(10), text-subject (long), text-title (long)
// are not. acceptance-data: служебное.
self::assertSame(1, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_empty(): void
{
$fields = ['a' => 'AB', 'b' => '', 'c' => 'XY', 'd' => null, 'e' => 'CD'];
self::assertSame(3, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_long_values(): void
{
$fields = ['a' => 'AB', 'b' => 'Hello world', 'c' => 'CD'];
self::assertSame(2, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_service_keys(): void
{
$fields = [
'acceptance-data' => '1',
'_wpcf7' => 'AB',
'_wpcf7_unit_tag' => 'XY',
'g-recaptcha-response' => 'CD',
'real' => 'EF',
];
self::assertSame(1, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_non_alnum(): void
{
// Кириллица или с пробелами не считается placeholder
$fields = ['a' => 'Ия', 'b' => 'a b', 'c' => 'XY'];
self::assertSame(1, Classifier::count_placeholder_fields($fields));
}
public function test_screenshot_1_yields_drop(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-1.php';
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_screenshot_2_yields_drop(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-2.php';
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_screenshot_3_yields_drop(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-3.php';
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_drop_skipped_when_cyrillic_name_present(): void
{
$fields = [
'your-name' => 'Иван',
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function test_drop_skipped_when_meaningful_text_present(): void
{
$fields = [
'your-name' => 'AB',
'your-message' => 'Здравствуйте, нужна уборка квартиры в субботу с 10 утра',
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
/** @dataProvider valid_email_provider */
public function test_drop_skipped_when_valid_email_present(string $email): void
{
$fields = [
'your-name' => 'AB',
'your-email' => $email,
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function valid_email_provider(): array
{
return [
'gmail' => ['pasha@gmail.com'],
'doménный' => ['info@some-domain.com'],
'yandex' => ['name@yandex.ru'],
];
}
public function test_drop_invalid_email_does_not_protect(): void
{
$fields = [
'your-name' => 'OB',
'email' => 'not-an-email',
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_drop_skipped_on_form_without_name_when_no_placeholder_rule(): void
{
// Форма «обратный звонок»: только телефон, имя нет, < 3 placeholder
$fields = ['tel-1' => '+79991234567'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function test_drop_triggers_on_form_without_name_via_placeholder_rule(): void
{
$fields = ['a' => 'AB', 'b' => 'CD', 'c' => 'EF', 'd' => 'GH'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_score_drop_threshold_minus_5(): void
{
// keyword_cap (-6) + latin_only (-1) = -7, без позитивных → score-based drop
// Имя длинное (Melissa Smith), сообщение latin_only → НЕ hard-rule, чисто по score.
$fields = [
'your-name' => 'Melissa Smith',
'your-message' => 'SEO, backlinks, crypto, casino, займ, bitcoin, viagra, porno',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertLessThanOrEqual(-5, $r['score']);
self::assertSame('drop', $r['label']);
}
public function test_filter_disables_short_latin_name_rule(): void
{
add_filter('cf7stg_hard_drop_rules', static function ($rules) {
$rules['short_latin_name'] = false;
return $rules;
});
// Только короткое имя, < 3 placeholder, нет позитивных → правило отключено → не drop
$fields = ['your-name' => 'OB', 'tel' => '+79991234567'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function test_filter_thresholds_can_change_drop_score(): void
{
add_filter('cf7stg_classifier_thresholds', static function ($t) {
$t['drop'] = -3;
return $t;
});
// URL даёт -3, нет hard-rule (нет короткого латинского имени, нет 3+ placeholder),
// но -3 ≤ -3 → drop по score-порогу.
$fields = ['msg' => 'http://x.com'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_hard_drop_disabled_via_settings_skips_drop(): void
{
Settings::set_hard_drop_enabled(false);
Settings::register_classifier_filters();
$fields = require __DIR__ . '/fixtures/spam-screenshot-1.php';
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
// With hard-rule disabled, screenshot-1 has no positive signals AND score isn't ≤ -5
// (placeholders by themselves give no score penalty), so label should NOT be 'drop'.
self::assertNotSame('drop', $r['label']);
}
public function test_drop_score_threshold_from_settings_applied(): void
{
Settings::set_drop_score_threshold(-3);
Settings::register_classifier_filters();
// URL gives -3 score, no positive signals, no hard-rule trigger.
// With threshold lowered to -3, this should fire score-based drop.
$fields = ['msg' => 'http://x.com'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_settings_threshold_can_be_overridden_by_user_filter(): void
{
Settings::set_drop_score_threshold(-3);
Settings::register_classifier_filters();
// User filter at priority 10 should override Settings default at priority 5.
add_filter('cf7stg_classifier_thresholds', static function ($t) {
$t['drop'] = -10;
return $t;
}, 10);
$fields = ['msg' => 'http://x.com']; // score = -3
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
// -3 is NOT ≤ -10, so no drop
self::assertNotSame('drop', $r['label']);
}
public function test_filter_disables_placeholder_fields_rule(): void
{
add_filter('cf7stg_hard_drop_rules', static function ($rules) {
$rules['placeholder_fields'] = false;
return $rules;
});
// Form WITHOUT name field — digit-only values are valid placeholders (^[A-Za-z0-9]{1,2}$)
// but FieldDetector::find_name rejects them (has digits), so short_latin_name cannot fire.
// With placeholder_fields rule also disabled → no hard-rule fires → no drop.
$fields = ['a' => '11', 'b' => '22', 'c' => '33', 'd' => '44'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
}