diff --git a/VEZA_VERSIONS_ROADMAP.md b/VEZA_VERSIONS_ROADMAP.md index 5c9ee882e..8c535feb5 100644 --- a/VEZA_VERSIONS_ROADMAP.md +++ b/VEZA_VERSIONS_ROADMAP.md @@ -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 | diff --git a/veza-backend-api/internal/api/contest/handler.go b/veza-backend-api/internal/api/contest/handler.go deleted file mode 100644 index 7f2feea80..000000000 --- a/veza-backend-api/internal/api/contest/handler.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package contest - TO BE IMPLEMENTED -package contest diff --git a/veza-backend-api/internal/api/graphql/handler.go b/veza-backend-api/internal/api/graphql/handler.go deleted file mode 100644 index f060e76d3..000000000 --- a/veza-backend-api/internal/api/graphql/handler.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package graphql - TO BE IMPLEMENTED -package graphql diff --git a/veza-backend-api/internal/api/production_challenge/handler.go b/veza-backend-api/internal/api/production_challenge/handler.go deleted file mode 100644 index 88a69aa16..000000000 --- a/veza-backend-api/internal/api/production_challenge/handler.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package production_challenge - TO BE IMPLEMENTED -package production_challenge diff --git a/veza-backend-api/internal/api/sound_design_contest/handler.go b/veza-backend-api/internal/api/sound_design_contest/handler.go deleted file mode 100644 index 9cceeb641..000000000 --- a/veza-backend-api/internal/api/sound_design_contest/handler.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package sound_design_contest - TO BE IMPLEMENTED -package sound_design_contest diff --git a/veza-backend-api/internal/api/voting_system/handler.go b/veza-backend-api/internal/api/voting_system/handler.go deleted file mode 100644 index 9597de447..000000000 --- a/veza-backend-api/internal/api/voting_system/handler.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package voting_system - TO BE IMPLEMENTED -package voting_system diff --git a/veza-backend-api/internal/models/contest.go b/veza-backend-api/internal/models/contest.go deleted file mode 100644 index 155a5ff0a..000000000 --- a/veza-backend-api/internal/models/contest.go +++ /dev/null @@ -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 -} diff --git a/veza-backend-api/internal/models/user.go b/veza-backend-api/internal/models/user.go index c25c04c31..3e3f08d7e 100644 --- a/veza-backend-api/internal/models/user.go +++ b/veza-backend-api/internal/models/user.go @@ -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 -} diff --git a/veza-backend-api/internal/services/playback_abtest_service.go b/veza-backend-api/internal/services/playback_abtest_service.go deleted file mode 100644 index d5e75b685..000000000 --- a/veza-backend-api/internal/services/playback_abtest_service.go +++ /dev/null @@ -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." -} diff --git a/veza-backend-api/internal/services/playback_abtest_service_test.go b/veza-backend-api/internal/services/playback_abtest_service_test.go deleted file mode 100644 index 8e148d155..000000000 --- a/veza-backend-api/internal/services/playback_abtest_service_test.go +++ /dev/null @@ -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) -}