Refactor event occurrence parsing by introducing modular components for improved maintainability
This commit restructures the EventOccurrenceParser by breaking down its functionality into modular components, including EventDefinitionResolver, LineClassifier, LocationPatternMatcher, SectionHeaderMatcher, TimeLocationParser, and TimeParser. This refactoring enhances code readability and maintainability, allowing for easier updates and testing. Additionally, the TextUtil class has been updated to include input sanitization methods. Comprehensive unit tests have been added to ensure the correctness of the new parsing logic and to validate the handling of various event occurrence scenarios.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Parsers.EventOccurrence;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves event definitions from occurrence name patterns or section context.
|
||||
/// </summary>
|
||||
public static class EventDefinitionResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps special event name patterns to their EventDefinition instances.
|
||||
/// Patterns are checked in order, and the first match wins.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, EventDefinition> SpecialEventPatterns =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Meet the Candidates"] = EventDefinition.MeetTheCandidates,
|
||||
["Chapter Officer Meeting"] = EventDefinition.ChapterOfficerMeeting,
|
||||
["Voting Delegate Meeting"] = EventDefinition.VotingDelegateMeeting,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determines the EventDefinition for an occurrence based on its name pattern or current section context.
|
||||
/// </summary>
|
||||
/// <param name="occurrenceName">The name of the occurrence.</param>
|
||||
/// <param name="currentEventDefinition">The current event definition from section header, if any.</param>
|
||||
/// <returns>The resolved EventDefinition, or null if it cannot be determined.</returns>
|
||||
public static EventDefinition? Resolve(string occurrenceName, EventDefinition? currentEventDefinition)
|
||||
{
|
||||
// Check for special event name patterns first (regardless of current section)
|
||||
foreach (var (pattern, eventDef) in SpecialEventPatterns)
|
||||
{
|
||||
if (occurrenceName.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return eventDef;
|
||||
}
|
||||
|
||||
// If we're in a General Schedule/Session section and no pattern matched, use GeneralSchedule
|
||||
if (currentEventDefinition == EventDefinition.GeneralSchedule)
|
||||
return EventDefinition.GeneralSchedule;
|
||||
|
||||
// If we have a current event definition from section header (e.g., regular events), use it
|
||||
return currentEventDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace Core.Parsers.EventOccurrence;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies lines to determine if they should be skipped during parsing.
|
||||
/// </summary>
|
||||
public static class LineClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a line is empty or contains only whitespace.
|
||||
/// </summary>
|
||||
public static bool IsEmptyLine(string line)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a line is a comment (starts with "#").
|
||||
/// </summary>
|
||||
public static bool IsCommentLine(string line)
|
||||
{
|
||||
return EventOccurrenceGrammar.IsCommentLine(line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a line is a continuation/wrapped line that should be skipped.
|
||||
/// These are typically lines that:
|
||||
/// - Start with lowercase or special characters (not event names)
|
||||
/// - Are parenthetical notes like "(Semifinalists only)"
|
||||
/// - Are informational text like "Schedule Posted on..."
|
||||
/// </summary>
|
||||
public static bool IsContinuationLine(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Skip parenthetical notes
|
||||
if (trimmed.StartsWith("(", StringComparison.Ordinal) && trimmed.EndsWith(")", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
// Skip lines that are clearly continuation text (start with lowercase, common continuation words)
|
||||
if (trimmed.Length > 0 && char.IsLower(trimmed[0]))
|
||||
{
|
||||
// Check if it starts with common continuation words
|
||||
var continuationPrefixes = new[] { "be ", "the ", "and ", "or ", "to ", "a ", "an ", "will ", "may ", "can " };
|
||||
foreach (var prefix in continuationPrefixes)
|
||||
{
|
||||
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip informational lines that don't contain dates/times
|
||||
if (trimmed.Contains("Schedule Posted", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Contains("Note:", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Core.Parsers.EventOccurrence;
|
||||
|
||||
/// <summary>
|
||||
/// Matches location strings against configurable patterns.
|
||||
/// Supports exact matches and wildcard patterns (e.g., "Room *", "Exhibit Hall *").
|
||||
/// </summary>
|
||||
public static class LocationPatternMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Matches location text against configured patterns and returns the matched location.
|
||||
/// </summary>
|
||||
/// <param name="locationText">The location text to match.</param>
|
||||
/// <param name="patterns">The list of patterns to match against.</param>
|
||||
/// <returns>The matched location if a pattern matches, or empty string if no match is found.</returns>
|
||||
public static string Match(string locationText, IReadOnlyList<string> patterns)
|
||||
{
|
||||
// Normalize location text for matching (trim and handle variations)
|
||||
var normalizedLocation = locationText.Trim();
|
||||
|
||||
// If location is empty after normalization, return empty
|
||||
if (string.IsNullOrWhiteSpace(normalizedLocation))
|
||||
return string.Empty;
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
var normalizedPattern = pattern.Trim();
|
||||
|
||||
// Skip empty patterns
|
||||
if (string.IsNullOrWhiteSpace(normalizedPattern))
|
||||
continue;
|
||||
|
||||
// Handle exact matches (patterns without wildcards like "Online", "Virtual", "TBD")
|
||||
if (!normalizedPattern.Contains('*'))
|
||||
{
|
||||
if (string.Equals(normalizedPattern, normalizedLocation, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalizedLocation;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert pattern to regex: escape special chars, replace * with .*
|
||||
// This handles patterns like "Exhibit Hall *", "Room *", "Mtg. Room *", etc.
|
||||
var escapedPattern = Regex.Escape(normalizedPattern);
|
||||
escapedPattern = escapedPattern.Replace(@"\*", ".*?");
|
||||
|
||||
// Use case-insensitive matching
|
||||
// Note: For dynamic patterns, we compile on demand. This is acceptable since patterns
|
||||
// are configured and reused across parsing sessions.
|
||||
var regex = new Regex($"^{escapedPattern}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
if (regex.IsMatch(normalizedLocation))
|
||||
{
|
||||
return normalizedLocation; // Return the full matched location
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Core.Entities;
|
||||
using FuzzySharp;
|
||||
|
||||
namespace Core.Parsers.EventOccurrence;
|
||||
|
||||
/// <summary>
|
||||
/// Matches section headers to event definitions using fuzzy matching.
|
||||
/// </summary>
|
||||
public static class SectionHeaderMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a line is a general schedule header.
|
||||
/// </summary>
|
||||
public static bool IsGeneralSchedule(string line)
|
||||
{
|
||||
return EventOccurrenceGrammar.IsGeneralSchedule(line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a line contains school level markers (MS or HS).
|
||||
/// </summary>
|
||||
public static bool HasSchoolLevel(string line)
|
||||
{
|
||||
return line.Contains("MS", StringComparison.Ordinal) ||
|
||||
line.Contains("HS", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a section header to the best matching event definition using fuzzy matching.
|
||||
/// </summary>
|
||||
/// <param name="sectionHeader">The section header text to match.</param>
|
||||
/// <param name="events">The collection of available event definitions.</param>
|
||||
/// <returns>The best matching EventDefinition, or null if no match is found (ratio > 50).</returns>
|
||||
public static EventDefinition? MatchEventDefinition(string sectionHeader, ICollection<EventDefinition> events)
|
||||
{
|
||||
var evt =
|
||||
(from e in events
|
||||
let rat = Fuzz.Ratio(e.Name, sectionHeader)
|
||||
where rat > 50
|
||||
orderby rat descending
|
||||
select e).FirstOrDefault();
|
||||
|
||||
return evt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the best match ratio for a section header against events.
|
||||
/// Useful for error messages when no match is found.
|
||||
/// </summary>
|
||||
/// <param name="sectionHeader">The section header text.</param>
|
||||
/// <param name="events">The collection of available event definitions.</param>
|
||||
/// <returns>The best match ratio, or 0 if no events are available.</returns>
|
||||
public static int GetBestMatchRatio(string sectionHeader, ICollection<EventDefinition> events)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
return 0;
|
||||
|
||||
var bestEvent = events.FirstOrDefault();
|
||||
return bestEvent != null ? Fuzz.Ratio(sectionHeader, bestEvent.Name) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Core.Models;
|
||||
|
||||
namespace Core.Parsers.EventOccurrence;
|
||||
|
||||
/// <summary>
|
||||
/// Parses time and location from combined strings.
|
||||
/// Handles time ranges, location extraction, and pattern matching.
|
||||
/// </summary>
|
||||
public static class TimeLocationParser
|
||||
{
|
||||
// Shared time value pattern: matches either NOON or a time with AM/PM (e.g., "10:30 a.m.", "3 p.m.")
|
||||
private static string TimeValuePattern => TimePatterns.TimeValue;
|
||||
|
||||
// Regex to match time ranges like "10:30 a.m. - 12:00 p.m." or "10:30 a.m. - NOON"
|
||||
// Matches: time1 (optional dash time2/NOON), then location
|
||||
// The time group captures the full time range (including " - NOON" if present)
|
||||
// Note: Input is normalized via SanitizeInput, so only regular hyphens need to be handled
|
||||
private static readonly Regex TimeLocationRegex = new(
|
||||
$@"(?<Time>{TimeValuePattern}(?:\s*-\s*{TimeValuePattern})?)(?:\s+(?<Location>.+))?",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
// Pattern for cleaning time components from location text
|
||||
// Matches optional dash, whitespace, time pattern, optional whitespace at start
|
||||
// Handles: "- 12:15 p.m. ", "12:15 p.m. ", "- NOON ", "NOON ", etc.
|
||||
private static readonly Regex TimeInLocationPattern = new(
|
||||
$@"^(?:-\s*)?{TimeValuePattern}(?:\s+|$)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Parses time and location from the timeAndLocation string using configurable location patterns.
|
||||
/// </summary>
|
||||
/// <param name="timeAndLocation">The combined time and location string.</param>
|
||||
/// <param name="locationConfig">The location parsing configuration with patterns.</param>
|
||||
/// <param name="time">Output parameter: the parsed time string.</param>
|
||||
/// <param name="location">Output parameter: the parsed location string.</param>
|
||||
/// <param name="locationParseSuccess">Output parameter: whether location parsing was successful.</param>
|
||||
public static void Parse(
|
||||
string timeAndLocation,
|
||||
LocationParsingConfiguration? locationConfig,
|
||||
out string time,
|
||||
out string location,
|
||||
out bool locationParseSuccess)
|
||||
{
|
||||
// Try to separate time from location using the time regex
|
||||
var timeLocationMatch = TimeLocationRegex.Match(timeAndLocation);
|
||||
|
||||
if (!timeLocationMatch.Success)
|
||||
{
|
||||
// If time regex doesn't match, use the whole string as time
|
||||
time = timeAndLocation.Trim();
|
||||
location = string.Empty;
|
||||
locationParseSuccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
time = timeLocationMatch.Groups["Time"].Captures[0].Value.Trim();
|
||||
var locationPart = timeLocationMatch.Groups["Location"].Success
|
||||
? timeLocationMatch.Groups["Location"].Captures[0].Value.Trim()
|
||||
: string.Empty;
|
||||
|
||||
// No location part found, which is valid (some events might not have locations)
|
||||
if (string.IsNullOrWhiteSpace(locationPart))
|
||||
{
|
||||
location = string.Empty;
|
||||
locationParseSuccess = true; // Consider it a success since no location is needed
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up location part - remove any remaining time components
|
||||
// (e.g., "– 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C")
|
||||
locationPart = CleanLocationText(locationPart);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(locationPart))
|
||||
{
|
||||
location = string.Empty;
|
||||
locationParseSuccess = true; // No location after cleaning is also valid
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to match location using configurable patterns
|
||||
(location, locationParseSuccess) = TryMatchLocation(locationPart, locationConfig);
|
||||
|
||||
// If no pattern matched but we have a location, use it anyway
|
||||
// This allows parsing to continue while still tracking that the location didn't match a pattern
|
||||
if (!locationParseSuccess)
|
||||
{
|
||||
location = locationPart;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a location string against configured patterns.
|
||||
/// </summary>
|
||||
private static (string location, bool success) TryMatchLocation(
|
||||
string locationPart,
|
||||
LocationParsingConfiguration? locationConfig)
|
||||
{
|
||||
// No patterns configured - can't match
|
||||
if (locationConfig == null || !locationConfig.LocationPatterns.Any())
|
||||
{
|
||||
return (string.Empty, false);
|
||||
}
|
||||
|
||||
// Try initial match
|
||||
var location = LocationPatternMatcher.Match(locationPart, locationConfig.LocationPatterns);
|
||||
if (!string.IsNullOrEmpty(location))
|
||||
{
|
||||
return (location, true);
|
||||
}
|
||||
|
||||
// Try matching against trimmed version (handles extra whitespace)
|
||||
var cleanedForMatching = locationPart.Trim();
|
||||
location = LocationPatternMatcher.Match(cleanedForMatching, locationConfig.LocationPatterns);
|
||||
if (!string.IsNullOrEmpty(location))
|
||||
{
|
||||
return (cleanedForMatching, true);
|
||||
}
|
||||
|
||||
return (string.Empty, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans location text by removing any remaining time components from the start.
|
||||
/// Handles cases like "- 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C"
|
||||
/// Note: Input is normalized, so only regular hyphens need to be handled.
|
||||
/// </summary>
|
||||
public static string CleanLocationText(string locationText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(locationText))
|
||||
return string.Empty;
|
||||
|
||||
// Remove time pattern from start, repeat until no more matches
|
||||
string previous;
|
||||
do
|
||||
{
|
||||
previous = locationText;
|
||||
locationText = TimeInLocationPattern.Replace(locationText, "").Trim();
|
||||
} while (locationText != previous && !string.IsNullOrWhiteSpace(locationText));
|
||||
|
||||
// If result is empty or only whitespace, return empty
|
||||
return string.IsNullOrWhiteSpace(locationText) ? string.Empty : locationText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Core.Parsers.EventOccurrence;
|
||||
|
||||
/// <summary>
|
||||
/// Parses time strings into TimeOnly objects.
|
||||
/// Handles various time formats including NOON, TBD, time ranges, and AM/PM formats.
|
||||
/// </summary>
|
||||
public static class TimeParser
|
||||
{
|
||||
private static readonly Regex TimeRegex = new(
|
||||
TimePatterns.TimeWithGroups,
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the start time from a time range string.
|
||||
/// Example: "10:00 a.m. - 12:00 p.m." -> "10:00 a.m."
|
||||
/// </summary>
|
||||
public static string ExtractStartTime(string timeRange)
|
||||
{
|
||||
if (timeRange.Contains(" - ", StringComparison.Ordinal))
|
||||
{
|
||||
return timeRange[..timeRange.IndexOf(" - ", StringComparison.Ordinal)];
|
||||
}
|
||||
return timeRange;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a time string into a TimeOnly object.
|
||||
/// Handles:
|
||||
/// - NOON -> 12:00 PM
|
||||
/// - TBD -> 00:00:00 (midnight as placeholder)
|
||||
/// - Time ranges -> extracts start time (e.g., "10:00 a.m. - 12:00 p.m." -> parses "10:00 a.m.")
|
||||
/// - Standard AM/PM formats (e.g., "3:00 p.m.", "10:30 am")
|
||||
/// </summary>
|
||||
/// <param name="time">The time string to parse.</param>
|
||||
/// <returns>A TimeOnly object representing the parsed time.</returns>
|
||||
/// <exception cref="FormatException">Thrown when the time format is not recognized.</exception>
|
||||
public static TimeOnly Parse(string time)
|
||||
{
|
||||
int hour = 0;
|
||||
int minute = 0;
|
||||
|
||||
// Handle TBD (To Be Determined) times gracefully
|
||||
if (string.Equals(time.Trim(), "TBD", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Use a placeholder time (midnight) for TBD - the occurrence will still be created
|
||||
// but with a time that indicates it's TBD
|
||||
return new TimeOnly(0, 0, 0);
|
||||
}
|
||||
|
||||
// Extract start time from range if present
|
||||
var timeToParse = ExtractStartTime(time);
|
||||
|
||||
// Handle NOON
|
||||
if (timeToParse == "NOON")
|
||||
{
|
||||
hour = 12;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Parse time with regex
|
||||
var timeMatch = TimeRegex.Match(timeToParse);
|
||||
if (timeMatch.Success)
|
||||
{
|
||||
hour = int.Parse(timeMatch.Groups["Hour"].Captures[0].Value);
|
||||
if (timeMatch.Groups["Minute"].Success)
|
||||
{
|
||||
minute = int.Parse(timeMatch.Groups["Minute"].Captures[0].Value);
|
||||
}
|
||||
|
||||
// Convert AM/PM times to 24-hour format
|
||||
var apmValue = timeMatch.Groups["APM"].Captures[0].Value.ToLower();
|
||||
if (apmValue is "p.m." or "pm")
|
||||
{
|
||||
// PM: add 12 unless it's 12 PM (which stays 12)
|
||||
if (hour < 12)
|
||||
hour += 12;
|
||||
}
|
||||
else if (apmValue is "a.m." or "am")
|
||||
{
|
||||
// AM: if it's 12 AM, convert to midnight (0)
|
||||
if (hour == 12)
|
||||
hour = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new FormatException($"Time format not recognized: {time}");
|
||||
}
|
||||
}
|
||||
|
||||
return new TimeOnly(hour, minute, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Core.Parsers.EventOccurrence;
|
||||
|
||||
/// <summary>
|
||||
/// Shared regex patterns for time parsing.
|
||||
/// </summary>
|
||||
internal static class TimePatterns
|
||||
{
|
||||
/// <summary>
|
||||
/// AM/PM pattern (case-insensitive via IgnoreCase flag).
|
||||
/// Matches: "a.m.", "am", "A.M.", "AM", "p.m.", "pm", "P.M.", "PM"
|
||||
/// </summary>
|
||||
public const string AmPm = @"(?:a|p)\.?m\.?";
|
||||
|
||||
/// <summary>
|
||||
/// Hour pattern: matches 1-2 digits (1-12 or 1-23).
|
||||
/// </summary>
|
||||
public const string Hour = @"\d{1,2}";
|
||||
|
||||
/// <summary>
|
||||
/// Minute pattern with named group for parsing: matches exactly 2 digits if present.
|
||||
/// Used when parsing to TimeOnly objects where minutes must be valid.
|
||||
/// </summary>
|
||||
public const string MinuteWithGroup = @"(?<Minute>\d{2})?";
|
||||
|
||||
/// <summary>
|
||||
/// Minute pattern for matching: matches 0-2 digits.
|
||||
/// Used when extracting time strings (more lenient for location parsing).
|
||||
/// </summary>
|
||||
public const string MinuteFlexible = @"\d{0,2}";
|
||||
|
||||
/// <summary>
|
||||
/// Time value pattern: matches either NOON or a time with AM/PM.
|
||||
/// Used for matching time strings in location parsing (more lenient minute format).
|
||||
/// </summary>
|
||||
public static string TimeValue => $@"(?:NOON|{Hour}:?{MinuteFlexible}\s*{AmPm})";
|
||||
|
||||
/// <summary>
|
||||
/// Time pattern with named groups for parsing hour, minute, and AM/PM.
|
||||
/// Used when parsing to TimeOnly objects (requires 2-digit minutes if present).
|
||||
/// </summary>
|
||||
public static string TimeWithGroups => $@"(?<Hour>{Hour}):?{MinuteWithGroup}\s?(?<APM>{AmPm})";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using FuzzySharp;
|
||||
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
|
||||
using Core.Utility;
|
||||
|
||||
namespace Core.Parsers;
|
||||
|
||||
@@ -10,7 +11,7 @@ namespace Core.Parsers;
|
||||
/// </summary>
|
||||
public class EventOccurrenceParserResult
|
||||
{
|
||||
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>();
|
||||
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>();
|
||||
public List<ParsingIssue> Issues { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -27,18 +28,6 @@ public class EventOccurrenceParser
|
||||
_locationConfig = locationConfig;
|
||||
}
|
||||
|
||||
private readonly Regex _timeRe = new(@"(?<Hour>\d{1,2}):?(?<Minute>\d{2})?\s?(?<APM>(?:a|p)\.?m\.?)");
|
||||
|
||||
// Regex to match time ranges like "10:30 a.m. - 12:00 p.m." or "10:30 a.m. - NOON"
|
||||
// Matches: time1 (optional dash time2/NOON), then location
|
||||
// The time group captures the full time range (including " - NOON" if present)
|
||||
// Note: Input is normalized via SanitizeInput, so only regular hyphens need to be handled
|
||||
// Pattern breakdown:
|
||||
// - First time: (?:NOON|\d{1,2}:?\d{0,2}\s*(?:[AaPp]\.?[Mm]\.?)) - matches NOON or time with AM/PM (more flexible whitespace)
|
||||
// - Optional range: (?:\s*-\s*(?:NOON|\d{1,2}:?\d{0,2}\s*(?:[AaPp]\.?[Mm]\.?))) - matches dash followed by NOON or time
|
||||
// - Location: (?:\s+(?<Location>.+))? - optional whitespace followed by location (capture group with explicit name)
|
||||
private readonly Regex _timeLocationRegex = new(@"(?<Time>(?:NOON|\d{1,2}:?\d{0,2}\s*(?:[AaPp]\.?[Mm]\.?))(?:\s*-\s*(?:NOON|\d{1,2}:?\d{0,2}\s*(?:[AaPp]\.?[Mm]\.?)))?)(?:\s+(?<Location>.+))?");
|
||||
|
||||
public EventOccurrenceParserResult Parse()
|
||||
{
|
||||
var result = new EventOccurrenceParserResult();
|
||||
@@ -51,14 +40,14 @@ public class EventOccurrenceParser
|
||||
{
|
||||
// Normalize input: trim and normalize hyphens (en-dash, em-dash -> regular hyphen)
|
||||
// This allows the grammar parser to assume normalized input
|
||||
var normalizedLine = SanitizeInput(line.Trim());
|
||||
var normalizedLine = TextUtil.SanitizeInput(line.Trim());
|
||||
|
||||
// Skip empty lines
|
||||
if (string.IsNullOrWhiteSpace(normalizedLine))
|
||||
if (EventOccurrenceParsers.LineClassifier.IsEmptyLine(normalizedLine))
|
||||
continue;
|
||||
|
||||
// Skip comment lines (starting with "#") - use grammar parser
|
||||
if (EventOccurrenceGrammar.IsCommentLine(normalizedLine))
|
||||
if (EventOccurrenceParsers.LineClassifier.IsCommentLine(normalizedLine))
|
||||
continue;
|
||||
|
||||
// Try to parse occurrence line using grammar parser
|
||||
@@ -73,20 +62,16 @@ public class EventOccurrenceParser
|
||||
var (eventNamePart, schoolLevel) = sectionHeader.Value;
|
||||
|
||||
// Use fuzzy matching to find the best matching event definition
|
||||
var evt =
|
||||
(from e in _events
|
||||
let rat = Fuzz.Ratio(e.Name, eventNamePart)
|
||||
where rat > 50
|
||||
orderby rat descending
|
||||
select e).FirstOrDefault();
|
||||
var evt = EventOccurrenceParsers.SectionHeaderMatcher.MatchEventDefinition(eventNamePart, _events);
|
||||
if (evt == null)
|
||||
{
|
||||
var bestRatio = EventOccurrenceParsers.SectionHeaderMatcher.GetBestMatchRatio(eventNamePart, _events);
|
||||
issues.Add(new ParsingIssue
|
||||
{
|
||||
LineNumber = index,
|
||||
LineContent = normalizedLine,
|
||||
IssueType = ParsingIssueType.UnmatchedLine,
|
||||
Message = $"Section header '{eventNamePart} - {schoolLevel}' found but no matching event definition (best match ratio: {Fuzz.Ratio(eventNamePart, _events.FirstOrDefault()?.Name ?? "")})"
|
||||
Message = $"Section header '{eventNamePart} - {schoolLevel}' found but no matching event definition (best match ratio: {bestRatio})"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -95,29 +80,25 @@ public class EventOccurrenceParser
|
||||
}
|
||||
|
||||
// Check for General Schedule/Session using grammar parser
|
||||
if (EventOccurrenceGrammar.IsGeneralSchedule(normalizedLine))
|
||||
if (EventOccurrenceParsers.SectionHeaderMatcher.IsGeneralSchedule(normalizedLine))
|
||||
{
|
||||
currentEventDefinition = EventDefinition.GeneralSchedule;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also check for simple "MS" or "HS" in line (backward compatibility)
|
||||
if (normalizedLine.Contains("MS") || normalizedLine.Contains("HS"))
|
||||
if (EventOccurrenceParsers.SectionHeaderMatcher.HasSchoolLevel(normalizedLine))
|
||||
{
|
||||
var evt =
|
||||
(from e in _events
|
||||
let rat = Fuzz.Ratio(e.Name, normalizedLine)
|
||||
where rat > 50
|
||||
orderby rat descending
|
||||
select e).FirstOrDefault();
|
||||
var evt = EventOccurrenceParsers.SectionHeaderMatcher.MatchEventDefinition(normalizedLine, _events);
|
||||
if (evt == null)
|
||||
{
|
||||
var bestRatio = EventOccurrenceParsers.SectionHeaderMatcher.GetBestMatchRatio(normalizedLine, _events);
|
||||
issues.Add(new ParsingIssue
|
||||
{
|
||||
LineNumber = index,
|
||||
LineContent = normalizedLine,
|
||||
IssueType = ParsingIssueType.UnmatchedLine,
|
||||
Message = $"Section header with 'MS' or 'HS' found but no matching event definition (best match ratio: {Fuzz.Ratio(normalizedLine, _events.FirstOrDefault()?.Name ?? "")})"
|
||||
Message = $"Section header with 'MS' or 'HS' found but no matching event definition (best match ratio: {bestRatio})"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -126,11 +107,7 @@ public class EventOccurrenceParser
|
||||
}
|
||||
|
||||
// Skip continuation lines (lines that look like they're continuing from previous line)
|
||||
// These are typically lines that:
|
||||
// - Start with lowercase or special characters (not event names)
|
||||
// - Are parenthetical notes like "(Semifinalists only)"
|
||||
// - Are informational text like "Schedule Posted on..."
|
||||
if (IsContinuationLine(normalizedLine))
|
||||
if (EventOccurrenceParsers.LineClassifier.IsContinuationLine(normalizedLine))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -157,7 +134,7 @@ public class EventOccurrenceParser
|
||||
@"(?<Weekday>Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s?$", "").Trim();
|
||||
|
||||
// Determine event definition based on occurrence name pattern or current section
|
||||
EventDefinition? eventDefinition = DetermineEventDefinition(occurrenceName, currentEventDefinition);
|
||||
EventDefinition? eventDefinition = EventOccurrenceParsers.EventDefinitionResolver.Resolve(occurrenceName, currentEventDefinition);
|
||||
|
||||
// Track issue if we can't determine the event definition
|
||||
if (eventDefinition == null)
|
||||
@@ -175,13 +152,28 @@ public class EventOccurrenceParser
|
||||
// timeAndLocation is already normalized (hyphens normalized) since normalizedLine was sanitized
|
||||
|
||||
// Parse time and location using configurable patterns
|
||||
var (time, location, locationParseSuccess) = ParseTimeAndLocation(timeAndLocation, index, normalizedLine, issues);
|
||||
EventOccurrenceParsers.TimeLocationParser.Parse(timeAndLocation, _locationConfig, out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Track location parsing failure if patterns are configured but none matched
|
||||
if (!locationParseSuccess && !string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
if (_locationConfig != null && _locationConfig.LocationPatterns.Any())
|
||||
{
|
||||
issues.Add(new ParsingIssue
|
||||
{
|
||||
LineNumber = index,
|
||||
LineContent = normalizedLine,
|
||||
IssueType = ParsingIssueType.LocationParseFailure,
|
||||
Message = $"Location '{location}' does not match any configured pattern"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse date
|
||||
DateOnly? startDate = null;
|
||||
try
|
||||
{
|
||||
startDate = ParseDate(month, dayOfMonthStr.ToString(), DateTime.Now.Year);
|
||||
startDate = TextUtil.ParseDate(month, dayOfMonthStr.ToString(), DateTime.Now.Year);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -199,7 +191,7 @@ public class EventOccurrenceParser
|
||||
TimeOnly? startTime = null;
|
||||
try
|
||||
{
|
||||
startTime = ParseStartTime(time);
|
||||
startTime = EventOccurrenceParsers.TimeParser.Parse(time);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -218,7 +210,7 @@ public class EventOccurrenceParser
|
||||
|
||||
var t = new DateTime(startDate.Value, startTime.Value);
|
||||
|
||||
var eventOccurrence = new EventOccurrence
|
||||
var eventOccurrence = new Core.Entities.EventOccurrence
|
||||
{
|
||||
Name = occurrenceName,
|
||||
StartTime = t,
|
||||
@@ -234,302 +226,4 @@ public class EventOccurrenceParser
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the EventDefinition for an occurrence based on its name pattern or current section context.
|
||||
/// </summary>
|
||||
private EventDefinition? DetermineEventDefinition(string occurrenceName, EventDefinition? currentEventDefinition)
|
||||
{
|
||||
// Check for special event name patterns first (regardless of current section)
|
||||
if (occurrenceName.Contains("Meet the Candidates Session", StringComparison.OrdinalIgnoreCase))
|
||||
return EventDefinition.MeetTheCandidates;
|
||||
|
||||
if (occurrenceName.Contains("Chapter Officer Meeting", StringComparison.OrdinalIgnoreCase))
|
||||
return EventDefinition.ChapterOfficerMeeting;
|
||||
|
||||
if (occurrenceName.Contains("Voting Delegate Meeting", StringComparison.OrdinalIgnoreCase))
|
||||
return EventDefinition.VotingDelegateMeeting;
|
||||
|
||||
// If we're in a General Schedule/Session section and no pattern matched, use GeneralSchedule
|
||||
if (currentEventDefinition == EventDefinition.GeneralSchedule)
|
||||
return EventDefinition.GeneralSchedule;
|
||||
|
||||
// If we have a current event definition from section header (e.g., regular events), use it
|
||||
if (currentEventDefinition != null)
|
||||
return currentEventDefinition;
|
||||
|
||||
// Cannot determine event definition
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a line is a continuation/wrapped line that should be skipped.
|
||||
/// </summary>
|
||||
private bool IsContinuationLine(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Skip parenthetical notes
|
||||
if (trimmed.StartsWith("(", StringComparison.Ordinal) && trimmed.EndsWith(")", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
// Skip lines that are clearly continuation text (start with lowercase, common continuation words)
|
||||
if (trimmed.Length > 0 && char.IsLower(trimmed[0]))
|
||||
{
|
||||
// Check if it starts with common continuation words
|
||||
var continuationPrefixes = new[] { "be ", "the ", "and ", "or ", "to ", "a ", "an ", "will ", "may ", "can " };
|
||||
foreach (var prefix in continuationPrefixes)
|
||||
{
|
||||
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip informational lines that don't contain dates/times
|
||||
if (trimmed.Contains("Schedule Posted", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Contains("Note:", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Contains("*Note:", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string SanitizeInput(string input)
|
||||
{
|
||||
|
||||
input = input.Replace("–", "-");
|
||||
input = input.Replace("—", "-");
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
private DateOnly ParseDate(string month, string dayOfMonth, int year)
|
||||
{
|
||||
// Use normalized MonthNames array from grammar
|
||||
var monthLower = month.ToLower();
|
||||
var monthIndex = Array.FindIndex(EventOccurrenceGrammar.MonthNames,
|
||||
m => m.ToLower() == monthLower);
|
||||
|
||||
if (monthIndex < 0)
|
||||
throw new ArgumentException($"Invalid month: {month}", nameof(month));
|
||||
|
||||
// Month index is 0-based, month number is 1-based
|
||||
int monthNum = monthIndex + 1;
|
||||
var day = int.Parse(dayOfMonth);
|
||||
return new DateOnly(year, monthNum, day);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses time and location from the timeAndLocation string using configurable location patterns.
|
||||
/// </summary>
|
||||
private (string time, string location, bool locationParseSuccess) ParseTimeAndLocation(
|
||||
string timeAndLocation,
|
||||
int lineNumber,
|
||||
string lineContent,
|
||||
List<ParsingIssue> issues)
|
||||
{
|
||||
var time = timeAndLocation;
|
||||
var location = string.Empty;
|
||||
var locationParseSuccess = false;
|
||||
|
||||
// First, try to separate time from location using the time regex
|
||||
var timeLocationMatch = _timeLocationRegex.Match(timeAndLocation);
|
||||
|
||||
if (timeLocationMatch.Success)
|
||||
{
|
||||
time = timeLocationMatch.Groups["Time"].Captures[0].Value.Trim();
|
||||
var locationPart = timeLocationMatch.Groups["Location"].Success
|
||||
? timeLocationMatch.Groups["Location"].Captures[0].Value.Trim()
|
||||
: string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(locationPart))
|
||||
{
|
||||
// Clean up location part - remove any remaining time components (e.g., "– 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C")
|
||||
locationPart = CleanLocationText(locationPart);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(locationPart))
|
||||
{
|
||||
// Try to match location using configurable patterns
|
||||
if (_locationConfig != null && _locationConfig.LocationPatterns.Any())
|
||||
{
|
||||
location = MatchLocationPattern(locationPart, _locationConfig.LocationPatterns);
|
||||
locationParseSuccess = !string.IsNullOrEmpty(location);
|
||||
|
||||
// If pattern matching failed but location part looks valid, try matching against cleaned version
|
||||
if (!locationParseSuccess && !string.IsNullOrWhiteSpace(locationPart))
|
||||
{
|
||||
// Some locations might not match because of extra whitespace or formatting
|
||||
// Try matching the location even if initial match failed
|
||||
var cleanedForMatching = locationPart.Trim();
|
||||
location = MatchLocationPattern(cleanedForMatching, _locationConfig.LocationPatterns);
|
||||
locationParseSuccess = !string.IsNullOrEmpty(location);
|
||||
if (locationParseSuccess)
|
||||
{
|
||||
location = cleanedForMatching; // Use the cleaned version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no pattern matched but we have a location, use it anyway but mark as not matching pattern
|
||||
// This allows parsing to continue while still tracking that the location didn't match a pattern
|
||||
if (!locationParseSuccess && !string.IsNullOrWhiteSpace(locationPart))
|
||||
{
|
||||
location = locationPart;
|
||||
// Only add issue if we have patterns configured but none matched
|
||||
// This helps identify locations that might need new patterns added
|
||||
if (_locationConfig != null && _locationConfig.LocationPatterns.Any())
|
||||
{
|
||||
issues.Add(new ParsingIssue
|
||||
{
|
||||
LineNumber = lineNumber,
|
||||
LineContent = lineContent,
|
||||
IssueType = ParsingIssueType.LocationParseFailure,
|
||||
Message = $"Location '{locationPart}' does not match any configured pattern"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No location part found, which is valid (some events might not have locations)
|
||||
locationParseSuccess = true; // Consider it a success since no location is needed
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// If time regex doesn't match, use the whole string as time
|
||||
time = timeAndLocation.Trim();
|
||||
}
|
||||
|
||||
return (time, location, locationParseSuccess || string.IsNullOrWhiteSpace(location));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans location text by removing any remaining time components.
|
||||
/// Handles cases like "– 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C"
|
||||
/// </summary>
|
||||
private string CleanLocationText(string locationText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(locationText))
|
||||
return string.Empty;
|
||||
|
||||
// Remove leading dashes and whitespace
|
||||
// Note: Input is normalized, so only regular hyphens need to be handled
|
||||
locationText = locationText.TrimStart('-', ' ', '\t');
|
||||
|
||||
// Try to match and remove time patterns at the start
|
||||
// Pattern 1: Dash, whitespace, time (e.g., "- 12:15 p.m. " or "- NOON ")
|
||||
// Note: Input is normalized, so only regular hyphens need to be handled
|
||||
var dashTimePattern = new Regex(@"^-\s+(?:NOON|\d{1,2}:?\d{0,2}\s*[AaPp]\.?[Mm]\.?)\s+", RegexOptions.IgnoreCase);
|
||||
locationText = dashTimePattern.Replace(locationText, "").Trim();
|
||||
|
||||
// Pattern 2: Time without dash at start (e.g., "12:15 p.m. " or "NOON ")
|
||||
var timePatternAtStart = new Regex(@"^(?:NOON|\d{1,2}:?\d{0,2}\s*[AaPp]\.?[Mm]\.?)\s+", RegexOptions.IgnoreCase);
|
||||
locationText = timePatternAtStart.Replace(locationText, "").Trim();
|
||||
|
||||
// Pattern 3: Any remaining dash-time combinations (more flexible)
|
||||
// Note: Input is normalized, so only regular hyphens need to be handled
|
||||
var remainingDashTime = new Regex(@"^-\s*(?:NOON|\d{1,2}:?\d{0,2}\s*[AaPp]\.?[Mm]\.?)\s*", RegexOptions.IgnoreCase);
|
||||
locationText = remainingDashTime.Replace(locationText, "").Trim();
|
||||
|
||||
// Pattern 4: Remove any standalone time at the start (handles cases where dash was already removed)
|
||||
var standaloneTime = new Regex(@"^(?:NOON|\d{1,2}:?\d{0,2}\s*[AaPp]\.?[Mm]\.?)$", RegexOptions.IgnoreCase);
|
||||
if (standaloneTime.IsMatch(locationText))
|
||||
return string.Empty; // If only time remains, there's no location
|
||||
|
||||
return locationText.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches location text against configured patterns and returns the matched location.
|
||||
/// </summary>
|
||||
private string MatchLocationPattern(string locationText, List<string> patterns)
|
||||
{
|
||||
// Normalize location text for matching (trim and handle variations)
|
||||
var normalizedLocation = locationText.Trim();
|
||||
|
||||
// If location is empty after normalization, return empty
|
||||
if (string.IsNullOrWhiteSpace(normalizedLocation))
|
||||
return string.Empty;
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
var normalizedPattern = pattern.Trim();
|
||||
|
||||
// Skip empty patterns
|
||||
if (string.IsNullOrWhiteSpace(normalizedPattern))
|
||||
continue;
|
||||
|
||||
// Handle exact matches (patterns without wildcards like "Online", "Virtual", "TBD")
|
||||
if (!normalizedPattern.Contains('*'))
|
||||
{
|
||||
if (string.Equals(normalizedPattern, normalizedLocation, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalizedLocation;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert pattern to regex: escape special chars, replace * with .*
|
||||
// This handles patterns like "Exhibit Hall *", "Room *", "Mtg. Room *", etc.
|
||||
var escapedPattern = Regex.Escape(normalizedPattern);
|
||||
escapedPattern = escapedPattern.Replace(@"\*", ".*?");
|
||||
|
||||
// Use case-insensitive matching
|
||||
var regex = new Regex($"^{escapedPattern}$", RegexOptions.IgnoreCase);
|
||||
if (regex.IsMatch(normalizedLocation))
|
||||
{
|
||||
return normalizedLocation; // Return the full matched location
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private TimeOnly ParseStartTime(string time)
|
||||
{
|
||||
int hour = 0;
|
||||
int minute = 0;
|
||||
|
||||
// Handle TBD (To Be Determined) times gracefully
|
||||
if (string.Equals(time.Trim(), "TBD", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Use a placeholder time (midnight) for TBD - the occurrence will still be created
|
||||
// but with a time that indicates it's TBD
|
||||
return new TimeOnly(0, 0, 0);
|
||||
}
|
||||
|
||||
// get the part of the time before a timespan
|
||||
if (time.Contains(" - "))
|
||||
{
|
||||
time = time[..time.IndexOf(" - ", StringComparison.Ordinal)];
|
||||
|
||||
}
|
||||
|
||||
if (time == "NOON")
|
||||
hour = 12;
|
||||
else
|
||||
{
|
||||
// Regex is case-insensitive, so ToLower() is not needed
|
||||
var timeMatch = _timeRe.Match(time);
|
||||
if (timeMatch.Success)
|
||||
{
|
||||
hour = int.Parse(timeMatch.Groups["Hour"].Captures[0].Value);
|
||||
if (timeMatch.Groups["Minute"].Success)
|
||||
{
|
||||
minute = int.Parse(timeMatch.Groups["Minute"].Captures[0].Value);
|
||||
}
|
||||
|
||||
if (timeMatch.Groups["APM"].Captures[0].Value is "p.m." or "pm" && hour < 12)
|
||||
hour += 12;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new FormatException($"Time format not recognized: {time}");
|
||||
}
|
||||
}
|
||||
|
||||
return new TimeOnly(hour, minute, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,48 @@
|
||||
namespace Core.Utility;
|
||||
using Core.Parsers;
|
||||
|
||||
namespace Core.Utility;
|
||||
|
||||
public static class TextUtil
|
||||
{
|
||||
/// <summary>
|
||||
/// Sanitizes input by normalizing hyphens (en-dash, em-dash -> regular hyphen).
|
||||
/// This allows parsers to assume normalized input.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string to sanitize.</param>
|
||||
/// <returns>The sanitized string with normalized hyphens.</returns>
|
||||
public static string SanitizeInput(string input)
|
||||
{
|
||||
input = input.Replace("–", "-"); // en-dash
|
||||
input = input.Replace("—", "-"); // em-dash
|
||||
return input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a date from month name, day of month, and year.
|
||||
/// </summary>
|
||||
/// <param name="month">The month name (e.g., "January", "February"). Case-insensitive.</param>
|
||||
/// <param name="dayOfMonth">The day of the month as a string (e.g., "15", "3").</param>
|
||||
/// <param name="year">The year (e.g., 2025).</param>
|
||||
/// <returns>A <see cref="DateOnly"/> representing the parsed date.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the month name is invalid.</exception>
|
||||
/// <exception cref="FormatException">Thrown when the day of month cannot be parsed as an integer.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when the resulting date is invalid (e.g., February 30).</exception>
|
||||
public static DateOnly ParseDate(string month, string dayOfMonth, int year)
|
||||
{
|
||||
// Use normalized MonthNames array from grammar
|
||||
var monthLower = month.ToLower();
|
||||
var monthIndex = Array.FindIndex(EventOccurrenceGrammar.MonthNames,
|
||||
m => m.ToLower() == monthLower);
|
||||
|
||||
if (monthIndex < 0)
|
||||
throw new ArgumentException($"Invalid month: {month}", nameof(month));
|
||||
|
||||
// Month index is 0-based, month number is 1-based
|
||||
int monthNum = monthIndex + 1;
|
||||
var day = int.Parse(dayOfMonth);
|
||||
return new DateOnly(year, monthNum, day);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the ordinal value of positive integers.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using Core.Entities;
|
||||
using Core.Parsers.EventOccurrence;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.Parsers.EventOccurrence;
|
||||
|
||||
[TestFixture]
|
||||
public class EventDefinitionResolver_Tests
|
||||
{
|
||||
[Test]
|
||||
public void Resolve_SpecialEventPattern_MeetTheCandidates_ReturnsCorrectDefinition()
|
||||
{
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Meet the Candidates Session 1", null);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(EventDefinition.MeetTheCandidates));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_SpecialEventPattern_ChapterOfficerMeeting_ReturnsCorrectDefinition()
|
||||
{
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Chapter Officer Meeting - MS", null);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(EventDefinition.ChapterOfficerMeeting));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_SpecialEventPattern_VotingDelegateMeeting_ReturnsCorrectDefinition()
|
||||
{
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Voting Delegate Meeting", null);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(EventDefinition.VotingDelegateMeeting));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_SpecialEventPattern_CaseInsensitive_Works()
|
||||
{
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("MEET THE CANDIDATES", null);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(EventDefinition.MeetTheCandidates));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_GeneralSchedule_CurrentEventGeneralSchedule_ReturnsGeneralSchedule()
|
||||
{
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Some Event Name", EventDefinition.GeneralSchedule);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(EventDefinition.GeneralSchedule));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_CurrentEventDefinition_ReturnsCurrentEvent()
|
||||
{
|
||||
// Arrange
|
||||
var currentEvent = new EventDefinition { Name = "Test Event" };
|
||||
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Some Occurrence", currentEvent);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(currentEvent));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_SpecialEventPattern_TakesPrecedenceOverCurrentEvent()
|
||||
{
|
||||
// Arrange
|
||||
var currentEvent = new EventDefinition { Name = "Test Event" };
|
||||
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Meet the Candidates Session 1", currentEvent);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(EventDefinition.MeetTheCandidates));
|
||||
Assert.That(result, Is.Not.EqualTo(currentEvent));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_NoMatch_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Unknown Event Name", null);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_NoMatch_CurrentEventNull_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = EventDefinitionResolver.Resolve("Unknown Event Name", null);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using Core.Parsers.EventOccurrence;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.Parsers.EventOccurrence;
|
||||
|
||||
[TestFixture]
|
||||
public class LineClassifier_Tests
|
||||
{
|
||||
[Test]
|
||||
public void IsEmptyLine_EmptyString_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsEmptyLine(""), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsEmptyLine_WhitespaceOnly_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsEmptyLine(" "), Is.True);
|
||||
Assert.That(LineClassifier.IsEmptyLine("\t\n"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsEmptyLine_NonEmpty_ReturnsFalse()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsEmptyLine("Some text"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsCommentLine_StartsWithHash_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsCommentLine("# This is a comment"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsCommentLine_NoHash_ReturnsFalse()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsCommentLine("Not a comment"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsContinuationLine_ParentheticalNote_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsContinuationLine("(Semifinalists only)"), Is.True);
|
||||
Assert.That(LineClassifier.IsContinuationLine("(Note: Some information)"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsContinuationLine_StartsWithLowercaseContinuationWord_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsContinuationLine("the event will be held"), Is.True);
|
||||
Assert.That(LineClassifier.IsContinuationLine("and participants should arrive"), Is.True);
|
||||
Assert.That(LineClassifier.IsContinuationLine("be sure to register"), Is.True);
|
||||
Assert.That(LineClassifier.IsContinuationLine("or contact the coordinator"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsContinuationLine_StartsWithLowercase_NonContinuationWord_ReturnsFalse()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsContinuationLine("important: bring materials"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsContinuationLine_StartsWithUppercase_ReturnsFalse()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsContinuationLine("Important Event March 15"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsContinuationLine_ContainsSchedulePosted_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsContinuationLine("Schedule Posted on website"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsContinuationLine_ContainsNote_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsContinuationLine("Note: Additional information"), Is.True);
|
||||
Assert.That(LineClassifier.IsContinuationLine("*Note: Important details"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsContinuationLine_RegularEventLine_ReturnsFalse()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(LineClassifier.IsContinuationLine("Test Event March 15 3:00 p.m. Room A"), Is.False);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using Core.Parsers.EventOccurrence;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.Parsers.EventOccurrence;
|
||||
|
||||
[TestFixture]
|
||||
public class LocationPatternMatcher_Tests
|
||||
{
|
||||
[Test]
|
||||
public void Match_ExactMatch_ReturnsLocation()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room A", "Room B", "Hall C" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Room A", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Room A"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_ExactMatch_CaseInsensitive_ReturnsLocation()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room A" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("room a", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("room a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_WildcardPattern_Matches_ReturnsLocation()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room *", "Hall *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Room 101", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Room 101"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_WildcardPattern_MultipleMatches_ReturnsFirstMatch()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room *", "Exhibit Hall *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Room 202", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Room 202"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_WildcardPattern_ExhibitHall_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Exhibit Hall *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Exhibit Hall C", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_WildcardPattern_MtgRoom_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Mtg. Room *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Mtg. Room 14", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Mtg. Room 14"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room *", "Hall *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Unknown Location", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_EmptyLocation_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_WhitespaceLocation_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match(" ", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_EmptyPatterns_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string>();
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Room A", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_PatternWithSpecialCharacters_EscapesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room (A)" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match("Room (A)", patterns);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Room (A)"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_LocationWithWhitespace_Trims_ReturnsLocation()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new List<string> { "Room *" };
|
||||
|
||||
// Act
|
||||
var result = LocationPatternMatcher.Match(" Room 101 ", patterns);
|
||||
|
||||
// Assert
|
||||
// LocationPatternMatcher returns the matched location after normalization (trim)
|
||||
Assert.That(result, Is.EqualTo("Room 101"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Core.Entities;
|
||||
using Core.Parsers.EventOccurrence;
|
||||
using FuzzySharp;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.Parsers.EventOccurrence;
|
||||
|
||||
[TestFixture]
|
||||
public class SectionHeaderMatcher_Tests
|
||||
{
|
||||
[Test]
|
||||
public void IsGeneralSchedule_GeneralScheduleLine_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(SectionHeaderMatcher.IsGeneralSchedule("General Schedule"), Is.True);
|
||||
Assert.That(SectionHeaderMatcher.IsGeneralSchedule("General Schedule - MS"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsGeneralSchedule_RegularEvent_ReturnsFalse()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(SectionHeaderMatcher.IsGeneralSchedule("Test Event - MS"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasSchoolLevel_ContainsMS_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event - MS"), Is.True);
|
||||
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event MS"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasSchoolLevel_ContainsHS_ReturnsTrue()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event - HS"), Is.True);
|
||||
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event HS"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HasSchoolLevel_NoSchoolLevel_ReturnsFalse()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event"), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MatchEventDefinition_ExactMatch_ReturnsEvent()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<EventDefinition>
|
||||
{
|
||||
new() { Name = "Test Event" },
|
||||
new() { Name = "Another Event" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = SectionHeaderMatcher.MatchEventDefinition("Test Event", events);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result!.Name, Is.EqualTo("Test Event"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MatchEventDefinition_CloseMatch_ReturnsEvent()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<EventDefinition>
|
||||
{
|
||||
new() { Name = "Test Event" },
|
||||
new() { Name = "Another Event" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = SectionHeaderMatcher.MatchEventDefinition("Test Evnt", events); // Typo but close
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result!.Name, Is.EqualTo("Test Event"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MatchEventDefinition_NoMatch_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<EventDefinition>
|
||||
{
|
||||
new() { Name = "Test Event" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = SectionHeaderMatcher.MatchEventDefinition("Completely Different Event", events);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MatchEventDefinition_EmptyEvents_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<EventDefinition>();
|
||||
|
||||
// Act
|
||||
var result = SectionHeaderMatcher.MatchEventDefinition("Test Event", events);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MatchEventDefinition_MultipleMatches_ReturnsBestMatch()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<EventDefinition>
|
||||
{
|
||||
new() { Name = "Test Event" },
|
||||
new() { Name = "Test Event Advanced" },
|
||||
new() { Name = "Another Event" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = SectionHeaderMatcher.MatchEventDefinition("Test Event", events);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result!.Name, Is.EqualTo("Test Event")); // Exact match should win
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetBestMatchRatio_ValidEvents_ReturnsRatio()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<EventDefinition>
|
||||
{
|
||||
new() { Name = "Test Event" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var ratio = SectionHeaderMatcher.GetBestMatchRatio("Test Event", events);
|
||||
|
||||
// Assert
|
||||
Assert.That(ratio, Is.GreaterThan(50)); // Should be high for exact match
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetBestMatchRatio_EmptyEvents_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var events = new List<EventDefinition>();
|
||||
|
||||
// Act
|
||||
var ratio = SectionHeaderMatcher.GetBestMatchRatio("Test Event", events);
|
||||
|
||||
// Assert
|
||||
Assert.That(ratio, Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using Core.Models;
|
||||
using Core.Parsers.EventOccurrence;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.Parsers.EventOccurrence;
|
||||
|
||||
[TestFixture]
|
||||
public class TimeLocationParser_Tests
|
||||
{
|
||||
[Test]
|
||||
public void Parse_TimeAndLocation_ExtractsBoth()
|
||||
{
|
||||
// Arrange
|
||||
var locationConfig = new LocationParsingConfiguration
|
||||
{
|
||||
LocationPatterns = new List<string> { "Room *" }
|
||||
};
|
||||
|
||||
// Act
|
||||
TimeLocationParser.Parse("10:30 a.m. Room 101", locationConfig,
|
||||
out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Assert
|
||||
Assert.That(time, Is.EqualTo("10:30 a.m."));
|
||||
Assert.That(location, Is.EqualTo("Room 101"));
|
||||
Assert.That(locationParseSuccess, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TimeRangeAndLocation_ExtractsTimeRangeAndLocation()
|
||||
{
|
||||
// Arrange
|
||||
var locationConfig = new LocationParsingConfiguration
|
||||
{
|
||||
LocationPatterns = new List<string> { "Room *" }
|
||||
};
|
||||
|
||||
// Act
|
||||
TimeLocationParser.Parse("10:00 a.m. - 12:00 p.m. Room 202", locationConfig,
|
||||
out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Assert
|
||||
Assert.That(time, Is.EqualTo("10:00 a.m. - 12:00 p.m."));
|
||||
Assert.That(location, Is.EqualTo("Room 202"));
|
||||
Assert.That(locationParseSuccess, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_NOONAndLocation_ExtractsBoth()
|
||||
{
|
||||
// Arrange
|
||||
var locationConfig = new LocationParsingConfiguration
|
||||
{
|
||||
LocationPatterns = new List<string> { "Hall *" }
|
||||
};
|
||||
|
||||
// Act
|
||||
TimeLocationParser.Parse("NOON Hall C", locationConfig,
|
||||
out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Assert
|
||||
Assert.That(time, Is.EqualTo("NOON"));
|
||||
Assert.That(location, Is.EqualTo("Hall C"));
|
||||
Assert.That(locationParseSuccess, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TimeOnly_NoLocation()
|
||||
{
|
||||
// Arrange
|
||||
var locationConfig = new LocationParsingConfiguration
|
||||
{
|
||||
LocationPatterns = new List<string> { "Room *" }
|
||||
};
|
||||
|
||||
// Act
|
||||
TimeLocationParser.Parse("3:00 p.m.", locationConfig,
|
||||
out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Assert
|
||||
Assert.That(time, Is.EqualTo("3:00 p.m."));
|
||||
Assert.That(location, Is.Empty);
|
||||
Assert.That(locationParseSuccess, Is.True); // No location is valid
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_LocationNotMatchingPattern_StillReturnsLocation_ReportsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var locationConfig = new LocationParsingConfiguration
|
||||
{
|
||||
LocationPatterns = new List<string> { "Room *" }
|
||||
};
|
||||
|
||||
// Act
|
||||
TimeLocationParser.Parse("10:00 a.m. Unknown Location", locationConfig,
|
||||
out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Assert
|
||||
Assert.That(time, Is.EqualTo("10:00 a.m."));
|
||||
Assert.That(location, Is.EqualTo("Unknown Location"));
|
||||
Assert.That(locationParseSuccess, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_LocationWithTimeComponent_CleansTimeComponent()
|
||||
{
|
||||
// Arrange
|
||||
var locationConfig = new LocationParsingConfiguration
|
||||
{
|
||||
LocationPatterns = new List<string> { "Exhibit Hall *" }
|
||||
};
|
||||
|
||||
// Act
|
||||
TimeLocationParser.Parse("10:00 a.m. - 12:15 p.m. Exhibit Hall C", locationConfig,
|
||||
out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Assert
|
||||
Assert.That(time, Is.EqualTo("10:00 a.m. - 12:15 p.m."));
|
||||
Assert.That(location, Is.EqualTo("Exhibit Hall C"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_NoLocationConfig_StillExtractsTimeAndLocation()
|
||||
{
|
||||
// Act
|
||||
TimeLocationParser.Parse("3:00 p.m. Room A", null,
|
||||
out string time, out string location, out bool locationParseSuccess);
|
||||
|
||||
// Assert
|
||||
Assert.That(time, Is.EqualTo("3:00 p.m."));
|
||||
Assert.That(location, Is.EqualTo("Room A"));
|
||||
Assert.That(locationParseSuccess, Is.False); // No patterns to match against
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanLocationText_RemovesTimeAtStart()
|
||||
{
|
||||
// Act
|
||||
var result = TimeLocationParser.CleanLocationText("- 12:15 p.m. Exhibit Hall C");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanLocationText_RemovesTimeWithoutDash()
|
||||
{
|
||||
// Act
|
||||
var result = TimeLocationParser.CleanLocationText("12:15 p.m. Exhibit Hall C");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanLocationText_RemovesNOONAtStart()
|
||||
{
|
||||
// Act
|
||||
var result = TimeLocationParser.CleanLocationText("- NOON Exhibit Hall C");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanLocationText_OnlyTime_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = TimeLocationParser.CleanLocationText("12:15 p.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanLocationText_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = TimeLocationParser.CleanLocationText("");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanLocationText_WhitespaceOnly_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = TimeLocationParser.CleanLocationText(" ");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanLocationText_NoTimeComponent_ReturnsOriginal()
|
||||
{
|
||||
// Act
|
||||
var result = TimeLocationParser.CleanLocationText("Exhibit Hall C");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using Core.Parsers.EventOccurrence;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.Parsers.EventOccurrence;
|
||||
|
||||
[TestFixture]
|
||||
public class TimeParser_Tests
|
||||
{
|
||||
[Test]
|
||||
public void Parse_NOON_Returns12PM()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("NOON");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(12, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TBD_ReturnsMidnight()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("TBD");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(0, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TBD_CaseInsensitive_ReturnsMidnight()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("tbd");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(0, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_AMTime_ReturnsCorrectTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("10:30 a.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(10, 30, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_PMTime_ReturnsCorrectTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("3:45 p.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(15, 45, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TimeRange_ExtractsStartTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("10:00 a.m. - 12:00 p.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(10, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TimeRangeWithNOON_ExtractsStartTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("10:30 a.m. - NOON");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(10, 30, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TimeWithoutMinutes_ReturnsCorrectTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("3 p.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(15, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TimeWithoutColon_ReturnsCorrectTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("1030 a.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(10, 30, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_12PM_Returns12PM_NotMidnight()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("12:00 p.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(12, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_12AM_ReturnsMidnight()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.Parse("12:00 a.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new TimeOnly(0, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_InvalidFormat_ThrowsFormatException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<FormatException>(() => TimeParser.Parse("invalid time format"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtractStartTime_Range_ReturnsStartTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.ExtractStartTime("10:00 a.m. - 12:00 p.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("10:00 a.m."));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtractStartTime_NoRange_ReturnsOriginal()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.ExtractStartTime("3:00 p.m.");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("3:00 p.m."));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtractStartTime_RangeWithNOON_ReturnsStartTime()
|
||||
{
|
||||
// Act
|
||||
var result = TimeParser.ExtractStartTime("10:30 a.m. - NOON");
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo("10:30 a.m."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using Core.Utility;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.Utility;
|
||||
|
||||
[TestFixture]
|
||||
public class TextUtil_Tests
|
||||
{
|
||||
[Test]
|
||||
public void ParseDate_ValidInput_ReturnsCorrectDate()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TextUtil.ParseDate("January", "15", 2025);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_AllMonths_AreSupported()
|
||||
{
|
||||
// Arrange
|
||||
var months = new[] { "January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December" };
|
||||
var expectedDates = new[]
|
||||
{
|
||||
new DateOnly(2025, 1, 15),
|
||||
new DateOnly(2025, 2, 15),
|
||||
new DateOnly(2025, 3, 15),
|
||||
new DateOnly(2025, 4, 15),
|
||||
new DateOnly(2025, 5, 15),
|
||||
new DateOnly(2025, 6, 15),
|
||||
new DateOnly(2025, 7, 15),
|
||||
new DateOnly(2025, 8, 15),
|
||||
new DateOnly(2025, 9, 15),
|
||||
new DateOnly(2025, 10, 15),
|
||||
new DateOnly(2025, 11, 15),
|
||||
new DateOnly(2025, 12, 15),
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
for (int i = 0; i < months.Length; i++)
|
||||
{
|
||||
var result = TextUtil.ParseDate(months[i], "15", 2025);
|
||||
Assert.That(result, Is.EqualTo(expectedDates[i]),
|
||||
$"Month {months[i]} should parse correctly");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_CaseInsensitive_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result1 = TextUtil.ParseDate("JANUARY", "15", 2025);
|
||||
var result2 = TextUtil.ParseDate("january", "15", 2025);
|
||||
var result3 = TextUtil.ParseDate("JaNuArY", "15", 2025);
|
||||
|
||||
// Assert
|
||||
Assert.That(result1, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||
Assert.That(result2, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||
Assert.That(result3, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_InvalidMonth_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => TextUtil.ParseDate("InvalidMonth", "15", 2025));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_InvalidDay_ThrowsFormatException()
|
||||
{
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<FormatException>(() => TextUtil.ParseDate("January", "abc", 2025));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_InvalidDate_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
// Arrange, Act & Assert - February 30 doesn't exist
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => TextUtil.ParseDate("February", "30", 2025));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_LeapYear_February29_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TextUtil.ParseDate("February", "29", 2024); // 2024 is a leap year
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new DateOnly(2024, 2, 29)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_NonLeapYear_February29_Throws()
|
||||
{
|
||||
// Arrange, Act & Assert - 2025 is not a leap year
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => TextUtil.ParseDate("February", "29", 2025));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_SingleDigitDay_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TextUtil.ParseDate("March", "3", 2025);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new DateOnly(2025, 3, 3)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_DifferentYears_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result2024 = TextUtil.ParseDate("January", "1", 2024);
|
||||
var result2025 = TextUtil.ParseDate("January", "1", 2025);
|
||||
var result2026 = TextUtil.ParseDate("January", "1", 2026);
|
||||
|
||||
// Assert
|
||||
Assert.That(result2024, Is.EqualTo(new DateOnly(2024, 1, 1)));
|
||||
Assert.That(result2025, Is.EqualTo(new DateOnly(2025, 1, 1)));
|
||||
Assert.That(result2026, Is.EqualTo(new DateOnly(2026, 1, 1)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_FirstDayOfMonth_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TextUtil.ParseDate("December", "1", 2025);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new DateOnly(2025, 12, 1)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseDate_LastDayOfMonth_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = TextUtil.ParseDate("January", "31", 2025);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.EqualTo(new DateOnly(2025, 1, 31)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user