feat: rule-based plausibility check for children's shopping entries
This commit is contained in:
1 parent
2473ecd539
commit
b7dac20fc2
2 files changed
+197
No files matched your search
@@ -0,0 +1,125 @@
|
||||
namespace FamilyPlanner.Domain.Shopping;
|
||||
|
||||
/// <summary>
|
||||
/// Einfache, regelbasierte Plausibilitätsprüfung für Einkaufseinträge von Kindern (ohne KI): offensichtlicher Unsinn wird
|
||||
/// erst nach Rückfrage gespeichert. Die Regeln sind bewusst grob – im Zweifel lieber nicht nachfragen.
|
||||
/// </summary>
|
||||
public static class ShoppingPlausibility
|
||||
{
|
||||
/// <summary>Tastaturreihen (deutsches Layout); aufeinanderfolgende Zeichen davon gelten als „Hämmern“ auf der Tastatur.</summary>
|
||||
private static readonly string[] KeyboardRows = ["qwertzuiop", "asdfghjkl", "yxcvbnm", "qwertyuiop", "zxcvbnm"];
|
||||
|
||||
/// <summary>Fenster, die in echten Wörtern vorkommen (z. B. „Wertstoffsack“) und deshalb nicht als Tastaturmuster zählen.</summary>
|
||||
private static readonly HashSet<string> IgnoredKeyboardWindows = ["wert"];
|
||||
|
||||
private const int KeyboardWindow = 4;
|
||||
|
||||
private static readonly HashSet<string> Nonsense = new(StringComparer.Ordinal)
|
||||
{
|
||||
"asdf", "asdfg", "qwertz", "qwerty", "blabla", "bla", "blub", "lol", "haha", "hahaha", "hihi", "hehe", "lalala",
|
||||
"abc", "xyz", "test", "egal", "nix", "nichts", "irgendwas", "keine ahnung", "kacke", "kaka", "popo", "pups",
|
||||
"furz", "fuck", "scheiße", "scheisse", "penis", "sex", "ja", "nein", "ok", "hallo", "hi", "doof", "dumm",
|
||||
};
|
||||
|
||||
/// <summary>Die Prüfung gilt nur für Kinder (Rolle Kind ohne Eltern-Rolle).</summary>
|
||||
public static bool AppliesTo(IEnumerable<string> roles)
|
||||
{
|
||||
var list = roles.ToList();
|
||||
return list.Contains(Roles.Child) && !list.Contains(Roles.Admin) && !list.Contains(Roles.Family);
|
||||
}
|
||||
|
||||
/// <summary>Ist der eingegebene Text (ggf. mit Menge, z. B. „2x Milch“) offensichtlich kein Artikel?</summary>
|
||||
public static bool IsSuspicious(string? text)
|
||||
{
|
||||
var parsed = QuantityParser.Parse(text ?? string.Empty);
|
||||
var name = parsed.Name.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return false; // Leere Eingaben fängt die Validierung ab.
|
||||
}
|
||||
|
||||
var letters = new string(name.Where(char.IsLetter).ToArray()).ToLowerInvariant();
|
||||
|
||||
// 1. Nur Sonderzeichen und/oder Ziffern.
|
||||
if (letters.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Kleine Quatschliste (ganzer Eintrag, ohne Satzzeichen).
|
||||
var normalized = string.Join(' ', new string(name.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : ' ').ToArray())
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
if (Nonsense.Contains(normalized))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Zeichenwiederholung: „aaaa“, „hahaha“, „lolo lolo“.
|
||||
if (HasRepeatedCharacter(letters, 4) || IsRepeatedUnit(letters))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Tastaturmuster: „asdf“, „qwertz“, „hjkl“, auch rückwärts.
|
||||
if (HasKeyboardPattern(letters))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Längere Buchstabenfolge ohne einen einzigen Vokal.
|
||||
return letters.Length >= 5 && letters.All(c => !"aeiouäöüy".Contains(c));
|
||||
}
|
||||
|
||||
private static bool HasRepeatedCharacter(string value, int count)
|
||||
{
|
||||
var run = 1;
|
||||
for (var i = 1; i < value.Length; i++)
|
||||
{
|
||||
run = value[i] == value[i - 1] ? run + 1 : 1;
|
||||
if (run >= count)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Der ganze Text besteht aus einer 2–3 Zeichen langen Einheit, die mindestens dreimal wiederholt wird.</summary>
|
||||
private static bool IsRepeatedUnit(string value)
|
||||
{
|
||||
for (var unit = 2; unit <= 3; unit++)
|
||||
{
|
||||
if (value.Length >= unit * 3 && value.Length % unit == 0)
|
||||
{
|
||||
var first = value[..unit];
|
||||
if (Enumerable.Range(0, value.Length / unit).All(i => string.CompareOrdinal(value, i * unit, first, 0, unit) == 0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasKeyboardPattern(string letters)
|
||||
{
|
||||
for (var i = 0; i + KeyboardWindow <= letters.Length; i++)
|
||||
{
|
||||
var window = letters.Substring(i, KeyboardWindow);
|
||||
if (IgnoredKeyboardWindows.Contains(window))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var reversed = new string(window.Reverse().ToArray());
|
||||
if (KeyboardRows.Any(row => row.Contains(window, StringComparison.Ordinal) || row.Contains(reversed, StringComparison.Ordinal)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Shopping;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Shopping;
|
||||
|
||||
public sealed class ShoppingPlausibilityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Milch")]
|
||||
[InlineData("2x Milch")]
|
||||
[InlineData("500 g Mehl")]
|
||||
[InlineData("Kaffee")]
|
||||
[InlineData("Wertstoffsäcke")]
|
||||
[InlineData("Mississippi-Eis")]
|
||||
[InlineData("7up")]
|
||||
[InlineData("Toastbrot")]
|
||||
[InlineData("Käse")]
|
||||
[InlineData("Eis")]
|
||||
[InlineData("")]
|
||||
public void Plausible_items_are_not_suspicious(string text) =>
|
||||
Assert.False(ShoppingPlausibility.IsSuspicious(text));
|
||||
|
||||
[Theory]
|
||||
[InlineData("???")]
|
||||
[InlineData("12345")]
|
||||
[InlineData("!!!")]
|
||||
[InlineData("2x 55")]
|
||||
public void Only_special_characters_or_digits_are_suspicious(string text) =>
|
||||
Assert.True(ShoppingPlausibility.IsSuspicious(text));
|
||||
|
||||
[Theory]
|
||||
[InlineData("aaaaa")]
|
||||
[InlineData("Hmmmmm")]
|
||||
[InlineData("hahaha")]
|
||||
[InlineData("lolololo")]
|
||||
[InlineData("ababab")]
|
||||
public void Repeated_characters_are_suspicious(string text) =>
|
||||
Assert.True(ShoppingPlausibility.IsSuspicious(text));
|
||||
|
||||
[Theory]
|
||||
[InlineData("asdf")]
|
||||
[InlineData("ASDFG")]
|
||||
[InlineData("qwertz")]
|
||||
[InlineData("hjkl")]
|
||||
[InlineData("fdsa")]
|
||||
[InlineData("xyzqwer")]
|
||||
[InlineData("yxcvbnm")]
|
||||
public void Keyboard_patterns_are_suspicious(string text) =>
|
||||
Assert.True(ShoppingPlausibility.IsSuspicious(text));
|
||||
|
||||
[Theory]
|
||||
[InlineData("blabla")]
|
||||
[InlineData("Keine Ahnung")]
|
||||
[InlineData("Kacke!")]
|
||||
[InlineData("TEST")]
|
||||
[InlineData("3x pups")]
|
||||
public void Nonsense_words_are_suspicious(string text) =>
|
||||
Assert.True(ShoppingPlausibility.IsSuspicious(text));
|
||||
|
||||
[Fact]
|
||||
public void Long_letter_runs_without_vowels_are_suspicious() =>
|
||||
Assert.True(ShoppingPlausibility.IsSuspicious("grtkpfs"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { Roles.Child }, true)]
|
||||
[InlineData(new[] { Roles.Family }, false)]
|
||||
[InlineData(new[] { Roles.Admin }, false)]
|
||||
[InlineData(new[] { Roles.Guest }, false)]
|
||||
[InlineData(new[] { Roles.Child, Roles.Family }, false)]
|
||||
public void Check_applies_to_children_only(string[] roles, bool expected) =>
|
||||
Assert.Equal(expected, ShoppingPlausibility.AppliesTo(roles));
|
||||
}
|
||||
Reference in new issue
Block a user