diff --git a/api/.golangci.yml b/api/.golangci.yml index be245fde..ab6a579b 100644 --- a/api/.golangci.yml +++ b/api/.golangci.yml @@ -117,132 +117,13 @@ linters: #disable-all: true # Enable specific linter # https://golangci-lint.run/usage/linters/#enabled-by-default - enable: - - asasalint - - asciicheck - - bidichk - - bodyclose - - canonicalheader - - containedctx - - contextcheck - - copyloopvar - - cyclop - - decorder - - depguard - - dogsled - - dupl - - dupword - - durationcheck - - err113 - - errcheck - - errchkjson - - errname - - errorlint - - execinquery - - exhaustive - - exhaustruct - - exportloopref - - fatcontext - - forbidigo - - forcetypeassert - - funlen - - gci - - ginkgolinter - - gocheckcompilerdirectives - - gochecknoglobals - - gochecknoinits - - gochecksumtype - - gocognit - - goconst - - gocritic - - gocyclo - - godot - - godox - - gofmt - - gofumpt - - goheader - - goimports - - gomoddirectives - - gomodguard - - goprintffuncname - - gosec - - gosimple - - gosmopolitan - - govet - - grouper - - importas - - inamedparam - - ineffassign - - interfacebloat - - intrange - - ireturn - - lll - - loggercheck - - maintidx - - makezero - - mirror - - misspell - - mnd - - musttag - - nakedret - - nestif - - nilerr - - nilnil - - nlreturn - - noctx - - nolintlint - - nonamedreturns - - nosprintfhostport - - paralleltest - - perfsprint - - prealloc - - predeclared - - promlinter - - protogetter - - reassign - - revive - - rowserrcheck - - sloglint - - spancheck - - sqlclosecheck - - staticcheck - - stylecheck - - tagalign - - tagliatelle - - tenv - - testableexamples - - testifylint - - testpackage - - thelper - - tparallel - - typecheck - - unconvert - - unparam - - unused - - usestdlibvars - - varnamelen - - wastedassign - - whitespace - - wrapcheck - - wsl - - zerologlint + #enable: {} # Enable all available linters. # Default: false #enable-all: true # Disable specific linter # https://golangci-lint.run/usage/linters/#disabled-by-default - disable: - - deadcode # Deprecated - - exhaustivestruct # Deprecated - - golint # Deprecated - - ifshort # Deprecated - - interfacer # Deprecated - - maligned # Deprecated - - gomnd # Deprecated - - nosnakecase # Deprecated - - scopelint # Deprecated - - structcheck # Deprecated - - varcheck # Deprecated + #disable: {} # Enable presets. # https://golangci-lint.run/usage/linters # Default: [] diff --git a/api/go.mod b/api/go.mod index 0f1146e6..ef22a416 100644 --- a/api/go.mod +++ b/api/go.mod @@ -9,6 +9,7 @@ require ( github.com/buckket/go-blurhash v1.1.0 github.com/disintegration/imaging v1.6.2 github.com/go-sql-driver/mysql v1.9.0 + github.com/google/go-cmp v0.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 diff --git a/api/scanner/scanner_test.go b/api/scanner/scanner_test.go index f7e8e0cf..4ec90575 100644 --- a/api/scanner/scanner_test.go +++ b/api/scanner/scanner_test.go @@ -1,15 +1,20 @@ package scanner_test import ( + "context" + "fmt" "os" + "path/filepath" + "slices" + "strings" "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/photoview/photoview/api/graphql/models" "github.com/photoview/photoview/api/scanner/face_detection" "github.com/photoview/photoview/api/test_utils" scanner_utils "github.com/photoview/photoview/api/test_utils/scanner" - "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { @@ -22,8 +27,8 @@ func TestFullScan(t *testing.T) { pass := "1234" user, err := models.RegisterUser(db, "test_user", &pass, true) - if !assert.NoError(t, err) { - return + if err != nil { + t.Fatal("register user error:", err) } rootAlbum := models.Album{ @@ -31,52 +36,182 @@ func TestFullScan(t *testing.T) { Path: "./test_media/library", } - if !assert.NoError(t, db.Save(&rootAlbum).Error) { - return + wantImages := []string{ + "buttercup_close_summer_yellow.jpg", + "lilac_lilac_bush_lilac.jpg", + "mount_merapi_volcano_indonesia.jpg", + "boy1.jpg", + "boy2.jpg", + "girl_black_hair2.jpg", + "girl_blond1.jpg", + "girl_blond2.jpg", + "girl_blond3.jpg", + } + wantFaceGroups := [][]string{ + {"boy1.jpg", "boy2.jpg"}, + {"girl_black_hair2.jpg"}, + {"girl_blond1.jpg", "girl_blond2.jpg", "girl_blond3.jpg"}, } - err = db.Model(user).Association("Albums").Append(&rootAlbum) - if !assert.NoError(t, err) { - return + for i := range wantFaceGroups { + slices.Sort(wantFaceGroups[i]) + } + slices.SortFunc(wantFaceGroups, func(a, b []string) int { + return strings.Compare(fmt.Sprint(a), fmt.Sprint(b)) + }) + + if err := db.Save(&rootAlbum).Error; err != nil { + t.Fatal("create root album error:", err) } - if !assert.NoError(t, face_detection.InitializeFaceDetector(db)) { - return + if err := db.Model(user).Association("Albums").Append(&rootAlbum); err != nil { + t.Fatal("bind root album error:", err) + } + + if err := face_detection.InitializeFaceDetector(db); err != nil { + t.Fatal("initalize face detector error:", err) } scanner_utils.RunScannerOnUser(t, db, user) - var allMedia []*models.Media - if !assert.NoError(t, db.Find(&allMedia).Error) { - return - } - - assert.Equal(t, 9, len(allMedia)) - - var allMediaURL []*models.MediaURL - if !assert.NoError(t, db.Find(&allMediaURL).Error) { - return - } - - assert.Equal(t, 18, len(allMediaURL)) - - // Verify that faces was recognized - assert.Eventually(t, func() bool { - var allFaceGroups []*models.FaceGroup - if !assert.NoError(t, db.Find(&allFaceGroups).Error) { - return false + t.Run("CheckMedia", func(t *testing.T) { + var allMedia []*models.Media + if err := db.Find(&allMedia).Error; err != nil { + t.Fatal("get all media error:", err) } - return len(allFaceGroups) == 3 - }, time.Second*5, time.Millisecond*500) + want := slices.Clone(wantImages) + slices.Sort(want) - assert.Eventually(t, func() bool { + got := make([]string, len(allMedia)) + for i, media := range allMedia { + got[i] = media.Title + } + slices.Sort(got) + + if diff := cmp.Diff(got, want); diff != "" { + t.Errorf("all media diff:\n%s", diff) + } + }) + + t.Run("CheckMediaURL", func(t *testing.T) { + var allMediaURL []*models.MediaURL + if err := db.Find(&allMediaURL).Error; err != nil { + t.Fatal("get all media url error:", err) + } + + if got, want := len(allMediaURL), 18; got != want { + t.Errorf("got = %d, want: %v", got, want) + } + + want := slices.Clone(wantImages) + wantThumbs := slices.Clone(wantImages) + for _, thumb := range wantThumbs { + want = append(want, "thumbnail_"+thumb) + } + slices.Sort(want) + + got := make([]string, len(allMediaURL)) + for i, media := range allMediaURL { + got[i] = media.MediaName + } + slices.Sort(got) + + if diff := cmp.Diff(got, want, cmp.Comparer(equalNameWithoutSuffix)); diff != "" { + t.Errorf("all media diff:\n%s", diff) + } + }) + + t.Run("CheckFaceGroup", func(t *testing.T) { + ctx, done := context.WithTimeout(t.Context(), time.Second*5) + defer done() + + waitFor(ctx, t, time.Second/2, func() bool { + var allFaceGroups []*models.FaceGroup + if err := db.Find(&allFaceGroups).Error; err != nil { + t.Fatal("get face groups error:", err) + return false + } + + return len(allFaceGroups) == len(wantFaceGroups) + }) + }) + + t.Run("CheckFaces", func(t *testing.T) { var allImageFaces []*models.ImageFace - if !assert.NoError(t, db.Find(&allImageFaces).Error) { - return false + if err := db.Find(&allImageFaces).Error; err != nil { + t.Fatal("get face images error:", err) } - return len(allImageFaces) == 6 - }, time.Second*5, time.Millisecond*500) + for _, face := range allImageFaces { + if err := face.FillMedia(db); err != nil { + t.Fatalf("fill media for face %v error: %v", face, err) + } + } + got := groupMediaWithFaces(allImageFaces) + + if diff := cmp.Diff(got, wantFaceGroups); diff != "" { + t.Errorf("all media diff:\n%s", diff) + } + }) +} + +func equalNameWithoutSuffix(a, b string) bool { + extA := filepath.Ext(a) + mainA := strings.TrimRight(a, extA) + extB := filepath.Ext(b) + mainB := strings.TrimRight(b, extB) + + // ext names are not same + if extA != extB { + return false + } + + // a is not part of b and b is not part of a + if strings.Index(mainA, mainB) < 0 && strings.Index(mainB, mainA) < 0 { + return false + } + + return true +} + +func waitFor(ctx context.Context, t *testing.T, interval time.Duration, checkFn func() bool) { + t.Helper() + + ticker := time.NewTicker(interval) + for { + select { + case <-ctx.Done(): + t.Fatal("check timeout") + return + case <-ticker.C: + } + + if checkFn() { + return + } + } +} + +func groupMediaWithFaces(medias []*models.ImageFace) [][]string { + grouped := make(map[int][]string) + + for _, media := range medias { + group := grouped[media.FaceGroupID] + group = append(group, media.Media.Title) + grouped[media.FaceGroupID] = group + } + + ret := make([][]string, 0, len(grouped)) + for _, medias := range grouped { + slices.Sort(medias) + ret = append(ret, medias) + } + + slices.SortFunc(ret, func(a, b []string) int { + return strings.Compare(fmt.Sprint(a), fmt.Sprint(b)) + }) + + return ret }