package semanticreconcile import ( "reflect" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" ) func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) { preparation := proposalPreparation(t) response := ProposalResponse{DuplicateGroups: []DuplicateGroup{ {CandidateIDs: []int{5, 4}, CanonicalCandidateID: 5}, {CandidateIDs: []int{2, 1}, CanonicalCandidateID: 2}, }} assessment := preparation.Assess(response) want := []planGroupSnapshot{ {members: []int{0, 2}, canonical: 2}, {members: []int{5, 6}, canonical: 6}, } if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, want) { t.Fatalf("plan = %#v, want %#v", got, want) } if assessment.DiscardedGroupCount() != 0 || assessment.RetryRequired() || len(assessment.Issues()) != 0 { t.Fatalf("assessment diagnostics = discarded %d, retry %t, issues %#v", assessment.DiscardedGroupCount(), assessment.RetryRequired(), assessment.Issues()) } reordered := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}, {CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5}, }}) if got := snapshotPlan(reordered.Plan()); !reflect.DeepEqual(got, want) { t.Fatalf("reordered plan = %#v, want %#v", got, want) } empty := preparation.Assess(ProposalResponse{}) if len(empty.Plan().Groups()) != 0 || len(empty.Issues()) != 0 || empty.DiscardedGroupCount() != 0 || empty.RetryRequired() { t.Fatalf("empty assessment = plan %#v, issues %#v, discarded %d, retry %t", empty.Plan().Groups(), empty.Issues(), empty.DiscardedGroupCount(), empty.RetryRequired()) } } func TestIssueDetailsPreservesIssueOrder(t *testing.T) { issues := []Issue{ {GroupIndex: 3, Category: IssueCanonicalUnknown}, {GroupIndex: 1, Category: IssueMemberUnknown}, } want := []string{"group 3: canonical_unknown", "group 1: member_unknown"} if got := IssueDetails(issues); !reflect.DeepEqual(got, want) { t.Fatalf("IssueDetails() = %#v, want %#v", got, want) } } func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) { preparation := proposalPreparation(t) tests := []struct { name string group DuplicateGroup category IssueCategory }{ {name: "zero member", group: DuplicateGroup{CandidateIDs: []int{0, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive}, {name: "negative member", group: DuplicateGroup{CandidateIDs: []int{-1, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive}, {name: "unknown member", group: DuplicateGroup{CandidateIDs: []int{99, 2}, CanonicalCandidateID: 2}, category: IssueMemberUnknown}, {name: "repeated member", group: DuplicateGroup{CandidateIDs: []int{1, 1}, CanonicalCandidateID: 1}, category: IssueRepeatedMember}, {name: "too small", group: DuplicateGroup{CandidateIDs: []int{1}, CanonicalCandidateID: 1}, category: IssueFewerThanTwoMembers}, {name: "zero canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 0}, category: IssueCanonicalNonPositive}, {name: "negative canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: -1}, category: IssueCanonicalNonPositive}, {name: "unknown canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 99}, category: IssueCanonicalUnknown}, {name: "canonical not member", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 3}, category: IssueCanonicalNotMember}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{test.group}}) if len(assessment.Plan().Groups()) != 0 || assessment.DiscardedGroupCount() != 1 || !assessment.RetryRequired() { t.Fatalf("assessment = plan %#v, discarded %d, retry %t", assessment.Plan().Groups(), assessment.DiscardedGroupCount(), assessment.RetryRequired()) } if !hasIssue(assessment.Issues(), 0, test.category) { t.Fatalf("issues = %#v, want category %q", assessment.Issues(), test.category) } }) } } func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testing.T) { preparation := proposalPreparation(t) assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1}, {CandidateIDs: []int{2, 3}, CanonicalCandidateID: 2}, {CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5}, }}) wantPlan := []planGroupSnapshot{{members: []int{5, 6}, canonical: 6}} if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, wantPlan) { t.Fatalf("plan = %#v, want %#v", got, wantPlan) } if assessment.DiscardedGroupCount() != 2 || !assessment.RetryRequired() { t.Fatalf("discarded = %d, retry = %t", assessment.DiscardedGroupCount(), assessment.RetryRequired()) } issues := assessment.Issues() if len(issues) != 2 || !hasIssue(issues, 0, IssueOverlappingMember) || !hasIssue(issues, 1, IssueOverlappingMember) { t.Fatalf("issues = %#v, want both conflicting group indexes", issues) } invalidAndSafe := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ {CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1}, {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}, }}) wantPlan = []planGroupSnapshot{{members: []int{0, 2}, canonical: 2}} if got := snapshotPlan(invalidAndSafe.Plan()); !reflect.DeepEqual(got, wantPlan) { t.Fatalf("plan with independent invalid group = %#v, want %#v", got, wantPlan) } if invalidAndSafe.DiscardedGroupCount() != 1 || !invalidAndSafe.RetryRequired() || !hasIssue(invalidAndSafe.Issues(), 0, IssueMemberUnknown) { t.Fatalf("invalid-and-safe assessment = discarded %d, retry %t, issues %#v", invalidAndSafe.DiscardedGroupCount(), invalidAndSafe.RetryRequired(), invalidAndSafe.Issues()) } } func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) { preparation := proposalPreparation(t) response := ProposalResponse{DuplicateGroups: []DuplicateGroup{ {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}, }} assessment := preparation.Assess(response) want := snapshotPlan(assessment.Plan()) response.DuplicateGroups[0].CandidateIDs[0] = 99 response.DuplicateGroups[0].CanonicalCandidateID = 99 plan := assessment.Plan() groups := plan.Groups() members := groups[0].MemberPositions() members[0] = 99 groups[0] = PlanGroup{} if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, want) { t.Fatalf("assessment plan changed through returned or input data: %#v", got) } invalid := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{ CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1, }}}) issues := invalid.Issues() issues[0].GroupIndex = 99 issues[0].Category = IssueOverlappingMember if retained := invalid.Issues(); retained[0].GroupIndex == 99 || retained[0].Category == IssueOverlappingMember { t.Fatalf("Issues() exposed retained state: %#v", retained) } } type planGroupSnapshot struct { members []int canonical int } func snapshotPlan(plan Plan) []planGroupSnapshot { groups := plan.Groups() snapshot := make([]planGroupSnapshot, len(groups)) for index, group := range groups { snapshot[index] = planGroupSnapshot{ members: group.MemberPositions(), canonical: group.CanonicalPosition(), } } return snapshot } func hasIssue(issues []Issue, groupIndex int, category IssueCategory) bool { for _, issue := range issues { if issue.GroupIndex == groupIndex && issue.Category == category { return true } } return false } func proposalPreparation(t *testing.T) Preparation { t.Helper() document := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, 7)} candidates := make([]Candidate, len(document.Units)) for position := range document.Units { unitID := position + 1 document.Units[position] = source.SourceUnit{ID: unitID, Text: "unit"} candidates[position] = Candidate{ Label: "candidate", SourceRefs: []source.SourceRef{{ SourceID: document.ID, StartUnitID: unitID, EndUnitID: unitID, }}, } } for _, position := range []int{1, 4} { candidates[position].SourceRefs[0].SourceID = "other" } preparation, err := Prepare(document, candidates, Limits{ ContextRadius: 0, MaximumCandidates: len(candidates), MaximumMaterialBytes: 10000, }) if err != nil || preparation.Disposition() != Ready { t.Fatalf("Prepare() disposition = %v, error = %v", preparation.Disposition(), err) } return preparation }