XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

EARSystem

【ASP】耳间,网页端的情绪智能推荐系统

公开
关注 0 Fork 0 Star 0
UTF-8
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("综合评分较高"));
    }
}