412b9e37eb
Operator decision: the enricher is canonical. No MBID still runs the provider chain (name-based providers — Deezer/Last.fm — resolve without an MBID); if every provider returns ErrNotFound the row settles cover/artist source 'none' at the current sources version (re-eligible only when the registered provider set changes). It does NOT skip-and-leave-NULL. The two _NoMBID_LeavesNull tests predated the name-based providers (0020 slice) and asserted the old skip→NULL contract. Updated: - TestEnrichArtist_NoMBID_SettlesNone: stub now returns ErrNotFound (realistic MBID-only-provider-with-empty-MBID), expect source 'none'. - TestEnrichAlbum_NoSidecarNoMBID_SettlesNone: empty registry → allWere404 stays true → expect 'none'. Last failing cluster from the CI-integration initiative; suite should now be fully green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
534 lines
15 KiB
Go
534 lines
15 KiB
Go
package coverart
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// stubArtistProvider is a configurable ArtistArtProvider for artist enricher
|
|
// tests. Embeds fakeProvider for the base Provider methods; overrides
|
|
// FetchArtistArt with controllable byte/error responses.
|
|
type stubArtistProvider struct {
|
|
fakeProvider
|
|
thumb, fanart []byte
|
|
err error
|
|
}
|
|
|
|
func (f *stubArtistProvider) FetchArtistArt(_ context.Context, _ ArtistRef) ([]byte, []byte, error) {
|
|
return f.thumb, f.fanart, f.err
|
|
}
|
|
|
|
// insertArtist seeds a fresh artist via UpsertArtist and returns its UUID.
|
|
// mbid may be "" to seed a MBID-less artist.
|
|
func insertArtist(t *testing.T, q *dbq.Queries, ctx context.Context, name, mbid string) pgtype.UUID {
|
|
t.Helper()
|
|
var mbidParam *string
|
|
if mbid != "" {
|
|
mbidParam = &mbid
|
|
}
|
|
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{
|
|
Name: name,
|
|
SortName: name,
|
|
Mbid: mbidParam,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("insertArtist %q: %v", name, err)
|
|
}
|
|
return a.ID
|
|
}
|
|
|
|
// --- Eligibility tests ---
|
|
|
|
func TestEnrichArtist_NullSource_Eligible(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
|
|
thumb: []byte("thumb_bytes"),
|
|
fanart: []byte("fanart_bytes"),
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "Null Artist", "11111111-1111-1111-1111-111111111111")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "stub-artist" {
|
|
t.Errorf("source = %v, want 'stub-artist'", row.ArtistArtSource)
|
|
}
|
|
}
|
|
|
|
func TestEnrichArtist_FoundSource_Skip(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
|
|
thumb: []byte("should_not_write"),
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "Found Artist", "22222222-2222-2222-2222-222222222222")
|
|
|
|
// Pre-mark with a "found" source.
|
|
src := "theaudiodb"
|
|
if err := q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{
|
|
ID: artistID,
|
|
ArtistArtSource: &src,
|
|
ArtistArtSourcesVersion: e.settings.CurrentVersion(),
|
|
}); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
// Source must remain 'theaudiodb'; no files should have been written.
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "theaudiodb" {
|
|
t.Errorf("source = %v, want 'theaudiodb' (preserved)", row.ArtistArtSource)
|
|
}
|
|
thumbPath := filepath.Join(dataDir, "artist-art", uuidString(artistID), "thumb.jpg")
|
|
if _, err := os.Stat(thumbPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected no thumb.jpg (skip), got stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnrichArtist_NoneCurrentVersion_Skip(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
|
|
thumb: []byte("should_not_write"),
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "None Current Artist", "33333333-3333-3333-3333-333333333333")
|
|
|
|
src := "none"
|
|
if err := q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{
|
|
ID: artistID,
|
|
ArtistArtSource: &src,
|
|
ArtistArtSourcesVersion: e.settings.CurrentVersion(),
|
|
}); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
// Source must remain 'none' at current version.
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" {
|
|
t.Errorf("source = %v, want 'none' (skip — current version)", row.ArtistArtSource)
|
|
}
|
|
}
|
|
|
|
func TestEnrichArtist_NoneStaleVersion_FlipAndRetry(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
|
|
thumb: []byte("retry_thumb"),
|
|
fanart: []byte("retry_fanart"),
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "Stale None Artist", "44444444-4444-4444-4444-444444444444")
|
|
|
|
// Set 'none' with a stale version (currentVersion - 1).
|
|
staleVersion := e.settings.CurrentVersion() - 1
|
|
src := "none"
|
|
if err := q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{
|
|
ID: artistID,
|
|
ArtistArtSource: &src,
|
|
ArtistArtSourcesVersion: staleVersion,
|
|
}); err != nil {
|
|
t.Fatalf("set stale none: %v", err)
|
|
}
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
// Stale 'none' should be flipped and re-enriched; stub succeeds so
|
|
// source becomes the provider ID.
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "stub-artist" {
|
|
t.Errorf("source = %v, want 'stub-artist' (stale none flipped and retried)", row.ArtistArtSource)
|
|
}
|
|
}
|
|
|
|
// --- Atomicity: thumb-only / fanart-only ---
|
|
|
|
func TestEnrichArtist_ThumbOnly_ThumbPathSetFanartNull(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "thumb-only-provider", defaultOn: true},
|
|
thumb: []byte("thumb_only"),
|
|
fanart: nil,
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "Thumb Only Artist", "55555555-5555-5555-5555-555555555555")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "thumb-only-provider" {
|
|
t.Errorf("source = %v, want 'thumb-only-provider'", row.ArtistArtSource)
|
|
}
|
|
if row.ArtistThumbPath == nil {
|
|
t.Errorf("artist_thumb_path is nil, want a path")
|
|
}
|
|
if row.ArtistFanartPath != nil {
|
|
t.Errorf("artist_fanart_path = %v, want nil (fanart not provided)", row.ArtistFanartPath)
|
|
}
|
|
if row.ArtistThumbPath != nil {
|
|
body, rerr := os.ReadFile(*row.ArtistThumbPath)
|
|
if rerr != nil {
|
|
t.Fatalf("read thumb: %v", rerr)
|
|
}
|
|
if string(body) != "thumb_only" {
|
|
t.Errorf("thumb content = %q, want 'thumb_only'", string(body))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnrichArtist_FanartOnly_FanartPathSetThumbNull(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "fanart-only-provider", defaultOn: true},
|
|
thumb: nil,
|
|
fanart: []byte("fanart_only"),
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "Fanart Only Artist", "66666666-6666-6666-6666-666666666666")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "fanart-only-provider" {
|
|
t.Errorf("source = %v, want 'fanart-only-provider'", row.ArtistArtSource)
|
|
}
|
|
if row.ArtistThumbPath != nil {
|
|
t.Errorf("artist_thumb_path = %v, want nil (thumb not provided)", row.ArtistThumbPath)
|
|
}
|
|
if row.ArtistFanartPath == nil {
|
|
t.Errorf("artist_fanart_path is nil, want a path")
|
|
}
|
|
}
|
|
|
|
// --- All-404 settles to 'none' ---
|
|
|
|
func TestEnrichArtist_All404_SettlesNone(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "404-provider", defaultOn: true},
|
|
err: ErrNotFound,
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "All404 Artist", "77777777-7777-7777-7777-777777777777")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" {
|
|
t.Errorf("source = %v, want 'none' (all providers 404)", row.ArtistArtSource)
|
|
}
|
|
if row.ArtistArtSourcesVersion != e.settings.CurrentVersion() {
|
|
t.Errorf("version = %d, want %d", row.ArtistArtSourcesVersion, e.settings.CurrentVersion())
|
|
}
|
|
}
|
|
|
|
// --- Empty providers list settles vacuously to 'none' ---
|
|
|
|
func TestEnrichArtist_EmptyProviders_SettlesNone(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
// Register a plain provider that does NOT implement ArtistArtProvider —
|
|
// EnabledArtistProviders will return an empty slice.
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
Register(&fakeProvider{id: "album-only", defaultOn: true})
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "No Provider Artist", "88888888-8888-8888-8888-888888888888")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" {
|
|
t.Errorf("source = %v, want 'none' (vacuous all-404 with empty provider list)", row.ArtistArtSource)
|
|
}
|
|
}
|
|
|
|
// --- ErrTransient leaves row NULL ---
|
|
|
|
func TestEnrichArtist_ErrTransient_LeavesNull(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "transient-provider", defaultOn: true},
|
|
err: ErrTransient,
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "Transient Artist", "99999999-9999-9999-9999-999999999999")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
// Transient: source must remain NULL (eligible for retry).
|
|
if row.ArtistArtSource != nil {
|
|
t.Errorf("source = %v, want nil (transient should not settle)", row.ArtistArtSource)
|
|
}
|
|
}
|
|
|
|
// --- Filesystem layout ---
|
|
|
|
func TestEnrichArtist_FilesystemLayout(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
thumbBytes := []byte("thumb_content")
|
|
fanartBytes := []byte("fanart_content")
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "layout-provider", defaultOn: true},
|
|
thumb: thumbBytes,
|
|
fanart: fanartBytes,
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
artistID := insertArtist(t, q, ctx, "Layout Artist", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
artistIDStr := uuidString(artistID)
|
|
thumbPath := filepath.Join(dataDir, "artist-art", artistIDStr, "thumb.jpg")
|
|
fanartPath := filepath.Join(dataDir, "artist-art", artistIDStr, "fanart.jpg")
|
|
|
|
thumbBody, rerr := os.ReadFile(thumbPath)
|
|
if rerr != nil {
|
|
t.Fatalf("read thumb.jpg: %v", rerr)
|
|
}
|
|
if string(thumbBody) != string(thumbBytes) {
|
|
t.Errorf("thumb.jpg = %q, want %q", thumbBody, thumbBytes)
|
|
}
|
|
|
|
fanartBody, rerr := os.ReadFile(fanartPath)
|
|
if rerr != nil {
|
|
t.Fatalf("read fanart.jpg: %v", rerr)
|
|
}
|
|
if string(fanartBody) != string(fanartBytes) {
|
|
t.Errorf("fanart.jpg = %q, want %q", fanartBody, fanartBytes)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistThumbPath == nil || *row.ArtistThumbPath != thumbPath {
|
|
t.Errorf("DB thumb_path = %v, want %s", row.ArtistThumbPath, thumbPath)
|
|
}
|
|
if row.ArtistFanartPath == nil || *row.ArtistFanartPath != fanartPath {
|
|
t.Errorf("DB fanart_path = %v, want %s", row.ArtistFanartPath, fanartPath)
|
|
}
|
|
}
|
|
|
|
// --- CleanupArtistArt ---
|
|
|
|
func TestCleanupArtistArt_RemovesDirectory(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
artistID := pgtype.UUID{
|
|
Bytes: [16]byte{0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb, 0xbb},
|
|
Valid: true,
|
|
}
|
|
artistDir := filepath.Join(dataDir, "artist-art", uuidString(artistID))
|
|
if err := os.MkdirAll(artistDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(artistDir, "thumb.jpg"), []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
if err := CleanupArtistArt(dataDir, artistID); err != nil {
|
|
t.Fatalf("CleanupArtistArt: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(artistDir); !os.IsNotExist(err) {
|
|
t.Errorf("directory still exists after cleanup: stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCleanupArtistArt_IdempotentMissingDir(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
artistID := pgtype.UUID{
|
|
Bytes: [16]byte{0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc},
|
|
Valid: true,
|
|
}
|
|
|
|
// Directory does not exist — should be a no-op.
|
|
if err := CleanupArtistArt(dataDir, artistID); err != nil {
|
|
t.Fatalf("CleanupArtistArt on missing dir: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- MBID guard ---
|
|
|
|
func TestEnrichArtist_NoMBID_SettlesNone(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
|
|
resetRegistryForTests()
|
|
t.Cleanup(resetRegistryForTests)
|
|
|
|
// No MBID still runs the provider chain (name-based providers can
|
|
// resolve without one). An MBID-only provider returns ErrNotFound
|
|
// for an empty MBID; with the whole chain returning ErrNotFound the
|
|
// row settles 'none' (version-stamped), NOT NULL.
|
|
stub := &stubArtistProvider{
|
|
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
|
|
err: ErrNotFound,
|
|
}
|
|
Register(stub)
|
|
|
|
e := newTestEnricher(t, pool)
|
|
// Seed with no MBID.
|
|
artistID := insertArtist(t, q, ctx, "No MBID Artist", "")
|
|
|
|
dataDir := t.TempDir()
|
|
if err := e.EnrichArtist(ctx, artistID, dataDir); err != nil {
|
|
t.Fatalf("EnrichArtist: %v", err)
|
|
}
|
|
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
t.Fatalf("GetArtistByID: %v", err)
|
|
}
|
|
if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" {
|
|
t.Errorf("source = %v, want 'none' (settled)", row.ArtistArtSource)
|
|
}
|
|
}
|