package matching import ( "context" "testing" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" ) // fakeAliasResolver is a test double for DeterministicAliasResolver that returns a fixed result. type fakeAliasResolver struct { match engine.SkillMatch ok bool } func (f *fakeAliasResolver) ResolveAlias(_ context.Context, _ engine.RequiredSkill, _ []engine.CandidateSkill) (engine.SkillMatch, bool) { return f.match, f.ok } // fakeSemanticMatcher is a test double for engine.SkillMatcher that counts calls and returns a // fixed result. type fakeSemanticMatcher struct { match engine.SkillMatch ok bool calls int } func (f *fakeSemanticMatcher) Match(_ context.Context, _ engine.RequiredSkill, _ []engine.CandidateSkill, _ decimal.Decimal) (engine.SkillMatch, bool) { f.calls++ return f.match, f.ok } // resolverBridge adapts fakeAliasResolver to the interface ChainedResolver expects. ChainedResolver // holds a *DeterministicAliasResolver directly, so for tests we build a real resolver from injected // maps and a fakeSemanticMatcher. func resolverBridgeFromFake(f *fakeAliasResolver) *DeterministicAliasResolver { // Build a resolver that will always return the faked result by wiring maps such that the first // candidate resolves to the same canonical id as the required skill when f.ok is true, or builds // empty maps when f.ok is false so there is no match. if f.ok { // canonical id "test-id" for both sides causes alias match nameToID := map[string]string{"required skill": "test-id", "candidate skill": "test-id"} return newResolverFromMaps(nameToID, nil, nil) } return newResolverFromMaps(nil, nil, nil) } // chainedTestRequired and chainedTestCands are fixed inputs used in chained resolver tests. var chainedTestRequired = engine.RequiredSkill{SkillID: "test-id", Name: "Required Skill"} var chainedTestCands = []engine.CandidateSkill{{SkillID: "test-id", Name: "Candidate Skill"}} // TestChainedResolver_ResolverWins: when the alias resolver matches, the semantic matcher is not // called and the resolver result is returned. func TestChainedResolver_ResolverWins(t *testing.T) { resolverMatch := engine.SkillMatch{ CandidateSkillID: "test-id", CandidateName: "Candidate Skill", Similarity: decimal.NewFromInt(1), Exact: true, AliasMatched: true, Matched: true, } // Build a real resolver that will return a match for chainedTestRequired vs chainedTestCands: // both sides share canonical id "test-id" via nameToID. nameToID := map[string]string{ "required skill": "test-id", "candidate skill": "test-id", } resolver := newResolverFromMaps(nameToID, nil, nil) semantic := &fakeSemanticMatcher{ok: false} c := NewChainedResolver(resolver, semantic) m, ok := c.Match(context.Background(), chainedTestRequired, chainedTestCands, decimal.RequireFromString("0.62")) if !ok { t.Fatal("expected match from resolver, got none") } if semantic.calls != 0 { t.Errorf("semantic matcher should not be called when resolver wins, got %d calls", semantic.calls) } // The resolver result has AliasMatched true, Exact true. _ = resolverMatch if !m.AliasMatched { t.Error("AliasMatched should be true (resolver sourced)") } } // TestChainedResolver_SemanticRunsOnResolverMiss: when the resolver returns no match, the semantic // matcher is called and its result is returned. func TestChainedResolver_SemanticRunsOnResolverMiss(t *testing.T) { semanticMatch := engine.SkillMatch{ CandidateSkillID: "sk-adjacent", CandidateName: "Adjacent Skill", Similarity: decimal.RequireFromString("0.75"), Exact: false, Matched: true, } // empty maps: resolver will never match resolver := newResolverFromMaps(nil, nil, nil) semantic := &fakeSemanticMatcher{match: semanticMatch, ok: true} c := NewChainedResolver(resolver, semantic) required := engine.RequiredSkill{SkillID: "sk-req", Name: "Required"} cands := []engine.CandidateSkill{{SkillID: "sk-adjacent", Name: "Adjacent Skill"}} m, ok := c.Match(context.Background(), required, cands, decimal.RequireFromString("0.62")) if !ok { t.Fatal("expected semantic match, got none") } if semantic.calls != 1 { t.Errorf("semantic matcher should be called exactly once, got %d calls", semantic.calls) } if !m.Similarity.Equal(decimal.RequireFromString("0.75")) { t.Errorf("Similarity = %s, want 0.75", m.Similarity) } } // TestChainedResolver_NilSemanticDegrades: a nil semantic matcher plus a resolver that returns no // match yields (SkillMatch{}, false), not a panic. func TestChainedResolver_NilSemanticDegrades(t *testing.T) { resolver := newResolverFromMaps(nil, nil, nil) c := NewChainedResolver(resolver, nil) required := engine.RequiredSkill{SkillID: "sk-req", Name: "Required"} cands := []engine.CandidateSkill{{SkillID: "sk-other", Name: "Other"}} _, ok := c.Match(context.Background(), required, cands, decimal.RequireFromString("0.62")) if ok { t.Fatal("expected no match when resolver misses and semantic is nil") } }