Merge branch 'feat/v0.12.6.3-nettoyage-fantome'
This commit is contained in:
commit
b156651662
10 changed files with 23 additions and 1407 deletions
|
|
@ -1206,7 +1206,8 @@ Deux écarts de conformité sécurité identifiés entre le code et ORIGIN_SECUR
|
|||
|
||||
### v0.12.6.3 — Nettoyage Code Fantôme
|
||||
|
||||
**Statut** : ⏳ TODO
|
||||
**Statut** : ✅ DONE
|
||||
**Complété le** : 2026-03-12
|
||||
**Priorité** : P1
|
||||
**Durée estimée** : 1-2 jours
|
||||
**Prerequisite** : v0.12.6 complète
|
||||
|
|
@ -1216,19 +1217,29 @@ Le diagnostic audit a identifié 9 modules "fantômes" — du code présent dans
|
|||
|
||||
**Tâches**
|
||||
|
||||
- [ ] **TASK-GHOST-001** : Auditer les modules fantômes (contest, voting_system, production_challenge, sound_design_contest)
|
||||
- Si actifs et gamification → désactiver/supprimer
|
||||
- Si inactifs → supprimer le code mort
|
||||
- [ ] **TASK-GHOST-002** : Évaluer et traiter `playback_abtest_service.go`
|
||||
- Si manipulation UX sans consentement → supprimer
|
||||
- [x] **TASK-GHOST-001** : Auditer les modules fantômes (contest, voting_system, production_challenge, sound_design_contest)
|
||||
- Tous identifiés comme code mort (aucune route enregistrée, aucun import)
|
||||
- Supprimés : `api/contest/`, `api/sound_design_contest/`, `api/production_challenge/`, `api/voting_system/`
|
||||
- Supprimé : `models/contest.go` (314 lignes, ContestBadge avec rarity, ContestPrize, ContestVote — gamification interdite)
|
||||
- Supprimé : `JuryMember` struct dans `models/user.go` (référence contest orpheline)
|
||||
- [x] **TASK-GHOST-002** : Évaluer et traiter `playback_abtest_service.go`
|
||||
- 476 lignes de A/B testing sur métriques de lecture — dark pattern potentiel
|
||||
- Supprimé avec son fichier test `playback_abtest_service_test.go`
|
||||
- Ref: ORIGIN_UI_UX_SYSTEM.md §13 anti-dark-patterns
|
||||
- [ ] **TASK-GHOST-003** : Traiter les modules non-spec utiles (listing, offer, graphql, grpc)
|
||||
- [ ] **TASK-GHOST-004** : Vérifier absence de code mort résiduel (grep termes interdits)
|
||||
- [x] **TASK-GHOST-003** : Traiter les modules non-spec utiles (listing, offer, graphql, grpc)
|
||||
- `listing/`, `offer/` : conservés — stubs marketplace, ORIGIN-approuvés pour implémentation future
|
||||
- `grpc/` : conservé — ORIGIN Section 9 approuve gRPC pour communication inter-services
|
||||
- `graphql/` : supprimé — ORIGIN spécifie REST-only, GraphQL non autorisé
|
||||
- [x] **TASK-GHOST-004** : Vérifier absence de code mort résiduel (grep termes interdits)
|
||||
- grep XP/streak/leaderboard/badge/gamif : 0 résultats
|
||||
- grep blockchain/web3/nft : 0 résultats
|
||||
- grep tensorflow/pytorch/sklearn : 0 résultats
|
||||
- `go build ./...` : PASS (aucun import cassé)
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] Aucun module de gamification actif dans le code
|
||||
- [ ] `playback_abtest_service.go` traité
|
||||
- [ ] grep confirme 0 traces des catégories éthiquement exclues
|
||||
- [x] Aucun module de gamification actif dans le code
|
||||
- [x] `playback_abtest_service.go` traité (supprimé)
|
||||
- [x] grep confirme 0 traces des catégories éthiquement exclues
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1563,7 +1574,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 :
|
|||
| v0.12.6 | Pentest Externe | P6R | ✅ DONE | 2-4 sem. | v0.12.4 |
|
||||
| v0.12.6.1 | Correctifs Pentest (30/30) | P0 | ✅ DONE | 3-5j | v0.12.6 |
|
||||
| v0.12.6.2 | Correctifs Sécurité Spec | P0 | ✅ DONE | 1.5j | v0.12.6 |
|
||||
| v0.12.6.3 | Nettoyage Code Fantôme | P1 | ⏳ TODO | 1-2j | v0.12.6 |
|
||||
| v0.12.6.3 | Nettoyage Code Fantôme | P1 | ✅ DONE | 1-2j | v0.12.6 |
|
||||
| v0.12.7 | Internationalisation | P1 | ⏳ TODO | 3-4j | v0.12.5 |
|
||||
| v0.12.8 | Documentation & API Publique | P1 | ⏳ TODO | 3-4j | v0.12.6 |
|
||||
| v0.12.9 | Tests Éthiques & Coverage CI | P1 | ⏳ TODO | 2-3j | v0.12.6.3 |
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
// Package contest - TO BE IMPLEMENTED
|
||||
package contest
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Package graphql - TO BE IMPLEMENTED
|
||||
package graphql
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Package production_challenge - TO BE IMPLEMENTED
|
||||
package production_challenge
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Package sound_design_contest - TO BE IMPLEMENTED
|
||||
package sound_design_contest
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Package voting_system - TO BE IMPLEMENTED
|
||||
package voting_system
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Contest représente un concours musical
|
||||
type Contest struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"not null;index"` // remix, production, sound_design, collaboration
|
||||
Status string `json:"status" gorm:"not null;default:'draft'"` // draft, active, voting, completed, cancelled
|
||||
CreatorID uuid.UUID `json:"creator_id" gorm:"type:uuid;not null;index"`
|
||||
OriginalTrackID *uuid.UUID `json:"original_track_id,omitempty" gorm:"type:uuid"`
|
||||
Genre sql.NullString `json:"genre,omitempty"`
|
||||
BPM sql.NullInt32 `json:"bpm,omitempty"`
|
||||
Key sql.NullString `json:"key,omitempty"`
|
||||
Requirements pq.StringArray `json:"requirements" gorm:"type:jsonb"`
|
||||
Rules pq.StringArray `json:"rules" gorm:"type:jsonb"`
|
||||
Timeline ContestTimeline `json:"timeline" gorm:"type:jsonb"`
|
||||
Prizes []ContestPrize `json:"prizes" gorm:"type:jsonb"`
|
||||
JudgingCriteria []JudgingCriterion `json:"judging_criteria" gorm:"type:jsonb"`
|
||||
Settings map[string]interface{} `json:"settings" gorm:"type:jsonb"`
|
||||
CoverImage sql.NullString `json:"cover_image,omitempty"`
|
||||
IsPublic bool `json:"is_public" gorm:"not null;default:true"`
|
||||
IsFeatured bool `json:"is_featured" gorm:"not null;default:false"`
|
||||
MaxParticipants sql.NullInt32 `json:"max_participants,omitempty"`
|
||||
EntryCount int64 `json:"entry_count" gorm:"not null;default:0"`
|
||||
ViewCount int64 `json:"view_count" gorm:"not null;default:0"`
|
||||
VoteCount int64 `json:"vote_count" gorm:"not null;default:0"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
// Relations
|
||||
Creator *User `json:"creator,omitempty"`
|
||||
OriginalTrack *SellableContent `json:"original_track,omitempty"`
|
||||
Entries []ContestEntry `json:"entries,omitempty"`
|
||||
Judges []ContestJudge `json:"judges,omitempty"`
|
||||
Sponsors []ContestSponsor `json:"sponsors,omitempty"`
|
||||
}
|
||||
|
||||
// ContestTimeline représente la timeline d'un concours
|
||||
type ContestTimeline struct {
|
||||
StartDate time.Time `json:"start_date"`
|
||||
SubmissionDeadline time.Time `json:"submission_deadline"`
|
||||
VotingStart time.Time `json:"voting_start"`
|
||||
VotingEnd time.Time `json:"voting_end"`
|
||||
ResultsAnnouncement time.Time `json:"results_announcement"`
|
||||
}
|
||||
|
||||
// ContestPrize représente un prix dans un concours
|
||||
type ContestPrize struct {
|
||||
Position int `json:"position"`
|
||||
Prize string `json:"prize"`
|
||||
Description string `json:"description"`
|
||||
CashAmount float64 `json:"cash_amount,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Badge string `json:"badge,omitempty"`
|
||||
Distribution string `json:"distribution,omitempty"`
|
||||
}
|
||||
|
||||
// JudgingCriterion représente un critère de jugement
|
||||
type JudgingCriterion struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Weight float64 `json:"weight"`
|
||||
MaxScore int `json:"max_score"`
|
||||
}
|
||||
|
||||
// ContestEntry représente une participation à un concours
|
||||
type ContestEntry struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `json:"contest_id" gorm:"type:uuid;not null;index"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;index"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
AudioFile string `json:"audio_file" gorm:"not null"`
|
||||
Metadata map[string]interface{} `json:"metadata" gorm:"type:jsonb"`
|
||||
Status string `json:"status" gorm:"not null;default:'submitted'"` // submitted, approved, disqualified, winner
|
||||
Position sql.NullInt32 `json:"position,omitempty"`
|
||||
Score sql.NullFloat64 `json:"score,omitempty"`
|
||||
VoteCount int64 `json:"vote_count" gorm:"not null;default:0"`
|
||||
ViewCount int64 `json:"view_count" gorm:"not null;default:0"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
// Relations
|
||||
Contest *Contest `json:"contest,omitempty"`
|
||||
User *User `json:"user,omitempty"`
|
||||
Votes []ContestVote `json:"votes,omitempty"`
|
||||
}
|
||||
|
||||
// ContestJudge représente un juge dans un concours
|
||||
type ContestJudge struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `json:"contest_id" gorm:"type:uuid;not null;index"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;index"`
|
||||
Role string `json:"role" gorm:"not null"` // head_judge, expert_judge, community_judge
|
||||
Weight float64 `json:"weight" gorm:"not null;default:1.0"`
|
||||
Credentials sql.NullString `json:"credentials,omitempty"`
|
||||
IsActive bool `json:"is_active" gorm:"not null;default:true"`
|
||||
JoinedAt time.Time `json:"joined_at" gorm:"autoCreateTime"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
// Relations
|
||||
Contest *Contest `json:"contest,omitempty"`
|
||||
User *User `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// ContestVote représente un vote dans un concours
|
||||
type ContestVote struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `json:"contest_id" gorm:"type:uuid;not null;index"`
|
||||
EntryID uuid.UUID `json:"entry_id" gorm:"type:uuid;not null;index"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;index"`
|
||||
JudgeID *uuid.UUID `json:"judge_id,omitempty" gorm:"type:uuid"`
|
||||
VoteType string `json:"vote_type" gorm:"not null"` // expert, community
|
||||
Score float64 `json:"score" gorm:"not null"`
|
||||
Criteria map[string]float64 `json:"criteria" gorm:"type:jsonb"`
|
||||
Comment sql.NullString `json:"comment,omitempty"`
|
||||
IsValid bool `json:"is_valid" gorm:"not null;default:true"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
|
||||
// Relations
|
||||
Contest *Contest `json:"contest,omitempty"`
|
||||
Entry *ContestEntry `json:"entry,omitempty"`
|
||||
User *User `json:"user,omitempty"`
|
||||
Judge *ContestJudge `json:"judge,omitempty"`
|
||||
}
|
||||
|
||||
// ContestSponsor représente un sponsor d'un concours
|
||||
type ContestSponsor struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `json:"contest_id" gorm:"type:uuid;not null;index"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description sql.NullString `json:"description,omitempty"`
|
||||
Logo sql.NullString `json:"logo,omitempty"`
|
||||
Website sql.NullString `json:"website,omitempty"`
|
||||
Contribution float64 `json:"contribution" gorm:"not null"`
|
||||
Currency string `json:"currency" gorm:"not null;default:'EUR'"`
|
||||
Benefits pq.StringArray `json:"benefits" gorm:"type:jsonb"`
|
||||
IsActive bool `json:"is_active" gorm:"not null;default:true"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
// Relations
|
||||
Contest *Contest `json:"contest,omitempty"`
|
||||
}
|
||||
|
||||
// ContestStems représente les stems d'un concours (pour remix contests)
|
||||
type ContestStems struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `json:"contest_id" gorm:"type:uuid;not null;uniqueIndex"`
|
||||
VocalsPath string `json:"vocals_path" gorm:"not null"`
|
||||
DrumsPath string `json:"drums_path" gorm:"not null"`
|
||||
BassPath string `json:"bass_path" gorm:"not null"`
|
||||
OtherPath string `json:"other_path" gorm:"not null"`
|
||||
DownloadURL string `json:"download_url" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
// Relations
|
||||
Contest *Contest `json:"contest,omitempty"`
|
||||
}
|
||||
|
||||
// ContestAnalytics représente les analytics d'un concours
|
||||
type ContestAnalytics struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `json:"contest_id" gorm:"type:uuid;not null;uniqueIndex"`
|
||||
TotalEntries int64 `json:"total_entries" gorm:"not null;default:0"`
|
||||
UniqueParticipants int64 `json:"unique_participants" gorm:"not null;default:0"`
|
||||
TotalVotes int64 `json:"total_votes" gorm:"not null;default:0"`
|
||||
UniqueVoters int64 `json:"unique_voters" gorm:"not null;default:0"`
|
||||
AverageScore float64 `json:"average_score" gorm:"not null;default:0"`
|
||||
CompletionRate float64 `json:"completion_rate" gorm:"not null;default:0"`
|
||||
EngagementRate float64 `json:"engagement_rate" gorm:"not null;default:0"`
|
||||
SocialShares int64 `json:"social_shares" gorm:"not null;default:0"`
|
||||
Comments int64 `json:"comments" gorm:"not null;default:0"`
|
||||
Countries int64 `json:"countries" gorm:"not null;default:0"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
// Relations
|
||||
Contest *Contest `json:"contest,omitempty"`
|
||||
}
|
||||
|
||||
// ContestBadge représente un badge de concours
|
||||
type ContestBadge struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `json:"contest_id" gorm:"type:uuid;not null;index"`
|
||||
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;index"`
|
||||
BadgeType string `json:"badge_type" gorm:"not null"` // winner, participant, judge, sponsor
|
||||
Position sql.NullInt32 `json:"position,omitempty"`
|
||||
Description string `json:"description" gorm:"not null"`
|
||||
Icon string `json:"icon" gorm:"not null"`
|
||||
Rarity string `json:"rarity" gorm:"not null;default:'common'"` // common, rare, epic, legendary
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
|
||||
// Relations
|
||||
Contest *Contest `json:"contest,omitempty"`
|
||||
User *User `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour Contest
|
||||
func (Contest) TableName() string {
|
||||
return "contests"
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour ContestEntry
|
||||
func (ContestEntry) TableName() string {
|
||||
return "contest_entries"
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour ContestJudge
|
||||
func (ContestJudge) TableName() string {
|
||||
return "contest_judges"
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour ContestVote
|
||||
func (ContestVote) TableName() string {
|
||||
return "contest_votes"
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour ContestSponsor
|
||||
func (ContestSponsor) TableName() string {
|
||||
return "contest_sponsors"
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour ContestStems
|
||||
func (ContestStems) TableName() string {
|
||||
return "contest_stems"
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour ContestAnalytics
|
||||
func (ContestAnalytics) TableName() string {
|
||||
return "contest_analytics"
|
||||
}
|
||||
|
||||
// TableName spécifie le nom de la table pour ContestBadge
|
||||
func (ContestBadge) TableName() string {
|
||||
return "contest_badges"
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *Contest) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *ContestEntry) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *ContestJudge) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *ContestVote) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *ContestSponsor) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *ContestStems) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *ContestAnalytics) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *ContestBadge) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -70,16 +70,6 @@ type SellableContent struct {
|
|||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// JuryMember représente un membre du jury pour un contest
|
||||
// MIGRATION UUID: UserID migré vers UUID
|
||||
type JuryMember struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContestID uuid.UUID `gorm:"type:uuid;not null" json:"contest_id" db:"contest_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id" db:"user_id"`
|
||||
Role string `json:"role" db:"role"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *SellableContent) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
|
|
@ -87,11 +77,3 @@ func (m *SellableContent) BeforeCreate(tx *gorm.DB) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook GORM pour générer UUID si non défini
|
||||
func (m *JuryMember) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == uuid.Nil {
|
||||
m.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,475 +0,0 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PlaybackABTestService gère le support A/B testing pour les analytics de lecture
|
||||
// T0379: Create Playback Analytics A/B Testing Support
|
||||
type PlaybackABTestService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPlaybackABTestService crée un nouveau service A/B testing
|
||||
func NewPlaybackABTestService(db *gorm.DB, logger *zap.Logger) *PlaybackABTestService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &PlaybackABTestService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// VariantFilter représente les critères de filtrage pour un variant
|
||||
// GO-004: Migré vers UUID pour TrackID et UserIDs
|
||||
type VariantFilter struct {
|
||||
TrackID *uuid.UUID `json:"track_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
UserIDs []uuid.UUID `json:"user_ids,omitempty"` // Liste d'IDs utilisateurs spécifiques
|
||||
MinPlayTime *int `json:"min_play_time,omitempty"` // Filtre optionnel par temps de lecture minimum
|
||||
}
|
||||
|
||||
// VariantStats représente les statistiques d'un variant
|
||||
type VariantStats struct {
|
||||
VariantName string `json:"variant_name"`
|
||||
TotalSessions int64 `json:"total_sessions"`
|
||||
TotalPlayTime int64 `json:"total_play_time"` // seconds
|
||||
AveragePlayTime float64 `json:"average_play_time"` // seconds
|
||||
AverageCompletion float64 `json:"average_completion"` // percentage
|
||||
CompletionRate float64 `json:"completion_rate"` // percentage of sessions with >90% completion
|
||||
AveragePauses float64 `json:"average_pauses"`
|
||||
AverageSeeks float64 `json:"average_seeks"`
|
||||
}
|
||||
|
||||
// StatisticalSignificance représente la significativité statistique
|
||||
type StatisticalSignificance struct {
|
||||
PValue float64 `json:"p_value"` // P-value (0-1)
|
||||
IsSignificant bool `json:"is_significant"` // True si p-value < 0.05
|
||||
ConfidenceLevel float64 `json:"confidence_level"` // Niveau de confiance (95%, 99%, etc.)
|
||||
ConfidenceIntervalLower float64 `json:"confidence_interval_lower"` // Borne inférieure de l'intervalle de confiance
|
||||
ConfidenceIntervalUpper float64 `json:"confidence_interval_upper"` // Borne supérieure de l'intervalle de confiance
|
||||
EffectSize float64 `json:"effect_size"` // Taille de l'effet (Cohen's d)
|
||||
}
|
||||
|
||||
// ABTestStatsDifference représente la différence absolue entre deux variants
|
||||
type ABTestStatsDifference struct {
|
||||
TotalSessions int64 `json:"total_sessions"`
|
||||
TotalPlayTime int64 `json:"total_play_time"` // seconds
|
||||
AveragePlayTime float64 `json:"average_play_time"` // seconds
|
||||
TotalPauses int64 `json:"total_pauses"`
|
||||
AveragePauses float64 `json:"average_pauses"`
|
||||
TotalSeeks int64 `json:"total_seeks"`
|
||||
AverageSeeks float64 `json:"average_seeks"`
|
||||
AverageCompletion float64 `json:"average_completion"` // percentage
|
||||
CompletionRate float64 `json:"completion_rate"` // percentage
|
||||
}
|
||||
|
||||
// ABTestPercentageChange représente le changement en pourcentage entre deux variants
|
||||
type ABTestPercentageChange struct {
|
||||
TotalSessions float64 `json:"total_sessions"`
|
||||
TotalPlayTime float64 `json:"total_play_time"`
|
||||
AveragePlayTime float64 `json:"average_play_time"`
|
||||
TotalPauses float64 `json:"total_pauses"`
|
||||
AveragePauses float64 `json:"average_pauses"`
|
||||
TotalSeeks float64 `json:"total_seeks"`
|
||||
AverageSeeks float64 `json:"average_seeks"`
|
||||
AverageCompletion float64 `json:"average_completion"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
}
|
||||
|
||||
// ABTestResult représente le résultat d'un test A/B
|
||||
type ABTestResult struct {
|
||||
VariantA *VariantStats `json:"variant_a"`
|
||||
VariantB *VariantStats `json:"variant_b"`
|
||||
Difference *ABTestStatsDifference `json:"difference"`
|
||||
PercentageChange *ABTestPercentageChange `json:"percentage_change"`
|
||||
Significance *StatisticalSignificance `json:"significance"`
|
||||
Winner string `json:"winner,omitempty"` // "A", "B", ou "inconclusive"
|
||||
Recommendation string `json:"recommendation,omitempty"` // Recommandation basée sur les résultats
|
||||
AnalyzedAt time.Time `json:"analyzed_at"`
|
||||
}
|
||||
|
||||
// CompareVariants compare deux variants et calcule la significativité statistique
|
||||
// T0379: Create Playback Analytics A/B Testing Support
|
||||
func (s *PlaybackABTestService) CompareVariants(ctx context.Context, variantA, variantB string, filterA, filterB VariantFilter) (*ABTestResult, error) {
|
||||
if variantA == "" || variantB == "" {
|
||||
return nil, fmt.Errorf("variant names cannot be empty")
|
||||
}
|
||||
|
||||
// Récupérer les analytics pour le variant A
|
||||
analyticsA, err := s.getAnalyticsForVariant(ctx, filterA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get analytics for variant A: %w", err)
|
||||
}
|
||||
|
||||
// Récupérer les analytics pour le variant B
|
||||
analyticsB, err := s.getAnalyticsForVariant(ctx, filterB)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get analytics for variant B: %w", err)
|
||||
}
|
||||
|
||||
// Calculer les statistiques pour chaque variant
|
||||
statsA := s.calculateVariantStats(variantA, analyticsA)
|
||||
statsB := s.calculateVariantStats(variantB, analyticsB)
|
||||
|
||||
// Calculer les différences
|
||||
difference := s.calculateDifference(statsA, statsB)
|
||||
percentageChange := s.calculatePercentageChange(statsA, statsB)
|
||||
|
||||
// Calculer la significativité statistique
|
||||
significance := s.calculateStatisticalSignificance(analyticsA, analyticsB)
|
||||
|
||||
// Déterminer le gagnant
|
||||
winner := s.determineWinner(statsA, statsB, significance)
|
||||
recommendation := s.generateRecommendation(statsA, statsB, significance)
|
||||
|
||||
result := &ABTestResult{
|
||||
VariantA: statsA,
|
||||
VariantB: statsB,
|
||||
Difference: difference,
|
||||
PercentageChange: percentageChange,
|
||||
Significance: significance,
|
||||
Winner: winner,
|
||||
Recommendation: recommendation,
|
||||
AnalyzedAt: time.Now(),
|
||||
}
|
||||
|
||||
s.logger.Info("Compared A/B test variants",
|
||||
zap.String("variant_a", variantA),
|
||||
zap.String("variant_b", variantB),
|
||||
zap.Int64("sessions_a", statsA.TotalSessions),
|
||||
zap.Int64("sessions_b", statsB.TotalSessions),
|
||||
zap.Bool("significant", significance.IsSignificant),
|
||||
zap.String("winner", winner))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getAnalyticsForVariant récupère les analytics pour un variant selon les filtres
|
||||
func (s *PlaybackABTestService) getAnalyticsForVariant(ctx context.Context, filter VariantFilter) ([]models.PlaybackAnalytics, error) {
|
||||
query := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{})
|
||||
|
||||
if filter.TrackID != nil && *filter.TrackID != uuid.Nil {
|
||||
query = query.Where("track_id = ?", *filter.TrackID)
|
||||
}
|
||||
|
||||
if filter.StartDate != nil {
|
||||
query = query.Where("created_at >= ?", *filter.StartDate)
|
||||
}
|
||||
|
||||
if filter.EndDate != nil {
|
||||
query = query.Where("created_at <= ?", *filter.EndDate)
|
||||
}
|
||||
|
||||
if len(filter.UserIDs) > 0 {
|
||||
query = query.Where("user_id IN ?", filter.UserIDs)
|
||||
}
|
||||
|
||||
if filter.MinPlayTime != nil && *filter.MinPlayTime > 0 {
|
||||
query = query.Where("play_time >= ?", *filter.MinPlayTime)
|
||||
}
|
||||
|
||||
var analytics []models.PlaybackAnalytics
|
||||
if err := query.Find(&analytics).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to query analytics: %w", err)
|
||||
}
|
||||
|
||||
return analytics, nil
|
||||
}
|
||||
|
||||
// calculateVariantStats calcule les statistiques pour un variant
|
||||
func (s *PlaybackABTestService) calculateVariantStats(variantName string, analytics []models.PlaybackAnalytics) *VariantStats {
|
||||
if len(analytics) == 0 {
|
||||
return &VariantStats{
|
||||
VariantName: variantName,
|
||||
}
|
||||
}
|
||||
|
||||
var totalSessions int64
|
||||
var totalPlayTime int64
|
||||
var totalCompletion float64
|
||||
var totalPauses int64
|
||||
var totalSeeks int64
|
||||
var completedSessions int64
|
||||
|
||||
for _, a := range analytics {
|
||||
totalSessions++
|
||||
totalPlayTime += int64(a.PlayTime)
|
||||
totalCompletion += a.CompletionRate
|
||||
totalPauses += int64(a.PauseCount)
|
||||
totalSeeks += int64(a.SeekCount)
|
||||
if a.CompletionRate >= 90.0 {
|
||||
completedSessions++
|
||||
}
|
||||
}
|
||||
|
||||
sessionCount := float64(totalSessions)
|
||||
stats := &VariantStats{
|
||||
VariantName: variantName,
|
||||
TotalSessions: totalSessions,
|
||||
TotalPlayTime: totalPlayTime,
|
||||
AveragePlayTime: float64(totalPlayTime) / sessionCount,
|
||||
AverageCompletion: totalCompletion / sessionCount,
|
||||
CompletionRate: float64(completedSessions) / sessionCount * 100.0,
|
||||
AveragePauses: float64(totalPauses) / sessionCount,
|
||||
AverageSeeks: float64(totalSeeks) / sessionCount,
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// calculateDifference calcule la différence absolue entre deux variants
|
||||
func (s *PlaybackABTestService) calculateDifference(statsA, statsB *VariantStats) *ABTestStatsDifference {
|
||||
return &ABTestStatsDifference{
|
||||
TotalSessions: statsB.TotalSessions - statsA.TotalSessions,
|
||||
TotalPlayTime: statsB.TotalPlayTime - statsA.TotalPlayTime,
|
||||
AveragePlayTime: statsB.AveragePlayTime - statsA.AveragePlayTime,
|
||||
TotalPauses: int64(statsB.AveragePauses*float64(statsB.TotalSessions)) - int64(statsA.AveragePauses*float64(statsA.TotalSessions)),
|
||||
AveragePauses: statsB.AveragePauses - statsA.AveragePauses,
|
||||
TotalSeeks: int64(statsB.AverageSeeks*float64(statsB.TotalSessions)) - int64(statsA.AverageSeeks*float64(statsA.TotalSessions)),
|
||||
AverageSeeks: statsB.AverageSeeks - statsA.AverageSeeks,
|
||||
AverageCompletion: statsB.AverageCompletion - statsA.AverageCompletion,
|
||||
CompletionRate: statsB.CompletionRate - statsA.CompletionRate,
|
||||
}
|
||||
}
|
||||
|
||||
// calculatePercentageChange calcule le changement en pourcentage entre deux variants
|
||||
func (s *PlaybackABTestService) calculatePercentageChange(statsA, statsB *VariantStats) *ABTestPercentageChange {
|
||||
return &ABTestPercentageChange{
|
||||
TotalSessions: s.safePercentageChange(float64(statsA.TotalSessions), float64(statsB.TotalSessions)),
|
||||
TotalPlayTime: s.safePercentageChange(float64(statsA.TotalPlayTime), float64(statsB.TotalPlayTime)),
|
||||
AveragePlayTime: s.safePercentageChange(statsA.AveragePlayTime, statsB.AveragePlayTime),
|
||||
TotalPauses: s.safePercentageChange(statsA.AveragePauses*float64(statsA.TotalSessions), statsB.AveragePauses*float64(statsB.TotalSessions)),
|
||||
AveragePauses: s.safePercentageChange(statsA.AveragePauses, statsB.AveragePauses),
|
||||
TotalSeeks: s.safePercentageChange(statsA.AverageSeeks*float64(statsA.TotalSessions), statsB.AverageSeeks*float64(statsB.TotalSessions)),
|
||||
AverageSeeks: s.safePercentageChange(statsA.AverageSeeks, statsB.AverageSeeks),
|
||||
AverageCompletion: s.safePercentageChange(statsA.AverageCompletion, statsB.AverageCompletion),
|
||||
CompletionRate: s.safePercentageChange(statsA.CompletionRate, statsB.CompletionRate),
|
||||
}
|
||||
}
|
||||
|
||||
// safePercentageChange calcule le changement en pourcentage en gérant la division par zéro
|
||||
func (s *PlaybackABTestService) safePercentageChange(base, current float64) float64 {
|
||||
if base == 0 {
|
||||
if current == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return math.Inf(1) // Infini si la base est zéro et le courant est non-zéro
|
||||
}
|
||||
return ((current - base) / base) * 100.0
|
||||
}
|
||||
|
||||
// calculateStatisticalSignificance calcule la significativité statistique entre deux variants
|
||||
// Utilise un test t de Student pour comparer les moyennes de completion rate
|
||||
func (s *PlaybackABTestService) calculateStatisticalSignificance(analyticsA, analyticsB []models.PlaybackAnalytics) *StatisticalSignificance {
|
||||
if len(analyticsA) == 0 || len(analyticsB) == 0 {
|
||||
return &StatisticalSignificance{
|
||||
PValue: 1.0,
|
||||
IsSignificant: false,
|
||||
ConfidenceLevel: 95.0,
|
||||
EffectSize: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire les completion rates
|
||||
completionRatesA := make([]float64, len(analyticsA))
|
||||
for i, a := range analyticsA {
|
||||
completionRatesA[i] = a.CompletionRate
|
||||
}
|
||||
|
||||
completionRatesB := make([]float64, len(analyticsB))
|
||||
for i, a := range analyticsB {
|
||||
completionRatesB[i] = a.CompletionRate
|
||||
}
|
||||
|
||||
// Calculer les moyennes et écarts-types
|
||||
meanA, stdDevA := s.calculateMeanAndStdDev(completionRatesA)
|
||||
meanB, stdDevB := s.calculateMeanAndStdDev(completionRatesB)
|
||||
|
||||
// Calculer le test t de Student
|
||||
pValue := s.calculateTTest(completionRatesA, completionRatesB, meanA, meanB, stdDevA, stdDevB)
|
||||
|
||||
// Calculer l'intervalle de confiance à 95%
|
||||
confidenceLevel := 95.0
|
||||
seA := stdDevA / math.Sqrt(float64(len(completionRatesA)))
|
||||
seB := stdDevB / math.Sqrt(float64(len(completionRatesB)))
|
||||
tValue := 1.96 // Pour un intervalle de confiance à 95%
|
||||
|
||||
diff := meanB - meanA
|
||||
seDiff := math.Sqrt(seA*seA + seB*seB)
|
||||
confidenceIntervalLower := diff - tValue*seDiff
|
||||
confidenceIntervalUpper := diff + tValue*seDiff
|
||||
|
||||
// Calculer la taille de l'effet (Cohen's d)
|
||||
pooledStdDev := math.Sqrt((stdDevA*stdDevA + stdDevB*stdDevB) / 2.0)
|
||||
effectSize := 0.0
|
||||
if pooledStdDev > 0 {
|
||||
effectSize = (meanB - meanA) / pooledStdDev
|
||||
}
|
||||
|
||||
return &StatisticalSignificance{
|
||||
PValue: pValue,
|
||||
IsSignificant: pValue < 0.05,
|
||||
ConfidenceLevel: confidenceLevel,
|
||||
ConfidenceIntervalLower: confidenceIntervalLower,
|
||||
ConfidenceIntervalUpper: confidenceIntervalUpper,
|
||||
EffectSize: effectSize,
|
||||
}
|
||||
}
|
||||
|
||||
// calculateMeanAndStdDev calcule la moyenne et l'écart-type
|
||||
func (s *PlaybackABTestService) calculateMeanAndStdDev(data []float64) (mean, stdDev float64) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Calcul de la moyenne
|
||||
var sum float64
|
||||
for _, v := range data {
|
||||
sum += v
|
||||
}
|
||||
mean = sum / float64(len(data))
|
||||
|
||||
// Calcul de l'écart-type
|
||||
var sumSqDiff float64
|
||||
for _, v := range data {
|
||||
diff := v - mean
|
||||
sumSqDiff += diff * diff
|
||||
}
|
||||
if len(data) > 1 {
|
||||
stdDev = math.Sqrt(sumSqDiff / float64(len(data)-1)) // Échantillon
|
||||
} else {
|
||||
stdDev = 0
|
||||
}
|
||||
|
||||
return mean, stdDev
|
||||
}
|
||||
|
||||
// calculateTTest calcule la p-value d'un test t de Student
|
||||
// Approximation simplifiée pour deux échantillons indépendants
|
||||
func (s *PlaybackABTestService) calculateTTest(dataA, dataB []float64, meanA, meanB, stdDevA, stdDevB float64) float64 {
|
||||
nA := float64(len(dataA))
|
||||
nB := float64(len(dataB))
|
||||
|
||||
if nA < 2 || nB < 2 {
|
||||
return 1.0 // Pas assez de données pour un test significatif
|
||||
}
|
||||
|
||||
// Calcul de l'erreur standard de la différence
|
||||
seA := stdDevA / math.Sqrt(nA)
|
||||
seB := stdDevB / math.Sqrt(nB)
|
||||
seDiff := math.Sqrt(seA*seA + seB*seB)
|
||||
|
||||
if seDiff == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Calcul de la statistique t
|
||||
tStat := (meanB - meanA) / seDiff
|
||||
|
||||
// Calcul des degrés de liberté (approximation de Welch)
|
||||
_ = s.calculateWelchDF(seA, seB, nA, nB) // Calculé mais non utilisé dans l'approximation normale
|
||||
|
||||
// Approximation de la p-value (test bilatéral)
|
||||
// Utilisation d'une approximation normale pour simplifier
|
||||
// En production, on utiliserait une table t ou une fonction de distribution
|
||||
pValue := 2.0 * (1.0 - s.normalCDF(math.Abs(tStat)))
|
||||
|
||||
return pValue
|
||||
}
|
||||
|
||||
// calculateWelchDF calcule les degrés de liberté pour le test t de Welch
|
||||
func (s *PlaybackABTestService) calculateWelchDF(seA, seB, nA, nB float64) float64 {
|
||||
if seA == 0 && seB == 0 {
|
||||
return nA + nB - 2
|
||||
}
|
||||
if seA == 0 {
|
||||
return nB - 1
|
||||
}
|
||||
if seB == 0 {
|
||||
return nA - 1
|
||||
}
|
||||
|
||||
numerator := math.Pow(seA*seA+seB*seB, 2)
|
||||
denominator := math.Pow(seA*seA, 2)/(nA-1) + math.Pow(seB*seB, 2)/(nB-1)
|
||||
|
||||
if denominator == 0 {
|
||||
return nA + nB - 2
|
||||
}
|
||||
|
||||
return numerator / denominator
|
||||
}
|
||||
|
||||
// normalCDF calcule la fonction de répartition cumulative de la distribution normale standard
|
||||
// Approximation utilisant la fonction d'erreur
|
||||
func (s *PlaybackABTestService) normalCDF(x float64) float64 {
|
||||
return 0.5 * (1.0 + s.erf(x/math.Sqrt2))
|
||||
}
|
||||
|
||||
// erf calcule la fonction d'erreur (approximation)
|
||||
func (s *PlaybackABTestService) erf(x float64) float64 {
|
||||
// Approximation de la fonction d'erreur
|
||||
// Formule d'Abramowitz et Stegun
|
||||
a1 := 0.254829592
|
||||
a2 := -0.284496736
|
||||
a3 := 1.421413741
|
||||
a4 := -1.453152027
|
||||
a5 := 1.061405429
|
||||
p := 0.3275911
|
||||
|
||||
sign := 1.0
|
||||
if x < 0 {
|
||||
sign = -1.0
|
||||
x = -x
|
||||
}
|
||||
|
||||
t := 1.0 / (1.0 + p*x)
|
||||
y := 1.0 - (((((a5*t+a4)*t)+a3)*t+a2)*t+a1)*t*math.Exp(-x*x)
|
||||
|
||||
return sign * y
|
||||
}
|
||||
|
||||
// determineWinner détermine le gagnant du test A/B
|
||||
func (s *PlaybackABTestService) determineWinner(statsA, statsB *VariantStats, significance *StatisticalSignificance) string {
|
||||
if !significance.IsSignificant {
|
||||
return "inconclusive"
|
||||
}
|
||||
|
||||
// Le gagnant est déterminé par le completion rate le plus élevé
|
||||
if statsB.CompletionRate > statsA.CompletionRate {
|
||||
return "B"
|
||||
} else if statsA.CompletionRate > statsB.CompletionRate {
|
||||
return "A"
|
||||
}
|
||||
|
||||
return "inconclusive"
|
||||
}
|
||||
|
||||
// generateRecommendation génère une recommandation basée sur les résultats
|
||||
func (s *PlaybackABTestService) generateRecommendation(statsA, statsB *VariantStats, significance *StatisticalSignificance) string {
|
||||
if !significance.IsSignificant {
|
||||
return "Les résultats ne sont pas statistiquement significatifs. Continuer le test ou augmenter la taille de l'échantillon."
|
||||
}
|
||||
|
||||
if statsB.CompletionRate > statsA.CompletionRate {
|
||||
improvement := ((statsB.CompletionRate - statsA.CompletionRate) / statsA.CompletionRate) * 100.0
|
||||
return fmt.Sprintf("Le variant B est significativement meilleur avec une amélioration de %.2f%% du taux de complétion.", improvement)
|
||||
} else if statsA.CompletionRate > statsB.CompletionRate {
|
||||
improvement := ((statsA.CompletionRate - statsB.CompletionRate) / statsB.CompletionRate) * 100.0
|
||||
return fmt.Sprintf("Le variant A est significativement meilleur avec une amélioration de %.2f%% du taux de complétion.", improvement)
|
||||
}
|
||||
|
||||
return "Aucune différence significative entre les variants."
|
||||
}
|
||||
|
|
@ -1,579 +0,0 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
)
|
||||
|
||||
func setupTestPlaybackABTestServiceDB(t *testing.T) (*gorm.DB, *PlaybackABTestService) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
db.Exec("PRAGMA foreign_keys = ON")
|
||||
|
||||
err = db.AutoMigrate(&models.User{}, &models.Track{}, &models.PlaybackAnalytics{})
|
||||
require.NoError(t, err)
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
service := NewPlaybackABTestService(db, logger)
|
||||
|
||||
return db, service
|
||||
}
|
||||
|
||||
func TestNewPlaybackABTestService(t *testing.T) {
|
||||
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
logger := zaptest.NewLogger(t)
|
||||
|
||||
service := NewPlaybackABTestService(db, logger)
|
||||
|
||||
assert.NotNil(t, service)
|
||||
assert.Equal(t, db, service.db)
|
||||
assert.NotNil(t, service.logger)
|
||||
}
|
||||
|
||||
func TestNewPlaybackABTestService_NilLogger(t *testing.T) {
|
||||
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
service := NewPlaybackABTestService(db, nil)
|
||||
|
||||
assert.NotNil(t, service)
|
||||
assert.NotNil(t, service.logger)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CompareVariants_EmptyVariantNames(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
filterA := VariantFilter{}
|
||||
filterB := VariantFilter{}
|
||||
|
||||
result, err := service.CompareVariants(ctx, "", "B", filterA, filterB)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "variant names cannot be empty")
|
||||
assert.Nil(t, result)
|
||||
|
||||
result, err = service.CompareVariants(ctx, "A", "", filterA, filterB)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "variant names cannot be empty")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CompareVariants_NoData(t *testing.T) {
|
||||
db, service := setupTestPlaybackABTestServiceDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Créer user et track
|
||||
userID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
user := &models.User{ID: userID, Username: "testuser", Slug: "testuser", Email: "test@example.com", IsActive: true}
|
||||
db.Create(user)
|
||||
track := &models.Track{
|
||||
ID: trackID,
|
||||
UserID: userID,
|
||||
Title: "Test Track",
|
||||
FilePath: "/test.mp3",
|
||||
FileSize: 1024,
|
||||
Format: "MP3",
|
||||
Duration: 180,
|
||||
IsPublic: true,
|
||||
Status: models.TrackStatusCompleted,
|
||||
}
|
||||
db.Create(track)
|
||||
|
||||
filterA := VariantFilter{TrackID: &trackID}
|
||||
filterB := VariantFilter{TrackID: &trackID}
|
||||
|
||||
result, err := service.CompareVariants(ctx, "A", "B", filterA, filterB)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "A", result.VariantA.VariantName)
|
||||
assert.Equal(t, "B", result.VariantB.VariantName)
|
||||
assert.Equal(t, int64(0), result.VariantA.TotalSessions)
|
||||
assert.Equal(t, int64(0), result.VariantB.TotalSessions)
|
||||
assert.NotNil(t, result.Significance)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CompareVariants_WithData(t *testing.T) {
|
||||
db, service := setupTestPlaybackABTestServiceDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Créer users et track
|
||||
user1ID := uuid.New()
|
||||
user2ID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
user1 := &models.User{ID: user1ID, Username: "user1", Slug: "user1", Email: "user1@example.com", IsActive: true}
|
||||
user2 := &models.User{ID: user2ID, Username: "user2", Slug: "user2", Email: "user2@example.com", IsActive: true}
|
||||
db.Create(user1)
|
||||
db.Create(user2)
|
||||
track := &models.Track{
|
||||
ID: trackID,
|
||||
UserID: user1ID,
|
||||
Title: "Test Track",
|
||||
FilePath: "/test.mp3",
|
||||
FileSize: 1024,
|
||||
Format: "MP3",
|
||||
Duration: 180,
|
||||
IsPublic: true,
|
||||
Status: models.TrackStatusCompleted,
|
||||
}
|
||||
db.Create(track)
|
||||
|
||||
now := time.Now()
|
||||
// Variant A: High completion
|
||||
for i := 0; i < 10; i++ {
|
||||
db.Create(&models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: user1ID,
|
||||
PlayTime: 180,
|
||||
PauseCount: 0,
|
||||
SeekCount: 0,
|
||||
CompletionRate: 100.0,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Variant B: Lower completion
|
||||
for i := 0; i < 10; i++ {
|
||||
db.Create(&models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: user2ID,
|
||||
PlayTime: 90,
|
||||
PauseCount: 2,
|
||||
SeekCount: 1,
|
||||
CompletionRate: 50.0,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
filterA := VariantFilter{TrackID: &trackID, UserIDs: []uuid.UUID{user1ID}}
|
||||
filterB := VariantFilter{TrackID: &trackID, UserIDs: []uuid.UUID{user2ID}}
|
||||
|
||||
result, err := service.CompareVariants(ctx, "A", "B", filterA, filterB)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "A", result.VariantA.VariantName)
|
||||
assert.Equal(t, "B", result.VariantB.VariantName)
|
||||
assert.Equal(t, int64(10), result.VariantA.TotalSessions)
|
||||
assert.Equal(t, int64(10), result.VariantB.TotalSessions)
|
||||
assert.Equal(t, 100.0, result.VariantA.AverageCompletion)
|
||||
assert.Equal(t, 50.0, result.VariantB.AverageCompletion)
|
||||
assert.NotNil(t, result.Significance)
|
||||
assert.NotNil(t, result.Difference)
|
||||
assert.NotNil(t, result.PercentageChange)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CalculateVariantStats(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
analytics := []models.PlaybackAnalytics{
|
||||
{PlayTime: 180, PauseCount: 0, SeekCount: 0, CompletionRate: 100.0},
|
||||
{PlayTime: 180, PauseCount: 1, SeekCount: 0, CompletionRate: 95.0},
|
||||
{PlayTime: 90, PauseCount: 2, SeekCount: 1, CompletionRate: 50.0},
|
||||
}
|
||||
|
||||
stats := service.calculateVariantStats("TestVariant", analytics)
|
||||
|
||||
assert.NotNil(t, stats)
|
||||
assert.Equal(t, "TestVariant", stats.VariantName)
|
||||
assert.Equal(t, int64(3), stats.TotalSessions)
|
||||
assert.InDelta(t, 150.0, stats.AveragePlayTime, 0.1) // (180 + 180 + 90) / 3
|
||||
assert.InDelta(t, 81.67, stats.AverageCompletion, 0.1) // (100 + 95 + 50) / 3
|
||||
assert.Equal(t, 1.0, stats.AveragePauses) // (0 + 1 + 2) / 3
|
||||
assert.InDelta(t, 0.33, stats.AverageSeeks, 0.1) // (0 + 0 + 1) / 3
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CalculateVariantStats_Empty(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
analytics := []models.PlaybackAnalytics{}
|
||||
stats := service.calculateVariantStats("EmptyVariant", analytics)
|
||||
|
||||
assert.NotNil(t, stats)
|
||||
assert.Equal(t, "EmptyVariant", stats.VariantName)
|
||||
assert.Equal(t, int64(0), stats.TotalSessions)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CalculateStatisticalSignificance(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
// Variant A: High completion (tous à 100%)
|
||||
analyticsA := []models.PlaybackAnalytics{
|
||||
{CompletionRate: 100.0},
|
||||
{CompletionRate: 100.0},
|
||||
{CompletionRate: 100.0},
|
||||
{CompletionRate: 100.0},
|
||||
{CompletionRate: 100.0},
|
||||
}
|
||||
|
||||
// Variant B: Lower completion (tous à 50%)
|
||||
analyticsB := []models.PlaybackAnalytics{
|
||||
{CompletionRate: 50.0},
|
||||
{CompletionRate: 50.0},
|
||||
{CompletionRate: 50.0},
|
||||
{CompletionRate: 50.0},
|
||||
{CompletionRate: 50.0},
|
||||
}
|
||||
|
||||
significance := service.calculateStatisticalSignificance(analyticsA, analyticsB)
|
||||
|
||||
assert.NotNil(t, significance)
|
||||
assert.GreaterOrEqual(t, significance.PValue, 0.0)
|
||||
assert.LessOrEqual(t, significance.PValue, 1.0)
|
||||
assert.Greater(t, significance.ConfidenceLevel, 0.0)
|
||||
// EffectSize peut être 0 si les écarts-types sont 0 (toutes les valeurs identiques)
|
||||
// Dans ce cas, on vérifie juste qu'il n'est pas NaN
|
||||
assert.False(t, math.IsNaN(significance.EffectSize))
|
||||
assert.False(t, math.IsInf(significance.EffectSize, 0))
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CalculateStatisticalSignificance_Empty(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
analyticsA := []models.PlaybackAnalytics{}
|
||||
analyticsB := []models.PlaybackAnalytics{}
|
||||
|
||||
significance := service.calculateStatisticalSignificance(analyticsA, analyticsB)
|
||||
|
||||
assert.NotNil(t, significance)
|
||||
assert.Equal(t, 1.0, significance.PValue)
|
||||
assert.False(t, significance.IsSignificant)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CalculateMeanAndStdDev(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
data := []float64{10.0, 20.0, 30.0, 40.0, 50.0}
|
||||
mean, stdDev := service.calculateMeanAndStdDev(data)
|
||||
|
||||
assert.Equal(t, 30.0, mean)
|
||||
assert.Greater(t, stdDev, 0.0)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CalculateMeanAndStdDev_Empty(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
data := []float64{}
|
||||
mean, stdDev := service.calculateMeanAndStdDev(data)
|
||||
|
||||
assert.Equal(t, 0.0, mean)
|
||||
assert.Equal(t, 0.0, stdDev)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_DetermineWinner(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
statsA := &VariantStats{CompletionRate: 80.0}
|
||||
statsB := &VariantStats{CompletionRate: 90.0}
|
||||
significance := &StatisticalSignificance{IsSignificant: true, PValue: 0.01}
|
||||
|
||||
winner := service.determineWinner(statsA, statsB, significance)
|
||||
assert.Equal(t, "B", winner)
|
||||
|
||||
// Test avec A gagnant
|
||||
statsA2 := &VariantStats{CompletionRate: 90.0}
|
||||
statsB2 := &VariantStats{CompletionRate: 80.0}
|
||||
winner2 := service.determineWinner(statsA2, statsB2, significance)
|
||||
assert.Equal(t, "A", winner2)
|
||||
|
||||
// Test non significatif
|
||||
significance2 := &StatisticalSignificance{IsSignificant: false, PValue: 0.1}
|
||||
winner3 := service.determineWinner(statsA, statsB, significance2)
|
||||
assert.Equal(t, "inconclusive", winner3)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_GenerateRecommendation(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
statsA := &VariantStats{CompletionRate: 80.0}
|
||||
statsB := &VariantStats{CompletionRate: 90.0}
|
||||
significance := &StatisticalSignificance{IsSignificant: true, PValue: 0.01}
|
||||
|
||||
recommendation := service.generateRecommendation(statsA, statsB, significance)
|
||||
assert.NotEmpty(t, recommendation)
|
||||
assert.Contains(t, recommendation, "variant B")
|
||||
assert.Contains(t, recommendation, "significativement meilleur")
|
||||
|
||||
// Test avec A gagnant
|
||||
statsA2 := &VariantStats{CompletionRate: 90.0}
|
||||
statsB2 := &VariantStats{CompletionRate: 80.0}
|
||||
recommendation2 := service.generateRecommendation(statsA2, statsB2, significance)
|
||||
assert.Contains(t, recommendation2, "variant A")
|
||||
|
||||
// Test non significatif
|
||||
significance2 := &StatisticalSignificance{IsSignificant: false, PValue: 0.1}
|
||||
recommendation3 := service.generateRecommendation(statsA, statsB, significance2)
|
||||
assert.Contains(t, recommendation3, "pas statistiquement significatifs")
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_GetAnalyticsForVariant(t *testing.T) {
|
||||
db, service := setupTestPlaybackABTestServiceDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Créer user et track
|
||||
userID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
user := &models.User{ID: userID, Username: "testuser", Slug: "testuser", Email: "test@example.com", IsActive: true}
|
||||
db.Create(user)
|
||||
track := &models.Track{
|
||||
ID: trackID,
|
||||
UserID: userID,
|
||||
Title: "Test Track",
|
||||
FilePath: "/test.mp3",
|
||||
FileSize: 1024,
|
||||
Format: "MP3",
|
||||
Duration: 180,
|
||||
IsPublic: true,
|
||||
Status: models.TrackStatusCompleted,
|
||||
}
|
||||
db.Create(track)
|
||||
|
||||
now := time.Now()
|
||||
analytics := &models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: userID,
|
||||
PlayTime: 180,
|
||||
PauseCount: 0,
|
||||
SeekCount: 0,
|
||||
CompletionRate: 100.0,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
db.Create(analytics)
|
||||
|
||||
filter := VariantFilter{TrackID: &trackID}
|
||||
result, err := service.getAnalyticsForVariant(ctx, filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 1, len(result))
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_GetAnalyticsForVariant_WithDateFilter(t *testing.T) {
|
||||
db, service := setupTestPlaybackABTestServiceDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Créer user et track
|
||||
userID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
user := &models.User{ID: userID, Username: "testuser", Slug: "testuser", Email: "test@example.com", IsActive: true}
|
||||
db.Create(user)
|
||||
track := &models.Track{
|
||||
ID: trackID,
|
||||
UserID: userID,
|
||||
Title: "Test Track",
|
||||
FilePath: "/test.mp3",
|
||||
FileSize: 1024,
|
||||
Format: "MP3",
|
||||
Duration: 180,
|
||||
IsPublic: true,
|
||||
Status: models.TrackStatusCompleted,
|
||||
}
|
||||
db.Create(track)
|
||||
|
||||
now := time.Now()
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
|
||||
// Analytics créé aujourd'hui
|
||||
analytics := &models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: userID,
|
||||
PlayTime: 180,
|
||||
PauseCount: 0,
|
||||
SeekCount: 0,
|
||||
CompletionRate: 100.0,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
db.Create(analytics)
|
||||
|
||||
// Filtrer par date (hier à demain) - devrait inclure l'analytics
|
||||
filter := VariantFilter{
|
||||
TrackID: &trackID,
|
||||
StartDate: &yesterday,
|
||||
EndDate: &tomorrow,
|
||||
}
|
||||
result, err := service.getAnalyticsForVariant(ctx, filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(result))
|
||||
|
||||
// Filtrer par date (avant-hier à hier) - ne devrait pas inclure l'analytics
|
||||
dayBeforeYesterday := now.AddDate(0, 0, -2)
|
||||
filter2 := VariantFilter{
|
||||
TrackID: &trackID,
|
||||
StartDate: &dayBeforeYesterday,
|
||||
EndDate: &yesterday,
|
||||
}
|
||||
result2, err := service.getAnalyticsForVariant(ctx, filter2)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(result2))
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_GetAnalyticsForVariant_WithUserFilter(t *testing.T) {
|
||||
db, service := setupTestPlaybackABTestServiceDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Créer users et track
|
||||
user1ID := uuid.New()
|
||||
user2ID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
user1 := &models.User{ID: user1ID, Username: "user1", Slug: "user1", Email: "user1@example.com", IsActive: true}
|
||||
user2 := &models.User{ID: user2ID, Username: "user2", Slug: "user2", Email: "user2@example.com", IsActive: true}
|
||||
db.Create(user1)
|
||||
db.Create(user2)
|
||||
track := &models.Track{
|
||||
ID: trackID,
|
||||
UserID: user1ID,
|
||||
Title: "Test Track",
|
||||
FilePath: "/test.mp3",
|
||||
FileSize: 1024,
|
||||
Format: "MP3",
|
||||
Duration: 180,
|
||||
IsPublic: true,
|
||||
Status: models.TrackStatusCompleted,
|
||||
}
|
||||
db.Create(track)
|
||||
|
||||
now := time.Now()
|
||||
analytics1 := &models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: user1ID,
|
||||
PlayTime: 180,
|
||||
CompletionRate: 100.0,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
analytics2 := &models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: user2ID,
|
||||
PlayTime: 90,
|
||||
CompletionRate: 50.0,
|
||||
StartedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
db.Create(analytics1)
|
||||
db.Create(analytics2)
|
||||
|
||||
// Filtrer par user 1 seulement
|
||||
filter := VariantFilter{
|
||||
TrackID: &trackID,
|
||||
UserIDs: []uuid.UUID{user1ID},
|
||||
}
|
||||
result, err := service.getAnalyticsForVariant(ctx, filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(result))
|
||||
assert.Equal(t, user1ID, result[0].UserID)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_SafePercentageChange(t *testing.T) {
|
||||
_, service := setupTestPlaybackABTestServiceDB(t)
|
||||
|
||||
// Test normal
|
||||
result := service.safePercentageChange(100.0, 120.0)
|
||||
assert.Equal(t, 20.0, result)
|
||||
|
||||
// Test avec base zéro et courant non-zéro
|
||||
result2 := service.safePercentageChange(0.0, 100.0)
|
||||
assert.True(t, math.IsInf(result2, 1))
|
||||
|
||||
// Test avec base zéro et courant zéro
|
||||
result3 := service.safePercentageChange(0.0, 0.0)
|
||||
assert.Equal(t, 0.0, result3)
|
||||
|
||||
// Test négatif
|
||||
result4 := service.safePercentageChange(100.0, 80.0)
|
||||
assert.Equal(t, -20.0, result4)
|
||||
}
|
||||
|
||||
func TestPlaybackABTestService_CompareVariants_WithDateRange(t *testing.T) {
|
||||
db, service := setupTestPlaybackABTestServiceDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Créer user et track
|
||||
userID := uuid.New()
|
||||
trackID := uuid.New()
|
||||
user := &models.User{ID: userID, Username: "testuser", Slug: "testuser", Email: "test@example.com", IsActive: true}
|
||||
db.Create(user)
|
||||
track := &models.Track{
|
||||
ID: trackID,
|
||||
UserID: userID,
|
||||
Title: "Test Track",
|
||||
FilePath: "/test.mp3",
|
||||
FileSize: 1024,
|
||||
Format: "MP3",
|
||||
Duration: 180,
|
||||
IsPublic: true,
|
||||
Status: models.TrackStatusCompleted,
|
||||
}
|
||||
db.Create(track)
|
||||
|
||||
now := time.Now()
|
||||
weekAgo := now.AddDate(0, 0, -7)
|
||||
threeDaysAgo := now.AddDate(0, 0, -3)
|
||||
|
||||
// Analytics pour variant A (il y a une semaine)
|
||||
analyticsA := &models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: userID,
|
||||
PlayTime: 180,
|
||||
CompletionRate: 100.0,
|
||||
StartedAt: weekAgo,
|
||||
CreatedAt: weekAgo,
|
||||
}
|
||||
db.Create(analyticsA)
|
||||
|
||||
// Analytics pour variant B (il y a 3 jours)
|
||||
analyticsB := &models.PlaybackAnalytics{
|
||||
TrackID: trackID,
|
||||
UserID: userID,
|
||||
PlayTime: 90,
|
||||
CompletionRate: 50.0,
|
||||
StartedAt: threeDaysAgo,
|
||||
CreatedAt: threeDaysAgo,
|
||||
}
|
||||
db.Create(analyticsB)
|
||||
|
||||
// Filtrer variant A par période (il y a 8 jours à 6 jours)
|
||||
eightDaysAgo := now.AddDate(0, 0, -8)
|
||||
sixDaysAgo := now.AddDate(0, 0, -6)
|
||||
filterA := VariantFilter{
|
||||
TrackID: &trackID,
|
||||
StartDate: &eightDaysAgo,
|
||||
EndDate: &sixDaysAgo,
|
||||
}
|
||||
|
||||
// Filtrer variant B par période (il y a 4 jours à 2 jours)
|
||||
fourDaysAgo := now.AddDate(0, 0, -4)
|
||||
twoDaysAgo := now.AddDate(0, 0, -2)
|
||||
filterB := VariantFilter{
|
||||
TrackID: &trackID,
|
||||
StartDate: &fourDaysAgo,
|
||||
EndDate: &twoDaysAgo,
|
||||
}
|
||||
|
||||
result, err := service.CompareVariants(ctx, "A", "B", filterA, filterB)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, int64(1), result.VariantA.TotalSessions)
|
||||
assert.Equal(t, int64(1), result.VariantB.TotalSessions)
|
||||
}
|
||||
Loading…
Reference in a new issue