Update EventOccurrence parsing to use EventOccurrenceParseGroup for improved data structure

This commit refactors the EventOccurrence parsing logic to utilize the EventOccurrenceParseGroup class, enhancing the organization of parsed occurrences by grouping them based on event definitions and optional section levels. The changes include updates to the EventOccurrenceParseResult, EventOccurrenceParser, and EventOccurrenceParserService to accommodate the new grouping structure. Additionally, tests are modified to reflect these changes, ensuring that the parsing functionality remains intact and accurate. This refactor improves data handling and aligns with the overall architecture of the application.
This commit is contained in:
2026-04-04 21:55:44 -04:00
parent 4dcd9e5aab
commit f400813667
39 changed files with 1896 additions and 93 deletions
@@ -0,0 +1,28 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class GlobalEventDeduplicatorTests
{
[Test]
public void Curfew_same_time_multiple_locations_becomes_one_line_without_location()
{
var lines = new List<ParsedOccurrenceLine>
{
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room A", 10, 10, 1),
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room B", 10, 10, 2),
new("Meeting", "April", 9, "9 a.m. - 10 a.m.", "Room A", 5, 5, 1)
};
var result = GlobalEventDeduplicator.Deduplicate(lines);
Assert.Multiple(() =>
{
Assert.That(result.Count, Is.EqualTo(2));
var curfew = result.Single(l => l.Name.Equals("CURFEW", StringComparison.OrdinalIgnoreCase));
Assert.That(curfew.Location, Is.Empty);
Assert.That(result.Any(l => l.Name == "Meeting"), Is.True);
});
}
}
@@ -0,0 +1,40 @@
using Core.Entities;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class ImportTextEmitterRoundTripTests
{
[Test]
public void EmittedText_Parses_UnderGeneralSchedule()
{
var sheets = new List<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)>
{
("Thursday", "General Schedule", new List<ParsedOccurrenceLine>
{
new(
Name: "Opening Ceremony",
Month: "April",
Day: 3,
TimeRange: "9 a.m. - 10 a.m.",
Location: "Main Hall",
SourceRowStart: 1,
SourceRowEnd: 1,
SourceCol: 1)
})
};
var text = ImportTextEmitter.Build(sheets);
var result = ParserRoundTripValidator.Validate(text, new List<EventDefinition>
{
EventDefinition.GeneralSchedule
});
Assert.Multiple(() =>
{
Assert.That(result.IsSuccess, Is.True, string.Join("; ", result.Errors));
Assert.That(result.TotalParsed, Is.EqualTo(1));
});
}
}
@@ -0,0 +1,29 @@
using Core.Entities;
using Core.Models;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class OccurrenceDisplayNameReducerTests
{
private static EventDefinition Cyber() =>
new()
{
Id = 1,
Name = "Cybersecurity",
ShortName = "Cyber",
Eligibility = "",
EventFormat = EventFormat.Team
};
[Test]
public void Strips_ms_prefix_and_event_name()
{
var n = OccurrenceDisplayNameReducer.ReduceForSection(
"MS Cybersecurity Semifinals Presentations",
Cyber(),
SchoolLevel.MiddleSchool);
Assert.That(n, Is.EqualTo("Semifinals Presentations"));
}
}
@@ -0,0 +1,39 @@
using Core.Entities;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class OccurrenceEventMatcherTests
{
private static EventDefinition E(string name, int id) =>
new()
{
Id = id,
Name = name,
ShortName = name,
Eligibility = "",
EventFormat = EventFormat.Team
};
[Test]
public void Ms_prefix_matches_Biotechnology()
{
var events = new List<EventDefinition> { E("Biotechnology", 1), E("Biotechnology Design", 2) };
var ok = OccurrenceEventMatcher.TryMatch("MS Biotechnology Semifinals Interviews April ...", events, out var evt, out var lvl);
Assert.Multiple(() =>
{
Assert.That(ok, Is.True);
Assert.That(evt!.Name, Is.EqualTo("Biotechnology"));
Assert.That(lvl, Is.EqualTo(Core.Models.SchoolLevel.MiddleSchool));
});
}
[Test]
public void No_Clear_prefix_goes_unmatched_or_general_bucket()
{
var events = new List<EventDefinition> { E("Opening Session", 1) };
var ok = OccurrenceEventMatcher.TryMatch("Opening Session April 10 9 a.m.", events, out var evt, out var lvl);
Assert.That(ok, Is.False);
}
}
@@ -0,0 +1,42 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class ScheduleGridExtractorTests
{
[Test]
public void Extract_SingleBlock_OneOccurrenceWithEndTimeFromNextSlot()
{
string?[][] values =
[
[null, "Main Hall"],
["9:00 a.m.", "Opening Ceremony"],
["10:00 a.m.", null]
];
string?[][] bg =
[
[null, null],
[null, null],
[null, null]
];
var grid = new GridSheetModel
{
SheetTitle = "Day1",
Values = values,
BackgroundKeys = bg
};
var warnings = new List<string>();
var lines = ScheduleGridExtractor.Extract(grid, "April", 2, warnings);
Assert.Multiple(() =>
{
Assert.That(lines, Has.Count.EqualTo(1));
Assert.That(lines[0].Name, Is.EqualTo("Opening Ceremony"));
Assert.That(lines[0].Month, Is.EqualTo("April"));
Assert.That(lines[0].Day, Is.EqualTo(2));
Assert.That(lines[0].Location, Is.EqualTo("Main Hall"));
Assert.That(lines[0].TimeRange, Is.EqualTo("9 a.m. - 10 a.m."));
});
}
}
@@ -0,0 +1,14 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class TextNormalizationTests
{
[Test]
public void Collapses_line_separator_and_newlines()
{
var s = "Banquet Room\u2028E";
Assert.That(TextNormalization.ForSheetCell(s), Is.EqualTo("Banquet Room E"));
}
}
@@ -226,9 +226,10 @@ public class EventOccurrenceParserIssues_Tests
// Verify successful occurrence is still parsed (if any valid lines exist)
// The "Valid Event" line should parse successfully despite other issues
var validEvent = events.First(e => e.Name == "Valid Event");
if (result.Occurrences.ContainsKey(validEvent))
var validGroup = new EventOccurrenceParseGroup(validEvent, null);
if (result.Occurrences.ContainsKey(validGroup))
{
Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1));
Assert.That(result.Occurrences[validGroup], Has.Count.EqualTo(1));
}
// Note: It's acceptable if the valid event doesn't parse if there are critical issues,
// but typically it should still parse since it's a valid line
@@ -350,11 +351,12 @@ public class EventOccurrenceParserIssues_Tests
// Verify occurrences were parsed correctly (if they were parsed)
var testEvent = events.First(e => e.Name == "Test Event");
if (result.Occurrences.ContainsKey(testEvent))
var testGroup = new EventOccurrenceParseGroup(testEvent, null);
if (result.Occurrences.ContainsKey(testGroup))
{
Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1));
Assert.That(result.Occurrences[testGroup], Has.Count.EqualTo(1));
var occurrence = result.Occurrences[testEvent].First();
var occurrence = result.Occurrences[testGroup].First();
Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
}
@@ -362,8 +364,8 @@ public class EventOccurrenceParserIssues_Tests
// The important thing is that the parser doesn't crash and processes the input
// Verify locations are extracted correctly (pattern matching is no longer used)
var testEventOccurrence = result.Occurrences.ContainsKey(testEvent)
? result.Occurrences[testEvent].FirstOrDefault()
var testEventOccurrence = result.Occurrences.ContainsKey(testGroup)
? result.Occurrences[testGroup].FirstOrDefault()
: null;
if (testEventOccurrence != null)
{
@@ -412,10 +414,11 @@ public class EventOccurrenceParserIssues_Tests
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
// General Schedule section uses EventDefinition.GeneralSchedule
Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule),
$"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}");
var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null);
Assert.That(result.Occurrences, Does.ContainKey(gsGroup),
$"Result should contain GeneralSchedule. Found groups: {string.Join(", ", result.Occurrences.Keys.Select(k => k.EventDefinition.Name))}");
var occurrences = result.Occurrences[EventDefinition.GeneralSchedule];
var occurrences = result.Occurrences[gsGroup];
Assert.That(occurrences, Has.Count.GreaterThan(0),
"Should have at least one occurrence in General Schedule");
@@ -501,7 +504,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0]));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
@@ -528,7 +531,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0]));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
@@ -554,7 +557,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0]));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
@@ -580,7 +583,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0]));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
+84 -59
View File
@@ -107,17 +107,24 @@ public class EventOccurrenceParser_Tests
/// <summary>
/// Writes special events summary to console.
/// </summary>
private static void WriteSpecialEventsSummary(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
private static void WriteSpecialEventsSummary(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
{
Console.WriteLine($"\n--- Special Events Found ---");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs))
Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences");
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences");
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com))
Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm))
Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences");
static int CountFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count);
var gs = CountFor(occurrences, EventDefinition.GeneralSchedule);
if (gs > 0)
Console.WriteLine($" GeneralSchedule: {gs} occurrences");
var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates);
if (mtc > 0)
Console.WriteLine($" MeetTheCandidates: {mtc} occurrences");
var com = CountFor(occurrences, EventDefinition.ChapterOfficerMeeting);
if (com > 0)
Console.WriteLine($" ChapterOfficerMeeting: {com} occurrences");
var vdm = CountFor(occurrences, EventDefinition.VotingDelegateMeeting);
if (vdm > 0)
Console.WriteLine($" VotingDelegateMeeting: {vdm} occurrences");
}
/// <summary>
@@ -237,43 +244,26 @@ public class EventOccurrenceParser_Tests
/// <summary>
/// Writes special events to console output.
/// </summary>
private static void WriteSpecialEvents(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
private static void WriteSpecialEvents(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
{
static List<Core.Entities.EventOccurrence> ListFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).SelectMany(kvp => kvp.Value).ToList();
Console.WriteLine("General Schedule");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule))
{
foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime))
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
Console.WriteLine("Meet the Candidates");
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates))
{
foreach (var eo in meetTheCandidates)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
Console.WriteLine("Chapter Officer Meeting");
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting))
{
foreach (var eo in chapterOfficerMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
Console.WriteLine("Voting Delegate Meeting");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting))
{
foreach (var eo in votingDelegateMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
#endregion
@@ -290,7 +280,11 @@ public class EventOccurrenceParser_Tests
{
Console.WriteLine($"{@event.Name}");
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
var eventOccurrences = dictionary
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
.SelectMany(kvp => kvp.Value)
.ToList();
if (eventOccurrences.Count == 0)
{
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue;
@@ -320,7 +314,11 @@ public class EventOccurrenceParser_Tests
{
Console.WriteLine($"{@event.Name}");
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
var eventOccurrences = dictionary
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
.SelectMany(kvp => kvp.Value)
.ToList();
if (eventOccurrences.Count == 0)
{
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue;
@@ -447,13 +445,13 @@ public class EventOccurrenceParser_Tests
// Total expected MS occurrences: 16
var msEventCount = 0;
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences))
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var csOccurrences))
msEventCount += csOccurrences.Count;
if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences))
if (coding != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(coding, SchoolLevel.MiddleSchool), out var codingOccurrences))
msEventCount += codingOccurrences.Count;
if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences))
if (communityServiceVideo != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(communityServiceVideo, SchoolLevel.MiddleSchool), out var csvOccurrences))
msEventCount += csvOccurrences.Count;
if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences))
if (constructionChallenge != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(constructionChallenge, SchoolLevel.MiddleSchool), out var ccOccurrences))
msEventCount += ccOccurrences.Count;
// When no school level is set, HS events should be processed (not skipped)
@@ -512,7 +510,7 @@ public class EventOccurrenceParser_Tests
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
// Verify specific locations are parsed
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences))
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var childrensStoriesOccurrences))
{
var locations = childrensStoriesOccurrences
.Select(eo => eo.Location)
@@ -563,20 +561,17 @@ public class EventOccurrenceParser_Tests
"HS section header should NOT be in SkippedSectionHeaders when no school level is set");
// With no school level filtering, both MS and HS events are processed
if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences))
{
// With no school level set, we process all occurrences (both MS and HS)
// Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total
Assert.That(allOccurrences, Has.Count.EqualTo(5),
"Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " +
$"Found {allOccurrences.Count} occurrences total.");
// Verify all expected occurrence names are present
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList();
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences");
Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences");
Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence");
}
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences);
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences);
msOccurrences ??= [];
hsOccurrences ??= [];
Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences");
Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences");
var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
Assert.That(allNames, Does.Contain("Submit Entry"));
Assert.That(allNames, Does.Contain("Judging"));
Assert.That(allNames, Does.Contain("Pick-up"));
Assert.Pass("All events processed when no school level is set");
}
@@ -585,4 +580,34 @@ public class EventOccurrenceParser_Tests
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
}
}
[Test]
public void Parse_SameEvent_HS_then_MS_ProducesTwoGroups()
{
var testContent = "Prepared Speech - HS\n" +
"Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4\n" +
"Prepared Speech - MS\n" +
"Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10";
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Prepared Speech") };
var parser = new EventOccurrenceParser(tempFile, events);
try
{
var result = parser.Parse();
Assert.That(result.Issues, Has.Count.EqualTo(0));
var def = events[0];
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.HighSchool), out var hsList), Is.True);
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.MiddleSchool), out var msList), Is.True);
Assert.That(hsList, Has.Count.EqualTo(1));
Assert.That(msList, Has.Count.EqualTo(1));
Assert.That(hsList![0].Name, Does.Contain("Extemporaneous"));
Assert.That(msList![0].Name, Does.Contain("Prelims"));
}
finally
{
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
}
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -18,6 +18,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Parsers\TestInput\2025 Assumptions.csv">