Merge branch 'feat/v0.12.6.3-nettoyage-fantome'

This commit is contained in:
senke 2026-03-12 07:30:18 +01:00
commit b156651662
10 changed files with 23 additions and 1407 deletions

View file

@ -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 |

View file

@ -1,2 +0,0 @@
// Package contest - TO BE IMPLEMENTED
package contest

View file

@ -1,2 +0,0 @@
// Package graphql - TO BE IMPLEMENTED
package graphql

View file

@ -1,2 +0,0 @@
// Package production_challenge - TO BE IMPLEMENTED
package production_challenge

View file

@ -1,2 +0,0 @@
// Package sound_design_contest - TO BE IMPLEMENTED
package sound_design_contest

View file

@ -1,2 +0,0 @@
// Package voting_system - TO BE IMPLEMENTED
package voting_system

View file

@ -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
}

View file

@ -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
}

View file

@ -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."
}

View file

@ -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)
}