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.Distance<high.Parts.Distance);
}
[Fact]
public void AtmosphereChangesOrderAtSameDistance() {
var match=RecommendationScorer.Score(Place(atmosphere:[5,5,4,1,1,3],n:100),Context())!;
var mismatch=RecommendationScorer.Score(Place(atmosphere:[1,1,1,5,5,1],n:100),Context())!;
Assert.True(match.Score>mismatch.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("综合评分较高"));
}
}
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.Distance<high.Parts.Distance);
}
[Fact]
public void AtmosphereChangesOrderAtSameDistance() {
var match=RecommendationScorer.Score(Place(atmosphere:[5,5,4,1,1,3],n:100),Context())!;
var mismatch=RecommendationScorer.Score(Place(atmosphere:[1,1,1,5,5,1],n:100),Context())!;
Assert.True(match.Score>mismatch.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("综合评分较高"));
}
}