using EARSystem.Core; namespace EARSystem.Tests; public class AlgorithmTests { private static readonly DateTimeOffset Now = new(2026, 10, 6, 9, 0, 0, TimeSpan.Zero); private static PlaceCandidate Place(string id = "test", double lng = 120.155, decimal? price = 30, bool? open = true, PlaceCategory category = PlaceCategory.Cafe, double[]? atmosphere = null, int n = 0) => new(id, Guid.TryParse(id, out var guid) ? guid : null, "独立测试地点", "", category, lng, 30.274, price, 4, open, atmosphere, n, "Collected", n > 0 ? "真实反馈" : "类型推测"); private static RankingContext Context(RecommendationQuery? query = null, EmotionProfile? profile = null, RecommendationGoal goal = RecommendationGoal.Relax, PlaceCategory[]? likes = null, HistoryRating[]? history = null) => new(profile ?? new(3,3,4,1,[]), goal, query ?? new(), likes ?? [], [], [], history ?? [], new()); [Fact] public void ProfileUsesLastEntryPerChinaDayAndBoostsLatest() { var entries = new[] { new MoodSnapshot(1,1,5,Now.AddHours(-3),[]),new MoodSnapshot(5,5,1,Now.AddHours(-1),[]), new MoodSnapshot(2,2,4,Now.AddDays(-1),[]) }; var result=EmotionProfileBuilder.Build(entries,Now); Assert.Equal(2,result.RecordedDays);Assert.InRange(result.Mood,4,5);Assert.True(result.Stress<2); } [Fact] public void ProfileIgnoresStaleAndFutureEntries() { var result=EmotionProfileBuilder.Build([new(1,1,5,Now.AddDays(-8),[]),new(5,5,1,Now.AddDays(1),[])],Now); Assert.False(result.HasRecords);Assert.Equal(3,result.Mood); } [Fact] public void ProfileUsesCreationTimeWhenRecordedTimesTie() { var result=EmotionProfileBuilder.Build([ new(1,1,5,Now.AddHours(-1),[],Now.AddMinutes(-2)), new(5,5,1,Now.AddHours(-1),["准备学习"],Now.AddMinutes(-1)) ],Now); Assert.Equal(1,result.RecordedDays);Assert.Equal(5,result.Mood); Assert.Equal(RecommendationGoal.Focus,EmotionProfileBuilder.Goal(result)); } [Theory] [InlineData(3,3,4,RecommendationGoal.Relax)] [InlineData(4,2,1,RecommendationGoal.Relax)] [InlineData(4,4,2,RecommendationGoal.Social)] [InlineData(2,3,3,RecommendationGoal.Explore)] public void GoalReflectsState(double mood,double energy,double stress,RecommendationGoal goal) => Assert.Equal(goal,EmotionProfileBuilder.Goal(new(mood,energy,stress,1,[]))); [Fact] public void ExplicitGoalAndConfirmedFocusTakePriority() { var profile=new EmotionProfile(3,1,5,1,["准备学习"]); Assert.Equal(RecommendationGoal.Focus,EmotionProfileBuilder.Goal(profile)); Assert.Equal(RecommendationGoal.Social,EmotionProfileBuilder.Goal(profile,RecommendationGoal.Social)); } [Theory] [InlineData("今天很开心","开心")] [InlineData("我不开心","低落")] [InlineData("现在不难过了","平静")] [InlineData("最近很疲惫","疲惫")] public void TextHandlesLongestAndNegatedPhrases(string text,string tag) { var result=new EmotionTagAnalyzer().Analyze(text); Assert.Contains(result,x=>x.Tag==tag); if(text=="我不开心") Assert.DoesNotContain(result,x=>x.Tag=="开心"); if(text=="现在不难过了") Assert.DoesNotContain(result,x=>x.Tag=="低落"); } [Theory] [InlineData("")] [InlineData("并非开心")] [InlineData("没有那么开心")] [InlineData("并不觉得焦虑")] [InlineData("不是不开心")] [InlineData("开心才怪")] public void UnclearOrNegatedTextDoesNotProduceFalseTags(string text) => Assert.Empty(new EmotionTagAnalyzer().Analyze(text)); [Fact] public void NearerIdenticalPlaceScoresHigher() { var c=Context(); Assert.True(RecommendationScorer.Score(Place("near"),c)!.Score > RecommendationScorer.Score(Place("far",120.17),c)!.Score); } [Fact] public void LowerEnergyMakesDistanceMatterMore() { var p=Place(lng:120.17); var low=RecommendationScorer.Score(p,Context(profile:new(3,1,3,1,[])))!; var high=RecommendationScorer.Score(p,Context(profile:new(3,4,3,1,[])))!; Assert.True(low.Parts.Distancemismatch.Score); } [Fact] public void MissingScoresUseConservativeTypeInference() { var item=RecommendationScorer.Score(Place(n:0),Context())!; Assert.Equal(0,item.Confidence);Assert.Equal(.65,item.Parts.Atmosphere); Assert.DoesNotContain(item.Reasons,x=>x.Contains("反馈显示")); } [Fact] public void UnknownPriceRetainedAndKnownOverBudgetRemoved() { var c=Context(new(){Budget=20}); Assert.NotNull(RecommendationScorer.Score(Place(price:null),c)); Assert.Null(RecommendationScorer.Score(Place(price:21),c)); } [Fact] public void FiltersRespectRadiusOpenAndBlockedCategories() { Assert.Null(RecommendationScorer.Score(Place(lng:121),Context())); Assert.Null(RecommendationScorer.Score(Place(open:null),Context(new(){OpenOnly=true}))); var c=Context() with { Blocks=[PlaceCategory.Cafe] }; Assert.Null(RecommendationScorer.Score(Place(),c)); } [Fact] public void HistoryIsSmoothedAndChangesPersonalization() { var id=Guid.NewGuid(); var p=Place(id.ToString()); var good=RecommendationScorer.Score(p,Context(history:[new(id,PlaceCategory.Cafe,5)]))!; var bad=RecommendationScorer.Score(p,Context(history:[new(id,PlaceCategory.Cafe,1)]))!; Assert.Equal(3.5/6,good.Parts.History,6);Assert.True(good.Score>bad.Score); } [Fact] public void DiversityIncludesOtherCategoriesAndFillsTen() { var places=Enumerable.Range(0,15).Select(i=>Place("c"+i)) .Concat(Enumerable.Range(0,3).Select(i=>Place("p"+i,category:PlaceCategory.Park))) .Concat(Enumerable.Range(0,4).Select(i=>Place("b"+i,category:PlaceCategory.Bookstore))); var results=RecommendationScorer.Rank(places,Context(),out var count); Assert.Equal(22,count);Assert.Equal(10,results.Length);Assert.Equal(3,results.Select(x=>x.Place.Category).Distinct().Count()); } [Fact] public void ChinaDayAndWeekBoundaryAreCorrect() { var utc=new DateTimeOffset(2026,10,5,17,0,0,TimeSpan.Zero); Assert.Equal(new DateOnly(2026,10,6),ChinaTime.Day(utc)); Assert.Equal(new DateOnly(2026,10,5),ChinaTime.Monday(ChinaTime.Day(utc))); } [Fact] public void WeightsRejectNaNAndIncorrectTotal() { Assert.True(new AlgorithmWeights().IsValid()); Assert.False(new AlgorithmWeights{Atmosphere=double.NaN}.IsValid()); Assert.False(new AlgorithmWeights{Atmosphere=.8}.IsValid()); } [Fact] public void ExplanationsRespectActiveWeightsAndAlwaysProvideTwoGroundedReasons() { var qualityOnly=Context() with {Weights=new(){Atmosphere=0,Distance=0,Quality=1,Budget=0,Preference=0,History=0}}; var quality=RecommendationScorer.Score(Place(),qualityOnly)!; Assert.Equal(2,quality.Reasons.Length); Assert.Contains("商家综合评分较高",quality.Reasons); Assert.DoesNotContain(quality.Reasons,x=>x.Contains("距离较近")); var unknown=Place(lng:120.175,category:PlaceCategory.Entertainment) with {Rating=null,Price=null}; var fallback=RecommendationScorer.Score(unknown,Context())!; Assert.Equal(2,fallback.Reasons.Length); Assert.DoesNotContain(fallback.Reasons,x=>x.Contains("反馈") || x.Contains("综合评分较高")); } }