packages feed

flac-0.2.1: tests/Codec/Audio/FLAC/MetadataSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Codec.Audio.FLAC.MetadataSpec (spec) where

import Codec.Audio.FLAC.Metadata hiding (runFlacMeta)
import Codec.Audio.FLAC.Metadata qualified as Flac
import Codec.Audio.FLAC.Metadata.CueSheet
import Codec.Audio.Wave
import Control.Monad
import Control.Monad.IO.Class (MonadIO (..))
import Data.ByteString (ByteString)
import Data.ByteString qualified as B
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Vector (Vector)
import Data.Vector qualified as V
import System.Directory
import System.IO
import System.IO.Temp (withSystemTempFile)
import Test.Hspec hiding (shouldBe, shouldReturn)
import Test.Hspec qualified as Hspec

-- TODO How to share the same sandbox between several subsequent tests? This
-- would allow for more precise labelling.

spec :: Spec
spec = around withSandbox $ do
  describe "MinBlockSize" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve MinBlockSize `shouldReturn` 4096

  describe "MaxBlockSize" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve MaxBlockSize `shouldReturn` 4096

  describe "MinFrameSize" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve MinFrameSize `shouldReturn` 1270

  describe "MaxFrameSize" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve MaxFrameSize `shouldReturn` 2504

  describe "SampleRate" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve SampleRate `shouldReturn` 44100

  describe "Channels" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve Channels `shouldReturn` 2

  describe "ChannelMask" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve ChannelMask `shouldReturn` speakerStereo

  describe "BitsPerSample" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve BitsPerSample `shouldReturn` 16

  describe "TotalSamples" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve TotalSamples `shouldReturn` 18304

  describe "FileSize" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve FileSize `shouldReturn` 11459

  describe "BitRate" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve BitRate `shouldReturn` 220

  describe "MD5Sum" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve MD5Sum `shouldReturn` refMD5Sum

  describe "Duration" $
    it "is read correctly" $ \path ->
      runFlacMeta def path . checkNoMod $
        retrieve Duration `shouldReturn` 0.41505668934240364

  describe "Application" $
    it "is set/read/deleted correctly" $ \path -> do
      -- Can set application data.
      runFlacMeta def path $ do
        Application "foo" =-> Just "foo"
        Application "bobo" =-> Just "bobo"
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [ApplicationBlock, ApplicationBlock, VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can read it back.
      runFlacMeta def path . checkNoMod $ do
        retrieve (Application "foo") `shouldReturn` Just "foo"
        retrieve (Application "bobo") `shouldReturn` Just "bobo"
      -- Can wipe one without affecting the other.
      runFlacMeta def path $ do
        Application "foo" =-> Nothing
        retrieve (Application "foo") `shouldReturn` Nothing
        retrieve (Application "bobo") `shouldReturn` Just "bobo"
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [ApplicationBlock, VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can overwrite application data.
      runFlacMeta def path $ do
        Application "bobo" =-> Just "moon"
        retrieve (Application "bobo") `shouldReturn` Just "moon"
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [ApplicationBlock, VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can wipe the other one bringing it to the default state.
      runFlacMeta def path $ do
        Application "bobo" =-> Nothing
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $ do
        retrieve (Application "foo") `shouldReturn` Nothing
        retrieve (Application "bobo") `shouldReturn` Nothing

  describe "SeekTable" $ do
    it "raises exception when invalid seek table given" $ \path -> do
      let m =
            runFlacMeta def path $
              SeekTable =-> Just invalidSeekTable
      m `shouldThrow` (== MetaInvalidSeekTable)
    it "is set/read/deleted correctly" $ \path -> do
      -- Can set seek table if it's correct.
      runFlacMeta def path $ do
        SeekTable =-> Just testSeekTable
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [SeekTableBlock, VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can read it back.
      runFlacMeta def path . checkNoMod $
        retrieve SeekTable `shouldReturn` Just testSeekTable
      -- Can delete it.
      runFlacMeta def path $ do
        SeekTable =-> Nothing
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        SeekTable =-> Nothing
    context "when auto-vacuum disabled" $
      it "can write empty seek table" $ \path -> do
        runFlacMeta def {metaAutoVacuum = False} path $ do
          SeekTable =-> Just V.empty
          retrieve SeekTable `shouldReturn` Just V.empty
          getMetaChain
            `shouldReturn` StreamInfoBlock
              :| [SeekTableBlock, VorbisCommentBlock, PaddingBlock]
          isMetaChainModified `shouldReturn` True
        runFlacMeta def path . checkNoMod $
          retrieve SeekTable `shouldReturn` Just V.empty
    context "when auto-vacuum enabled" $
      it "empty seek table is removed automatically" $ \path -> do
        runFlacMeta def {metaAutoVacuum = True} path $ do
          SeekTable =-> Just V.empty
          retrieve SeekTable `shouldReturn` Just V.empty
          getMetaChain
            `shouldReturn` StreamInfoBlock
              :| [SeekTableBlock, VorbisCommentBlock, PaddingBlock]
          isMetaChainModified `shouldReturn` True
        runFlacMeta def path . checkNoMod $ do
          retrieve SeekTable `shouldReturn` Nothing
          getMetaChain `shouldReturn` refChain

  describe "VorbisVendor" $ do
    it "is set/read correctly" $ \path -> do
      -- Can set vorbis vendor.
      runFlacMeta def path $ do
        VorbisVendor =-> Just "foo"
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can read it back.
      runFlacMeta def path . checkNoMod $
        retrieve VorbisVendor `shouldReturn` Just "foo"
    context "when auto-vacuum disabled" $
      it "deletion just sets the field to empty string" $ \path -> do
        runFlacMeta def {metaAutoVacuum = False} path $ do
          VorbisVendor =-> Nothing
          retrieve VorbisVendor `shouldReturn` Just ""
          getMetaChain
            `shouldReturn` StreamInfoBlock
              :| [VorbisCommentBlock, PaddingBlock]
          isMetaChainModified `shouldReturn` True
        runFlacMeta def path . checkNoMod $
          retrieve VorbisVendor `shouldReturn` Just ""
    context "when auto-vacuum enabled" $ do
      context "when no other vorbis fields set" $
        it "empty vendor causes removal of vorbis vendor block" $ \path -> do
          runFlacMeta def {metaAutoVacuum = True} path $ do
            VorbisVendor =-> Nothing
            retrieve VorbisVendor `shouldReturn` Just ""
            getMetaChain
              `shouldReturn` StreamInfoBlock
                :| [VorbisCommentBlock, PaddingBlock]
            isMetaChainModified `shouldReturn` True
          runFlacMeta def path . checkNoMod $ do
            retrieve VorbisVendor `shouldReturn` Nothing
            getMetaChain `shouldReturn` StreamInfoBlock :| [PaddingBlock]
      context "when other vorbis fields exist" $
        it "deletion just sets the field to empty string" $ \path -> do
          runFlacMeta def {metaAutoVacuum = True} path $ do
            VorbisComment Title =-> Just "bobla"
            VorbisVendor =-> Nothing
            retrieve VorbisVendor `shouldReturn` Just ""
            getMetaChain
              `shouldReturn` StreamInfoBlock
                :| [VorbisCommentBlock, PaddingBlock]
            isMetaChainModified `shouldReturn` True
          runFlacMeta def path . checkNoMod $ do
            retrieve VorbisVendor `shouldReturn` Just ""
            getMetaChain
              `shouldReturn` StreamInfoBlock
                :| [VorbisCommentBlock, PaddingBlock]

  describe "VorbisComment" . forM_ [minBound .. maxBound] $ \vfield ->
    it (show vfield ++ " is set/read/deleted correctly") $ \path -> do
      -- Can set vorbis comment.
      runFlacMeta def path $ do
        VorbisComment vfield =-> Just "foo"
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can read it back.
      runFlacMeta def path . checkNoMod $
        retrieve (VorbisComment vfield) `shouldReturn` Just "foo"
      -- Can delete it.
      runFlacMeta def path $ do
        VorbisComment vfield =-> Nothing
        retrieve (VorbisComment vfield) `shouldReturn` Nothing
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        retrieve (VorbisComment vfield) `shouldReturn` Nothing

  describe "CueSheet" $ do
    context "when the CUE sheet is for a CD" $
      it "raises exception when invalid CDDA CUE sheet is given" $ \path -> do
        let m =
              runFlacMeta def path $
                CueSheet =-> Just invalidCueSheet {cueIsCd = True}
            leadInError = "CD-DA cue sheet must have a lead-in length of at least 2 seconds"
        m `shouldThrow` (== MetaInvalidCueSheet leadInError)
    context "when the CUE sheet is not for a CD" $
      it "does not find anything bad in given CUE sheet" $ \path ->
        -- NOTE All other possible issues have been taken care of by the
        -- type system and carefully arranged data type definitions.
        runFlacMeta def path $
          CueSheet =-> Just invalidCueSheet {cueIsCd = False}
    it "is set/read/deleted correctly" $ \path -> do
      -- Can set CUE sheet if it's correct.
      runFlacMeta def path $ do
        CueSheet =-> Just testCueSheet
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [CueSheetBlock, VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can read it back.
      runFlacMeta def path . checkNoMod $
        retrieve CueSheet `shouldReturn` Just testCueSheet
      -- Can delete it.
      runFlacMeta def path $ do
        CueSheet =-> Nothing
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        CueSheet =-> Nothing

  describe "Picture" . forM_ [minBound .. maxBound] $ \ptype -> do
    it (show ptype ++ " raises exception on invalid picture") $ \path -> do
      let m =
            runFlacMeta def path $
              Picture ptype =-> Just invalidPicture
          mimeTypeError = "MIME type string must contain only printable ASCII characters (0x20-0x7e)"
      m `shouldThrow` (== MetaInvalidPicture mimeTypeError)
    it (show ptype ++ " is set/read/deleted correctly") $ \path -> do
      -- Can set a picture.
      runFlacMeta def path $ do
        Picture ptype =-> Just testPicture
        getMetaChain
          `shouldReturn` StreamInfoBlock
            :| [PictureBlock, VorbisCommentBlock, PaddingBlock]
        isMetaChainModified `shouldReturn` True
      -- Can read it back.
      runFlacMeta def path . checkNoMod $
        retrieve (Picture ptype) `shouldReturn` Just testPicture
      -- Can delete it.
      runFlacMeta def path $ do
        Picture ptype =-> Nothing
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        Picture ptype =-> Nothing

  describe "wipeVorbisComment" $
    it "wipes all “vorbis comment” metadata blocks" $ \path -> do
      runFlacMeta def path $ do
        VorbisComment Title =-> Just "Title"
        VorbisComment Artist =-> Just "Artist"
      runFlacMeta def path $ do
        wipeVorbisComment
        getMetaChain `shouldReturn` StreamInfoBlock :| [PaddingBlock]
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        getMetaChain `shouldReturn` StreamInfoBlock :| [PaddingBlock]

  describe "wipeApplications" $
    it "wipes all “application” metadata blocks" $ \path -> do
      runFlacMeta def path $ do
        Application "foo" =-> Just "foo"
        Application "bobo" =-> Just "bobo"
      runFlacMeta def path $ do
        wipeApplications
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        getMetaChain `shouldReturn` refChain

  describe "wipeSeekTable" $
    it "wipes all “seek table” metadata blocks" $ \path -> do
      runFlacMeta def path $
        SeekTable =-> Just testSeekTable
      runFlacMeta def path $ do
        wipeSeekTable
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        getMetaChain `shouldReturn` refChain

  describe "wipeCueSheets" $
    it "wipes all “CUE sheet” metadata blocks" $ \path -> do
      runFlacMeta def path $
        CueSheet =-> Just testCueSheet
      runFlacMeta def path $ do
        wipeCueSheets
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        getMetaChain `shouldReturn` refChain

  describe "wipePictures" $
    it "wipes all “picture” metadata blocks" $ \path -> do
      runFlacMeta def path $ do
        Picture PictureFrontCover =-> Just testPicture
        Picture PictureBackCover =-> Just testPicture
      runFlacMeta def path $ do
        wipePictures
        getMetaChain `shouldReturn` refChain
        isMetaChainModified `shouldReturn` True
      runFlacMeta def path . checkNoMod $
        getMetaChain `shouldReturn` refChain

----------------------------------------------------------------------------
-- Helpers

-- | A shortcut for 'defaultMetaSettings'.
def :: MetaSettings
def = defaultMetaSettings

infix 1 `shouldBe`, `shouldReturn`

-- | Lifted 'Hspec.shouldBe'.
shouldBe :: (MonadIO m, Show a, Eq a) => a -> a -> m ()
shouldBe x y = liftIO (x `Hspec.shouldBe` y)

-- | Lifted 'Hspec.shouldReturn'.
shouldReturn :: (MonadIO m, Show a, Eq a) => m a -> a -> m ()
shouldReturn m y = m >>= (`shouldBe` y)

-- | Type constrained version of 'Flac.runFlacMeta' to remove type
-- ambiguity.
runFlacMeta :: MetaSettings -> FilePath -> FlacMeta a -> IO a
runFlacMeta = Flac.runFlacMeta

-- | Make a temporary copy of @audio-samples/sample.flac@ file and provide
-- the path to the file. Automatically remove the file when the test
-- finishes.
withSandbox :: ActionWith FilePath -> IO ()
withSandbox action = withSystemTempFile "sample.flac" $ \path h -> do
  hClose h
  copyFile "audio-samples/sample.flac" path
  action path

-- | Check that the inner action does not modify the chain.
checkNoMod :: FlacMeta a -> FlacMeta a
checkNoMod m = do
  chainBefore <- getMetaChain
  result <- m
  chainAfter <- getMetaChain
  chainAfter `shouldBe` chainBefore
  isMetaChainModified `shouldReturn` False
  return result

-- | MD5 sum of uncompressed audio data of the unmodified sample we use in
-- these tests.
refMD5Sum :: ByteString
refMD5Sum = B.pack [89, 191, 106, 236, 125, 27, 65, 161, 78, 138, 172, 153, 91, 60, 42, 109]

-- | The sequence of metadata blocks as they appear in the unmodified sample
-- we use in these tests.
refChain :: NonEmpty MetadataType
refChain = StreamInfoBlock :| [VorbisCommentBlock, PaddingBlock]

-- | A correct seek table.
testSeekTable :: Vector SeekPoint
testSeekTable =
  V.fromList
    [ SeekPoint 1 10 100,
      SeekPoint 2 20 108,
      SeekPoint 3 30 101
    ]

-- | An invalid seek table.
invalidSeekTable :: Vector SeekPoint
invalidSeekTable =
  V.fromList
    [ SeekPoint 0 0 100,
      SeekPoint 0 0 108,
      SeekPoint 0 0 101
    ]

-- | A correct CUE sheet.
testCueSheet :: CueSheetData
testCueSheet =
  CueSheetData
    { cueCatalog = "1112223334445",
      cueLeadIn = 88200, -- at least two seconds
      cueIsCd = True,
      cueTracks =
        [ CueTrack
            { cueTrackOffset = 588 * 2,
              cueTrackIsrc = "abcde1234567",
              cueTrackAudio = True,
              cueTrackPreEmphasis = True,
              cueTrackPregapIndex = Nothing,
              cueTrackIndices = NE.fromList [0, 588, 588 * 2]
            },
          CueTrack
            { cueTrackOffset = 588 * 3,
              cueTrackIsrc = "abced1234576",
              cueTrackAudio = False,
              cueTrackPreEmphasis = False,
              cueTrackPregapIndex = Just 588,
              cueTrackIndices = NE.fromList [0, 588, 588 * 7]
            }
        ],
      cueLeadOutTrack =
        CueTrack
          { cueTrackOffset = 588 * 10,
            cueTrackIsrc = "",
            cueTrackAudio = True,
            cueTrackPreEmphasis = False,
            cueTrackPregapIndex = Just (588 * 9),
            cueTrackIndices = NE.fromList [0]
          }
    }

-- | An invalid CUE sheet.
invalidCueSheet :: CueSheetData
invalidCueSheet =
  CueSheetData
    { cueCatalog = "1112223334445",
      cueLeadIn = 1401, -- less than two seconds — illegal
      cueIsCd = True,
      cueTracks =
        [ CueTrack
            { cueTrackOffset = 1212,
              cueTrackIsrc = "abcde1234567",
              cueTrackAudio = True,
              cueTrackPreEmphasis = True,
              cueTrackPregapIndex = Nothing,
              cueTrackIndices = NE.fromList [0, 13, 1096]
            },
          CueTrack
            { cueTrackOffset = 1313,
              cueTrackIsrc = "abced1234576",
              cueTrackAudio = False,
              cueTrackPreEmphasis = False,
              cueTrackPregapIndex = Just 588,
              cueTrackIndices = NE.fromList [0, 19, 1069]
            }
        ],
      cueLeadOutTrack =
        CueTrack
          { cueTrackOffset = 8888,
            cueTrackIsrc = "",
            cueTrackAudio = True,
            cueTrackPreEmphasis = False,
            cueTrackPregapIndex = Just 588,
            cueTrackIndices = NE.fromList [0]
          }
    }

-- | A correct picture.
testPicture :: PictureData
testPicture =
  PictureData
    { pictureMimeType = "application/jpeg",
      pictureDescription = "Good description.",
      pictureWidth = 100,
      pictureHeight = 100,
      pictureDepth = 24,
      pictureColors = 0,
      pictureData = "Some picture data goes here, honest."
    }

-- | An invalid picture.
invalidPicture :: PictureData
invalidPicture =
  PictureData
    { pictureMimeType = "application\1/jpeg",
      pictureDescription = "Bad\1 description.",
      pictureWidth = 100,
      pictureHeight = 100,
      pictureDepth = 24,
      pictureColors = 0,
      pictureData = "Some picture data goes here, honest."
    }