< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Testing.PromptAssert
Assembly: NexusLabs.Needlr.AgentFramework.Testing
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Testing/PromptAssert.cs
Line coverage
93%
Covered lines: 103
Uncovered lines: 7
Coverable lines: 110
Total lines: 347
Line coverage: 93.6%
Branch coverage
79%
Covered branches: 46
Total branches: 58
Branch coverage: 79.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Contains(...)100%11100%
Contains(...)100%22100%
DoesNotContain(...)100%22100%
ForbidsPattern(...)100%22100%
HasSection(...)100%22100%
ContainsInSection(...)83.33%66100%
SectionOrder(...)100%66100%
ValidatePrompt(...)100%22100%
ValidateExpected(...)100%22100%
ExtractContext(...)100%11100%
FindSectionStart(...)54.54%402266.66%
ExtractSectionContent(...)87.5%8893.33%
CountHeaderLevel(...)100%44100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Testing/PromptAssert.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2
 3namespace NexusLabs.Needlr.AgentFramework.Testing;
 4
 5/// <summary>
 6/// Assertion helpers for verifying prompt integrity without LLM invocation.
 7/// Use in unit tests to catch prompt regressions (missing safety rules, deleted
 8/// sections, forbidden patterns) at zero token cost.
 9/// </summary>
 10/// <example>
 11/// <code>
 12/// [Fact]
 13/// public void WriterPrompt_HasSafetyRules()
 14/// {
 15///     PromptAssert.Contains(WriterPrompt.Text, "ABSOLUTE RULE");
 16///     PromptAssert.ContainsInSection(WriterPrompt.Text, "### Critical", "meta-instruction-leak");
 17///     PromptAssert.ForbidsPattern(WriterPrompt.Text, @"TODO|FIXME|HACK");
 18/// }
 19/// </code>
 20/// </example>
 21public static class PromptAssert
 22{
 23    /// <summary>
 24    /// Asserts that <paramref name="prompt"/> contains <paramref name="expected"/>
 25    /// using <see cref="StringComparison.OrdinalIgnoreCase"/>.
 26    /// </summary>
 27    /// <param name="prompt">The prompt text to inspect.</param>
 28    /// <param name="expected">The substring that must be present.</param>
 29    /// <exception cref="ArgumentException">
 30    /// <paramref name="prompt"/> is <see langword="null"/> or
 31    /// <paramref name="expected"/> is <see langword="null"/> or empty.
 32    /// </exception>
 33    /// <exception cref="PromptAssertionException">The expected text was not found.</exception>
 34    public static void Contains(string prompt, string expected)
 2235        => Contains(prompt, expected, StringComparison.OrdinalIgnoreCase);
 36
 37    /// <summary>
 38    /// Asserts that <paramref name="prompt"/> contains <paramref name="expected"/>
 39    /// using the specified <paramref name="comparison"/>.
 40    /// </summary>
 41    /// <param name="prompt">The prompt text to inspect.</param>
 42    /// <param name="expected">The substring that must be present.</param>
 43    /// <param name="comparison">The comparison rule to use.</param>
 44    /// <exception cref="ArgumentException">
 45    /// <paramref name="prompt"/> is <see langword="null"/> or
 46    /// <paramref name="expected"/> is <see langword="null"/> or empty.
 47    /// </exception>
 48    /// <exception cref="PromptAssertionException">The expected text was not found.</exception>
 49    public static void Contains(string prompt, string expected, StringComparison comparison)
 50    {
 2451        ValidatePrompt(prompt);
 2352        ValidateExpected(expected);
 53
 2154        if (!prompt.Contains(expected, comparison))
 55        {
 256            throw new PromptAssertionException(
 257                $"Expected prompt to contain '{expected}' but it was not found. " +
 258                $"Prompt length: {prompt.Length} chars.");
 59        }
 1960    }
 61
 62    /// <summary>
 63    /// Asserts that <paramref name="prompt"/> does NOT contain <paramref name="forbidden"/>
 64    /// using <see cref="StringComparison.OrdinalIgnoreCase"/>.
 65    /// </summary>
 66    /// <param name="prompt">The prompt text to inspect.</param>
 67    /// <param name="forbidden">The substring that must be absent.</param>
 68    /// <exception cref="ArgumentException">
 69    /// <paramref name="prompt"/> is <see langword="null"/> or
 70    /// <paramref name="forbidden"/> is <see langword="null"/> or empty.
 71    /// </exception>
 72    /// <exception cref="PromptAssertionException">The forbidden text was found.</exception>
 73    public static void DoesNotContain(string prompt, string forbidden)
 74    {
 275        ValidatePrompt(prompt);
 276        ValidateExpected(forbidden, nameof(forbidden));
 77
 278        var index = prompt.IndexOf(forbidden, StringComparison.OrdinalIgnoreCase);
 279        if (index >= 0)
 80        {
 181            var context = ExtractContext(prompt, index, forbidden.Length);
 182            throw new PromptAssertionException(
 183                $"Expected prompt to NOT contain '{forbidden}' but it was found at offset {index}. " +
 184                $"Context: '...{context}...'");
 85        }
 186    }
 87
 88    /// <summary>
 89    /// Asserts that <paramref name="prompt"/> does not match <paramref name="regexPattern"/>.
 90    /// </summary>
 91    /// <param name="prompt">The prompt text to inspect.</param>
 92    /// <param name="regexPattern">A regular expression pattern that must NOT match.</param>
 93    /// <exception cref="ArgumentException">
 94    /// <paramref name="prompt"/> is <see langword="null"/> or
 95    /// <paramref name="regexPattern"/> is <see langword="null"/> or empty.
 96    /// </exception>
 97    /// <exception cref="PromptAssertionException">The pattern matched.</exception>
 98    public static void ForbidsPattern(string prompt, string regexPattern)
 99    {
 2100        ValidatePrompt(prompt);
 2101        ValidateExpected(regexPattern, nameof(regexPattern));
 102
 2103        var match = Regex.Match(prompt, regexPattern, RegexOptions.None);
 2104        if (match.Success)
 105        {
 1106            throw new PromptAssertionException(
 1107                $"Expected prompt to not match pattern '{regexPattern}' but found match: " +
 1108                $"'{match.Value}' at offset {match.Index}.");
 109        }
 1110    }
 111
 112    /// <summary>
 113    /// Asserts that <paramref name="prompt"/> contains a markdown section header
 114    /// matching <paramref name="sectionHeader"/> (e.g., <c>"### Critical"</c>).
 115    /// </summary>
 116    /// <param name="prompt">The prompt text to inspect.</param>
 117    /// <param name="sectionHeader">The markdown header text (including <c>#</c> prefix).</param>
 118    /// <exception cref="ArgumentException">
 119    /// <paramref name="prompt"/> is <see langword="null"/> or
 120    /// <paramref name="sectionHeader"/> is <see langword="null"/> or empty.
 121    /// </exception>
 122    /// <exception cref="PromptAssertionException">The section header was not found.</exception>
 123    public static void HasSection(string prompt, string sectionHeader)
 124    {
 2125        ValidatePrompt(prompt);
 2126        ValidateExpected(sectionHeader, nameof(sectionHeader));
 127
 2128        if (FindSectionStart(prompt, sectionHeader) < 0)
 129        {
 1130            throw new PromptAssertionException(
 1131                $"Expected prompt to contain section '{sectionHeader}' but it was not found.");
 132        }
 1133    }
 134
 135    /// <summary>
 136    /// Asserts that <paramref name="expected"/> appears within the markdown section
 137    /// that starts at <paramref name="sectionHeader"/>. The section ends at the next
 138    /// header of equal or higher level, or at the end of the prompt.
 139    /// </summary>
 140    /// <param name="prompt">The prompt text to inspect.</param>
 141    /// <param name="sectionHeader">The markdown header that starts the section (e.g., <c>"### Critical"</c>).</param>
 142    /// <param name="expected">The text that must appear inside the section.</param>
 143    /// <exception cref="ArgumentException">
 144    /// <paramref name="prompt"/> is <see langword="null"/> or
 145    /// <paramref name="sectionHeader"/> or <paramref name="expected"/> is <see langword="null"/> or empty.
 146    /// </exception>
 147    /// <exception cref="PromptAssertionException">
 148    /// The section was not found, or the expected text was not in the section.
 149    /// </exception>
 150    public static void ContainsInSection(string prompt, string sectionHeader, string expected)
 151    {
 5152        ValidatePrompt(prompt);
 5153        ValidateExpected(sectionHeader, nameof(sectionHeader));
 5154        ValidateExpected(expected);
 155
 5156        var sectionStart = FindSectionStart(prompt, sectionHeader);
 5157        if (sectionStart < 0)
 158        {
 1159            throw new PromptAssertionException(
 1160                $"Section '{sectionHeader}' not found in prompt.");
 161        }
 162
 4163        var sectionContent = ExtractSectionContent(prompt, sectionHeader, sectionStart);
 164
 4165        if (!sectionContent.Contains(expected, StringComparison.OrdinalIgnoreCase))
 166        {
 1167            var preview = sectionContent.Length > 200
 1168                ? sectionContent[..200]
 1169                : sectionContent;
 1170            throw new PromptAssertionException(
 1171                $"Expected section '{sectionHeader}' to contain '{expected}' but it was not found. " +
 1172                $"Section content: '{preview}'");
 173        }
 3174    }
 175
 176    /// <summary>
 177    /// Asserts that <paramref name="firstSection"/> appears before
 178    /// <paramref name="secondSection"/> in <paramref name="prompt"/>.
 179    /// </summary>
 180    /// <param name="prompt">The prompt text to inspect.</param>
 181    /// <param name="firstSection">The section header that should come first.</param>
 182    /// <param name="secondSection">The section header that should come second.</param>
 183    /// <exception cref="ArgumentException">
 184    /// <paramref name="prompt"/> is <see langword="null"/> or
 185    /// <paramref name="firstSection"/> or <paramref name="secondSection"/> is <see langword="null"/> or empty.
 186    /// </exception>
 187    /// <exception cref="PromptAssertionException">
 188    /// A section was not found, or the order was reversed.
 189    /// </exception>
 190    public static void SectionOrder(string prompt, string firstSection, string secondSection)
 191    {
 4192        ValidatePrompt(prompt);
 4193        ValidateExpected(firstSection, nameof(firstSection));
 4194        ValidateExpected(secondSection, nameof(secondSection));
 195
 4196        var idx1 = FindSectionStart(prompt, firstSection);
 4197        if (idx1 < 0)
 198        {
 1199            throw new PromptAssertionException(
 1200                $"Expected prompt to contain section '{firstSection}' but it was not found.");
 201        }
 202
 3203        var idx2 = FindSectionStart(prompt, secondSection);
 3204        if (idx2 < 0)
 205        {
 1206            throw new PromptAssertionException(
 1207                $"Expected prompt to contain section '{secondSection}' but it was not found.");
 208        }
 209
 2210        if (idx1 >= idx2)
 211        {
 1212            throw new PromptAssertionException(
 1213                $"Expected section '{firstSection}' to appear before '{secondSection}' " +
 1214                $"but order was reversed (first at offset {idx1}, second at offset {idx2}).");
 215        }
 1216    }
 217
 218    private static void ValidatePrompt(string prompt)
 219    {
 39220        if (prompt is null)
 221        {
 1222            throw new ArgumentException("Prompt cannot be null.", nameof(prompt));
 223        }
 38224    }
 225
 226    private static void ValidateExpected(string value, string paramName = "expected")
 227    {
 47228        if (string.IsNullOrEmpty(value))
 229        {
 2230            throw new ArgumentException("Value cannot be null or empty.", paramName);
 231        }
 45232    }
 233
 234    private static string ExtractContext(string prompt, int matchIndex, int matchLength)
 235    {
 236        const int contextRadius = 20;
 1237        var start = Math.Max(0, matchIndex - contextRadius);
 1238        var end = Math.Min(prompt.Length, matchIndex + matchLength + contextRadius);
 1239        return prompt[start..end];
 240    }
 241
 242    /// <summary>
 243    /// Finds the start index of a markdown section header line.
 244    /// The header must appear at the start of a line.
 245    /// </summary>
 246    private static int FindSectionStart(string prompt, string sectionHeader)
 247    {
 248        // Check if the prompt starts with the header
 14249        if (prompt.StartsWith(sectionHeader, StringComparison.OrdinalIgnoreCase))
 250        {
 251            // Verify it's a complete line match (next char is newline or end of string)
 0252            if (prompt.Length == sectionHeader.Length ||
 0253                prompt[sectionHeader.Length] == '\n' ||
 0254                prompt[sectionHeader.Length] == '\r')
 255            {
 0256                return 0;
 257            }
 258        }
 259
 260        // Search for the header at the start of a line
 14261        var searchOffset = 0;
 14262        while (searchOffset < prompt.Length)
 263        {
 14264            var idx = prompt.IndexOf(sectionHeader, searchOffset, StringComparison.OrdinalIgnoreCase);
 14265            if (idx < 0)
 266            {
 4267                return -1;
 268            }
 269
 270            // Must be at the start of a line
 10271            if (idx == 0 || prompt[idx - 1] == '\n')
 272            {
 273                // Must be followed by newline, end of string, or whitespace to be a header
 10274                var endIdx = idx + sectionHeader.Length;
 10275                if (endIdx >= prompt.Length ||
 10276                    prompt[endIdx] == '\n' ||
 10277                    prompt[endIdx] == '\r')
 278                {
 10279                    return idx;
 280                }
 281            }
 282
 0283            searchOffset = idx + 1;
 284        }
 285
 0286        return -1;
 287    }
 288
 289    /// <summary>
 290    /// Extracts the content of a section starting after the header line,
 291    /// ending at the next header of same or higher level, or end of string.
 292    /// </summary>
 293    private static string ExtractSectionContent(string prompt, string sectionHeader, int sectionStart)
 294    {
 4295        var headerLevel = CountHeaderLevel(sectionHeader);
 296
 297        // Find the end of the header line
 4298        var contentStart = prompt.IndexOf('\n', sectionStart);
 4299        if (contentStart < 0)
 300        {
 0301            return string.Empty;
 302        }
 303
 4304        contentStart++; // skip past the newline
 305
 306        // Scan forward for the next header of same or higher level
 4307        var lines = prompt[contentStart..].Split('\n');
 4308        var sectionEnd = contentStart;
 80309        foreach (var line in lines)
 310        {
 38311            var trimmed = line.TrimStart();
 38312            if (trimmed.StartsWith('#'))
 313            {
 8314                var lineLevel = CountHeaderLevel(trimmed);
 8315                if (lineLevel <= headerLevel)
 316                {
 317                    break;
 318                }
 319            }
 320
 34321            sectionEnd += line.Length + 1; // +1 for the newline
 322        }
 323
 324        // Clamp to prompt length
 4325        sectionEnd = Math.Min(sectionEnd, prompt.Length);
 326
 4327        return prompt[contentStart..sectionEnd];
 328    }
 329
 330    private static int CountHeaderLevel(string header)
 331    {
 12332        var count = 0;
 100333        foreach (var ch in header)
 334        {
 44335            if (ch == '#')
 336            {
 32337                count++;
 338            }
 339            else
 340            {
 341                break;
 342            }
 343        }
 344
 12345        return count;
 346    }
 347}