Files
sub2api/backend/internal/service/channel_monitor_v2_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

470 lines
19 KiB
Go

package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type channelMonitorV2RepoStub struct {
config serviceChannelMonitorV2ConfigAlias
users *ChannelMonitorV2List[ChannelMonitorV2UserRow]
matrix *ChannelMonitorV2Matrix
errors *ChannelMonitorV2List[ChannelMonitorV2ErrorRow]
snap *ChannelMonitorV2Snapshot
group ChannelMonitorV2GroupBy
admin bool
}
// Alias keeps composite literals readable without introducing another package.
type serviceChannelMonitorV2ConfigAlias = ChannelMonitorV2Config
func (s *channelMonitorV2RepoStub) GetConfig(context.Context) (*ChannelMonitorV2Config, error) {
cfg := ChannelMonitorV2Config(s.config)
return &cfg, nil
}
func (s *channelMonitorV2RepoStub) UpdateConfig(context.Context, ChannelMonitorV2Config, int) (*ChannelMonitorV2Config, error) {
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetDimensions(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config) (*ChannelMonitorV2Dimensions, error) {
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetSnapshot(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2Snapshot, error) {
s.admin = admin
if s.snap == nil {
return nil, nil
}
// Shallow copy so public redaction does not mutate fixture.
cfg := s.snap.Config
cfg.Platforms = append([]ChannelMonitorV2PlatformConfig(nil), s.snap.Config.Platforms...)
for i := range cfg.Platforms {
cfg.Platforms[i].Models = append([]string(nil), s.snap.Config.Platforms[i].Models...)
}
cfg.GroupIDs = append([]int64(nil), s.snap.Config.GroupIDs...)
cfg.IgnoredErrorCategories = append([]string(nil), s.snap.Config.IgnoredErrorCategories...)
out := *s.snap
out.Config = cfg
return &out, nil
}
func (s *channelMonitorV2RepoStub) GetModels(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) {
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetMatrix(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, groupBy ChannelMonitorV2GroupBy, admin bool) (*ChannelMonitorV2Matrix, error) {
s.group, s.admin = groupBy, admin
return s.matrix, nil
}
func (s *channelMonitorV2RepoStub) GetErrors(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) {
s.admin = admin
if s.errors != nil {
// Return a shallow copy so service redaction does not mutate the fixture.
out := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{
Coverage: s.errors.Coverage,
Items: append([]ChannelMonitorV2ErrorRow(nil), s.errors.Items...),
}
for i := range out.Items {
if len(out.Items[i].Details) > 0 {
out.Items[i].Details = append([]ChannelMonitorV2ErrorDetail(nil), out.Items[i].Details...)
}
}
return out, nil
}
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetUsers(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) {
return s.users, nil
}
func (s *channelMonitorV2RepoStub) RecomputeRange(context.Context, time.Time, time.Time) error {
return nil
}
func (s *channelMonitorV2RepoStub) GetAggregationWatermark(context.Context) (*ChannelMonitorV2AggregationWatermark, error) {
return &ChannelMonitorV2AggregationWatermark{}, nil
}
func TestChannelMonitorV2BootstrapProgress(t *testing.T) {
now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
t.Run("no data shows active zero progress", func(t *testing.T) {
b := ChannelMonitorV2BootstrapProgress(now, time.Time{}, false)
require.NotNil(t, b)
require.True(t, b.Active)
require.Equal(t, 0, b.ProgressPercent)
require.Equal(t, now.Add(-ChannelMonitorV2BootstrapProductWindow), b.TargetStart)
})
t.Run("2h seed is small percent of 30d", func(t *testing.T) {
covered := now.Add(-2 * time.Hour)
b := ChannelMonitorV2BootstrapProgress(now, covered, true)
require.NotNil(t, b)
require.True(t, b.Active)
require.Greater(t, b.ProgressPercent, 0)
require.Less(t, b.ProgressPercent, 5)
})
t.Run("30d covered hides bootstrap", func(t *testing.T) {
covered := now.Add(-ChannelMonitorV2BootstrapProductWindow)
b := ChannelMonitorV2BootstrapProgress(now, covered, true)
require.Nil(t, b)
})
t.Run("beyond 30d also hides", func(t *testing.T) {
covered := now.Add(-60 * 24 * time.Hour)
b := ChannelMonitorV2BootstrapProgress(now, covered, true)
require.Nil(t, b)
})
}
func TestChannelMonitorV2ParseFilterDefaultsAndBuckets(t *testing.T) {
svc := &ChannelMonitorV2Service{now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }}
filter, err := svc.ParseFilter("", []string{"openai", "openai", ""}, []string{"gpt-5"}, []int64{2, 1, 2, 0})
require.NoError(t, err)
require.Equal(t, "90m", filter.Range)
require.Equal(t, 5*time.Minute, filter.Bucket)
require.Equal(t, []string{"openai"}, filter.Platforms)
require.Equal(t, []int64{1, 2}, filter.GroupIDs)
require.Equal(t, 90*time.Minute, filter.End.Sub(filter.Start))
filter, err = svc.ParseFilter("30d", nil, nil, nil)
require.NoError(t, err)
require.Equal(t, 24*time.Hour, filter.Bucket)
_, err = svc.ParseFilter("15d", nil, nil, nil)
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidRange)
}
func TestParseChannelMonitorV2GroupBy(t *testing.T) {
groupBy, err := ParseChannelMonitorV2GroupBy("")
require.NoError(t, err)
require.Equal(t, ChannelMonitorV2GroupByPlatformGroup, groupBy)
for _, value := range []ChannelMonitorV2GroupBy{ChannelMonitorV2GroupByPlatform, ChannelMonitorV2GroupByPlatformGroup, ChannelMonitorV2GroupByPlatformModel, ChannelMonitorV2GroupByPlatformGroupModel} {
parsed, parseErr := ParseChannelMonitorV2GroupBy(string(value))
require.NoError(t, parseErr)
require.Equal(t, value, parsed)
}
_, err = ParseChannelMonitorV2GroupBy("group")
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy)
}
func TestChannelMonitorV2MatrixForwardsGroupingAndAdminScope(t *testing.T) {
want := &ChannelMonitorV2Matrix{GroupBy: ChannelMonitorV2GroupByPlatformModel}
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, matrix: want}
result, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatformModel, true)
require.NoError(t, err)
require.Same(t, want, result)
require.Equal(t, ChannelMonitorV2GroupByPlatformModel, repo.group)
require.True(t, repo.admin)
_, err = NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, "bad", false)
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy)
}
func TestChannelMonitorV2ConfigValidation(t *testing.T) {
cfg := ChannelMonitorV2Config{
Platforms: []ChannelMonitorV2PlatformConfig{
{Platform: " OpenAI ", Enabled: true, Models: []string{"gpt-5", "gpt-5", ""}},
{Platform: "anthropic", Enabled: true},
},
GroupIDs: []int64{3, 1, 3},
}
require.NoError(t, normalizeChannelMonitorV2Config(&cfg))
require.Equal(t, 300, cfg.RefreshIntervalSeconds)
require.Equal(t, "anthropic", cfg.Platforms[0].Platform)
require.Equal(t, []int64{1, 3}, cfg.GroupIDs)
cfg.RefreshIntervalSeconds = 120
require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig)
cfg.RefreshIntervalSeconds = 60
cfg.GroupIDs = []int64{0}
require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig)
}
func TestChannelMonitorV2ErrorTaxonomyPriority(t *testing.T) {
tests := []struct {
name string
in ChannelMonitorV2ErrorInput
want string
}{
{"cyber", ChannelMonitorV2ErrorInput{ErrorType: "cyber_policy", StatusCode: 200}, "content_policy"},
{"auth before forbidden", ChannelMonitorV2ErrorInput{StatusCode: 403, Message: "invalid API key"}, "authentication"},
{"context", ChannelMonitorV2ErrorInput{Message: "maximum prompt length exceeded"}, "context_limit"},
{"unsupported", ChannelMonitorV2ErrorInput{Message: "not supported by any configured account"}, "model_unsupported"},
{"pool", ChannelMonitorV2ErrorInput{Message: "No available accounts"}, "account_pool_unavailable"},
{"timeout", ChannelMonitorV2ErrorInput{Message: "error code: 524"}, "timeout"},
{"upstream", ChannelMonitorV2ErrorInput{ErrorOwner: "provider", StatusCode: 502, UpstreamStatusCode: 502}, "upstream_5xx"},
{"other", ChannelMonitorV2ErrorInput{StatusCode: 400, Message: "unknown"}, "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, ClassifyChannelMonitorV2Error(tt.in)) })
}
}
func TestChannelMonitorV2HealthBlendsErrorTTFTAndCache(t *testing.T) {
// error 3%/5% → 40; ttft p50 2s → 100; cache 50% → 50
// overall = (0.6*40 + 0.2*100 + 0.2*50) / 1.0 = 54 → warning
p50 := int64(2000)
p95 := int64(9000)
thresholds := ChannelMonitorV2HealthThresholds{
MinimumSample: 20,
WarningErrorRate: 0.02,
CriticalErrorRate: 0.05,
TargetTTFTMs: 2500,
WarningTTFTMs: 2501,
CriticalTTFTMs: 6000,
WarningCacheRate: 0.20,
CriticalCacheRate: 0.05,
ErrorWeight: 0.60,
TTFTWeight: 0.20,
CacheWeight: 0.20,
}
metrics := ChannelMonitorV2Metric{
RequestCount: 100,
ErrorRate: 0.03,
CacheRate: 0.50,
CacheRateDenominator: 100,
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50, P95Ms: &p95},
}
health := ChannelMonitorV2HealthForWithThresholds(metrics, thresholds)
require.Equal(t, "warning", health.ErrorRate)
require.Equal(t, "healthy", health.TTFT)
require.Equal(t, "healthy", health.Cache)
require.NotNil(t, health.Score)
require.NotNil(t, health.CacheScore)
require.InDelta(t, 50.0, *health.CacheScore, 0.01)
require.InDelta(t, 54.0, *health.Score, 0.01)
require.Equal(t, "warning", health.Overall)
// Perfect signals → 100
p50OK := int64(1000)
metrics.ErrorRate = 0
metrics.CacheRate = 1
metrics.TTFT.P50Ms = &p50OK
health = ChannelMonitorV2HealthForWithThresholds(metrics, thresholds)
require.Equal(t, "healthy", health.Overall)
require.NotNil(t, health.Score)
require.InDelta(t, 100.0, *health.Score, 0.01)
// Small samples stay unknown
health = ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{RequestCount: 2})
require.Equal(t, "unknown", health.Overall)
require.Nil(t, health.Score)
}
func TestChannelMonitorV2DefaultHealthThresholdsAreTolerant(t *testing.T) {
p50 := int64(2500)
health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{
RequestCount: 100,
ErrorRate: 0.03,
CacheRate: 0,
CacheRateDenominator: 100,
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50},
})
require.Equal(t, "healthy", health.ErrorRate)
require.Equal(t, "healthy", health.TTFT)
require.Equal(t, "healthy", health.Cache)
require.Equal(t, "healthy", health.Overall)
require.NotNil(t, health.CacheScore)
require.InDelta(t, 100.0, *health.CacheScore, 0.01)
}
func TestErrorRateTTFTAndCacheScoreHelpers(t *testing.T) {
require.InDelta(t, 100.0, errorRateScore(0, 0.05), 0.001)
require.InDelta(t, 0.0, errorRateScore(0.05, 0.05), 0.001)
require.InDelta(t, 40.0, errorRateScore(0.03, 0.05), 0.001)
require.InDelta(t, 100.0, ttftP50Score(2500, 2500, 6000), 0.001)
require.InDelta(t, 100.0, ttftP50Score(1000, 2500, 6000), 0.001)
require.InDelta(t, 0.0, ttftP50Score(6000, 2500, 6000), 0.001)
require.InDelta(t, 50.0, ttftP50Score(4250, 2500, 6000), 0.001)
require.InDelta(t, 0.0, cacheRateScore(0), 0.001)
require.InDelta(t, 100.0, cacheRateScore(1), 0.001)
require.InDelta(t, 50.0, cacheRateScore(0.5), 0.001)
require.Equal(t, "critical", cacheRateBand(0.02, 0.20, 0.05))
require.Equal(t, "warning", cacheRateBand(0.10, 0.20, 0.05))
require.Equal(t, "healthy", cacheRateBand(0.50, 0.20, 0.05))
}
func TestChannelMonitorV2UsersRemovesOtherUserIdentity(t *testing.T) {
selfID, otherID := int64(7), int64(9)
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{
{UserID: &otherID, Email: "other@example.com", Username: "other"},
{UserID: &selfID, Email: "self@example.com", Username: "self"},
}}}
result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false)
require.NoError(t, err)
require.Nil(t, result.Items[0].UserID)
require.Empty(t, result.Items[0].Email)
require.Equal(t, "Other user #1", result.Items[0].DisplayLabel)
require.Equal(t, selfID, *result.Items[1].UserID)
require.True(t, result.Items[1].IsSelf)
require.Equal(t, "Me", result.Items[1].DisplayLabel)
}
func TestChannelMonitorV2UsersAppendsSelfWhenMissingFromRanking(t *testing.T) {
selfID, otherID := int64(7), int64(9)
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{
{UserID: &otherID, Email: "other@example.com", Username: "other", Metrics: ChannelMonitorV2Metric{RequestCount: 10}},
}}}
result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false)
require.NoError(t, err)
require.Len(t, result.Items, 2)
self := result.Items[1]
require.True(t, self.IsSelf)
require.Equal(t, "Me", self.DisplayLabel)
require.Equal(t, selfID, *self.UserID)
require.Equal(t, 0, self.Rank) // unranked / no traffic in window
}
func TestChannelMonitorV2TopUsersKeepsSelfOutsideLimit(t *testing.T) {
items := make([]ChannelMonitorV2UserRow, 0, 12)
for i := 1; i <= 12; i++ {
id := int64(i)
items = append(items, ChannelMonitorV2UserRow{UserID: &id, Rank: i, DisplayLabel: fmt.Sprintf("u%d", i)})
}
// self is rank 12 (index 11)
out := channelMonitorV2TopUsersWithSelf(items, 11, 10)
require.Len(t, out, 11)
require.Equal(t, int64(12), *out[10].UserID)
require.Equal(t, 12, out[10].Rank)
}
func TestChannelMonitorV2ReadAPIsRejectDisabledConfig(t *testing.T) {
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: false}}
_, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatform, false)
require.ErrorIs(t, err, ErrChannelMonitorDisabled)
}
func TestNormalizeChannelMonitorV2IgnoredCategories(t *testing.T) {
got := normalizeChannelMonitorV2IgnoredCategories([]string{"timeout", "TIMEOUT", "not-a-real", "", "timeout", "other"})
require.Equal(t, []string{"other", "timeout"}, got)
}
func TestApplyIgnoredErrorsAdjustsRateOnly(t *testing.T) {
// applyIgnoredErrors lives in repository package; mirror the scored-rate formula.
// scored errors = total_errors - ignored; error_rate = scored / request_count
// success_rate stays SuccessRequests / RequestCount (ignored ≠ success).
m := ChannelMonitorV2Metric{RequestCount: 100, ErrorRequests: 20, SuccessRequests: 80, ErrorRate: 0.2, SuccessRate: 0.8}
ignored := int64(5)
counted := m.ErrorRequests - ignored
errorRate := float64(counted) / float64(m.RequestCount)
successRate := float64(m.SuccessRequests) / float64(m.RequestCount)
require.InDelta(t, 0.15, errorRate, 0.0001)
require.InDelta(t, 0.80, successRate, 0.0001)
require.Equal(t, int64(20), m.ErrorRequests)
require.Equal(t, int64(100), m.RequestCount)
}
func TestErrorsForViewerStripsDetailsAndCountsForNonAdmin(t *testing.T) {
fixture := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{
Items: []ChannelMonitorV2ErrorRow{{
Category: "timeout",
Count: 42,
Rate: 0.4,
Details: []ChannelMonitorV2ErrorDetail{{
Platform: "anthropic",
Model: "claude",
ErrorType: "upstream",
StatusCode: 504,
UpstreamStatusCode: 504,
Message: "gateway timeout sk-secret",
Count: 12,
}},
}},
}
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, errors: fixture}
svc := NewChannelMonitorV2Service(repo)
userList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, false)
require.NoError(t, err)
require.False(t, repo.admin)
require.Len(t, userList.Items, 1)
require.Zero(t, userList.Items[0].Count)
require.Empty(t, userList.Items[0].Details)
require.InDelta(t, 0.4, userList.Items[0].Rate, 0.0001)
adminList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, true)
require.NoError(t, err)
require.True(t, repo.admin)
require.Equal(t, int64(42), adminList.Items[0].Count)
require.Len(t, adminList.Items[0].Details, 1)
require.Contains(t, adminList.Items[0].Details[0].Message, "gateway timeout")
}
func TestSnapshotRedactsPublicConfigPolicyFields(t *testing.T) {
updatedBy := int64(9)
repo := &channelMonitorV2RepoStub{
config: ChannelMonitorV2Config{Enabled: true},
snap: &ChannelMonitorV2Snapshot{
Config: ChannelMonitorV2Config{
Version: 3,
Enabled: true,
RefreshIntervalSeconds: 300,
Platforms: []ChannelMonitorV2PlatformConfig{
{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}},
},
GroupIDs: []int64{1, 2},
IgnoredErrorCategories: []string{"timeout"},
UpdatedBy: &updatedBy,
},
Metrics: ChannelMonitorV2Metric{RequestCount: 100, ErrorRate: 0.1, SuccessRate: 0.9, RPM: 5},
},
}
svc := NewChannelMonitorV2Service(repo)
snap, err := svc.Snapshot(context.Background(), ChannelMonitorV2Filter{}, false)
require.NoError(t, err)
require.Empty(t, snap.Config.GroupIDs)
require.Empty(t, snap.Config.IgnoredErrorCategories)
require.Nil(t, snap.Config.UpdatedBy)
require.Empty(t, snap.Config.Platforms[0].Models)
require.Equal(t, 300, snap.Config.RefreshIntervalSeconds)
require.Zero(t, snap.Metrics.RequestCount)
require.InDelta(t, 0.1, snap.Metrics.ErrorRate, 0.0001)
require.Zero(t, snap.Metrics.RPM)
}
func TestRedactChannelMonitorV2MetricKeepsRates(t *testing.T) {
p50 := int64(100)
m := ChannelMonitorV2Metric{
SuccessRequests: 90, ErrorRequests: 10, RequestCount: 100,
InputTokens: 1, OutputTokens: 2, CacheCreationTokens: 3, CacheReadTokens: 4,
TokenCount: 10, RPM: 12, TPM: 34, ErrorRate: 0.1, SuccessRate: 0.9,
CacheRate: 0.5, CacheRateNumerator: 4, CacheRateDenominator: 8,
TTFT: ChannelMonitorV2Latency{SampleCount: 50, P50Ms: &p50},
}
upstream := int64(3)
m.UpstreamAffectedRequests = &upstream
redactChannelMonitorV2Metric(&m, false)
require.Zero(t, m.RequestCount)
require.Zero(t, m.ErrorRequests)
require.Zero(t, m.TokenCount)
require.Zero(t, m.CacheRateNumerator)
require.Zero(t, m.TTFT.SampleCount)
require.Nil(t, m.UpstreamAffectedRequests)
require.InDelta(t, 0.1, m.ErrorRate, 0.0001)
require.InDelta(t, 12.0, m.RPM, 0.0001)
require.InDelta(t, 34.0, m.TPM, 0.0001)
require.NotNil(t, m.TTFT.P50Ms)
require.Equal(t, int64(100), *m.TTFT.P50Ms)
redactChannelMonitorV2Metric(&m, true)
require.Zero(t, m.RPM)
require.Zero(t, m.TPM)
require.InDelta(t, 0.1, m.ErrorRate, 0.0001)
}
func TestChannelMonitorV2HealthTTFTAtTargetIsHealthy(t *testing.T) {
p50 := int64(2500)
m := ChannelMonitorV2Metric{
RequestCount: 100, ErrorRate: 0,
CacheRate: 1, CacheRateDenominator: 100,
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50},
}
h := ChannelMonitorV2HealthFor(m)
require.Equal(t, "healthy", h.TTFT)
require.NotNil(t, h.TTFTScore)
require.InDelta(t, 100.0, *h.TTFTScore, 0.01)
}