feat: rule-based plausibility check for children's shopping entries

This commit is contained in:
Jannis committed 2026-10-10 18:49:13 +02:00
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));
}