packages feed

hanalyze-0.2.0.0: test/Hanalyze/Data/StringsSpec.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Hanalyze.Data.Strings の HSpec (Phase 28 Ch14)。
--   R4DS Ch14 の例を一次根拠に str_* の挙動を固定する。
module Hanalyze.Data.StringsSpec (spec) where

import           Test.Hspec
import           Control.Exception  (evaluate)
import           Data.Text          (Text)
import qualified Data.Text          as T
import qualified Data.Vector        as V
import qualified DataFrame.Internal.Column    as DF
import qualified DataFrame.Internal.DataFrame  as DF
import qualified DataFrame.Operations.Core     as DF
import           Hanalyze.Data.Strings
import           Hanalyze.DataIO.Convert (getMaybeTextVec)

-- | 純粋値を spine + 要素まで強制して error を IO に持ち上げる (deepseq 非依存)。
forceStrs :: [a] -> (a -> Int) -> IO Int
forceStrs xs f = evaluate (sum (map f xs))

-- | DataFrame の列を @[Maybe Text]@ で読み戻す (テスト検証用)。
colMT :: Text -> DF.DataFrame -> [Maybe Text]
colMT c d = maybe (error ("no col " ++ T.unpack c)) V.toList (getMaybeTextVec c d)

spec :: Spec
spec = do
  describe "strLength / strSub (str_length / str_sub)" $ do
    it "strLength = 文字数" $ do
      strLength "Apple"  `shouldBe` 5
      strLength ""       `shouldBe` 0
    it "strSub: 1 始まり両端含む" $
      strSub 1 3 "Apple" `shouldBe` "App"
    it "strSub: 負 index は末尾から" $ do
      strSub (-3) (-1) "Apple" `shouldBe` "ple"
      strSub (-1) (-1) "Apple" `shouldBe` "e"     -- last char (R4DS 14.5.2)
      strSub 1 1     "Apple"   `shouldBe` "A"      -- first char
    it "strSub: 範囲外はクリップ・逆転は空" $ do
      strSub 1 100 "Pear" `shouldBe` "Pear"
      strSub 3 2   "Pear" `shouldBe` ""

  describe "strC / strCMaybe (str_c・recycling + NA)" $ do
    it "リテラル + 列の recycling" $
      strC [["Hello "], ["Flora","David","Terra"], ["!"]]
        `shouldBe` ["Hello Flora!","Hello David!","Hello Terra!"]
    it "NA 伝播 (R4DS 14.3.1: NA を含む name)" $
      strCMaybe [[Just "Hello "], [Just "Flora", Nothing, Just "Terra"], [Just "!"]]
        `shouldBe` [Just "Hello Flora!", Nothing, Just "Hello Terra!"]
    it "長さ不整合は error" $
      forceStrs (strC [["a","b"], ["x","y","z"]]) strLength `shouldThrow` anyErrorCall

  describe "strFlatten (str_flatten)" $
    it "collapse で結合" $
      strFlatten ", " ["a","b","c"] `shouldBe` "a, b, c"

  describe "strGlue (str_glue)" $ do
    it "{key} を列で置換 (R4DS 14.3.2)" $
      strGlue "Hello {name}!" [("name", ["Flora","David"])]
        `shouldBe` ["Hello Flora!","Hello David!"]
    it "複数 placeholder + リテラル" $
      strGlue "{a}-{b}" [("a",["1","2"]),("b",["x","y"])]
        `shouldBe` ["1-x","2-y"]
    it "{{ }} はリテラル波括弧" $
      strGlue "{{x}}" [] `shouldBe` ["{x}"]
    it "未知 key は error" $
      forceStrs (strGlue "{z}" [("a",["1"])]) strLength `shouldThrow` anyErrorCall

  describe "strToUpper / strSort" $ do
    it "大文字化" $
      strToUpper "abc" `shouldBe` "ABC"
    it "既定 (コードポイント) 昇順" $
      strSort ["banana","apple","cherry"] `shouldBe` ["apple","banana","cherry"]

  describe "separateLongerDelim / separateLongerPosition (§14.4.1)" $ do
    let df = DF.fromNamedColumns
               [ ("x",  DF.fromList ["a,b,c","d,e","f" :: Text])
               , ("id", DF.fromList ["r1","r2","r3" :: Text]) ]
    it "delim: 1 行→複数行に展開し他列を複製 (R4DS df1)" $ do
      let out = separateLongerDelim "x" "," df
      DF.dimensions out `shouldBe` (6, 2)
      colMT "x"  out `shouldBe` map Just ["a","b","c","d","e","f"]
      colMT "id" out `shouldBe` map Just ["r1","r1","r1","r2","r2","r3"]
    it "delim: NA (Nothing) は分割せず 1 行保持" $ do
      let dfn = DF.fromNamedColumns
                  [("x", DF.fromList [Just "a,b", Nothing, Just "c" :: Maybe Text])]
          out = separateLongerDelim "x" "," dfn
      DF.dimensions out `shouldBe` (4, 1)
      colMT "x" out `shouldBe` [Just "a", Just "b", Nothing, Just "c"]
    it "position: width=1 で各文字を 1 行に (R4DS df3)" $ do
      let df3 = DF.fromNamedColumns [("x", DF.fromList ["1211","131","21" :: Text])]
          out = separateLongerPosition "x" 1 df3
      DF.dimensions out `shouldBe` (9, 1)
      colMT "x" out `shouldBe` map (Just . T.singleton) "121113121"
    it "position: width=2 で 2 文字塊・端数は短い塊" $ do
      let df4 = DF.fromNamedColumns [("x", DF.fromList ["abcde","fg" :: Text])]
          out = separateLongerPosition "x" 2 df4
      colMT "x" out `shouldBe` map Just ["ab","cd","e","fg"]

  describe "strEqual / charToRaw (§14.6 Non-English Text)" $ do
    it "strEqual: 合成 ü と 基底+結合 ü は NFC で等価 (R4DS 14.6.2)" $ do
      -- u = c("ü", "ü")
      let u1 = "\x00fc"; u2 = "u\x0308"
      (u1 == u2)        `shouldBe` False   -- 素の比較は不一致
      strEqual u1 u2    `shouldBe` True     -- str_equal は一致
      strLength u1      `shouldBe` 1        -- 14.6.2: 長さは 1
      strLength u2      `shouldBe` 2        --         と 2 で違う
    it "strEqual: 異なる文字は不一致" $
      strEqual "a" "b" `shouldBe` False
    it "charToRaw: UTF-8 バイト列 (R4DS 14.6.1: Hadley)" $
      charToRaw "Hadley" `shouldBe` [0x48,0x61,0x64,0x6c,0x65,0x79]

  describe "separateWiderDelim / separateWiderPosition (§14.4.2-3)" $ do
    it "delim + names: 1 セル→複数列 (R4DS df3)" $ do
      let df3 = DF.fromNamedColumns
                  [("x", DF.fromList ["a10.1.2022","b10.2.2011","e15.1.2015" :: Text])]
          out = separateWiderDelim "x" "." [Just "code", Just "edition", Just "year"] df3
      DF.dimensions out `shouldBe` (3, 3)
      colMT "code"    out `shouldBe` map Just ["a10","b10","e15"]
      colMT "edition" out `shouldBe` map Just ["1","2","1"]
      colMT "year"    out `shouldBe` map Just ["2022","2011","2015"]
    it "delim + names に NA: その piece を捨てる (R4DS)" $ do
      let df3 = DF.fromNamedColumns
                  [("x", DF.fromList ["a10.1.2022","b10.2.2011","e15.1.2015" :: Text])]
          out = separateWiderDelim "x" "." [Just "code", Nothing, Just "year"] df3
      DF.columnNames out `shouldMatchList` ["code","year"]
      colMT "code" out `shouldBe` map Just ["a10","b10","e15"]
      colMT "year" out `shouldBe` map Just ["2022","2011","2015"]
    it "position widths: 固定幅で列分割 (R4DS df4)" $ do
      let df4 = DF.fromNamedColumns
                  [("x", DF.fromList ["202215TX","202122LA","202325CA" :: Text])]
          out = separateWiderPosition "x" [("year",4),("age",2),("state",2)] df4
      colMT "year"  out `shouldBe` map Just ["2022","2021","2023"]
      colMT "age"   out `shouldBe` map Just ["15","22","25"]
      colMT "state" out `shouldBe` map Just ["TX","LA","CA"]

    -- §14.4.3 診断 df: a = c("1-1-1","1-1-2","1-3","1-3-2","1")
    let dfA = DF.fromNamedColumns
                [("a", DF.fromList ["1-1-1","1-1-2","1-3","1-3-2","1" :: Text])]
        nm  = [Just "x", Just "y", Just "z"]
    it "too_few = align_start: 不足は右を NA (R4DS)" $ do
      let out = separateWiderDelimWith "a" "-" nm AlignStart TooManyError dfA
      colMT "x" out `shouldBe` map Just ["1","1","1","1","1"]
      colMT "y" out `shouldBe` [Just "1", Just "1", Just "3", Just "3", Nothing]
      colMT "z" out `shouldBe` [Just "1", Just "2", Nothing, Just "2", Nothing]
    it "too_few = align_end: 不足は左を NA" $ do
      let out = separateWiderDelimWith "a" "-" nm AlignEnd TooManyError dfA
      colMT "x" out `shouldBe` [Just "1", Just "1", Nothing, Just "1", Nothing]
      colMT "y" out `shouldBe` [Just "1", Just "1", Just "1", Just "3", Nothing]
      colMT "z" out `shouldBe` [Just "1", Just "2", Just "3", Just "2", Just "1"]
    it "too_few = error: 不足があれば error" $
      forceStrs [DF.dimensions (separateWiderDelimWith "a" "-" nm TooFewError TooManyError dfA)] fst
        `shouldThrow` anyErrorCall

    -- §14.4.3 too_many df: a = c("1-1-1","1-1-2","1-3-5-6","1-3-2","1-3-5-7-9")
    let dfM = DF.fromNamedColumns
                [("a", DF.fromList ["1-1-1","1-1-2","1-3-5-6","1-3-2","1-3-5-7-9" :: Text])]
    it "too_many = drop: 余剰は捨てる (R4DS)" $ do
      let out = separateWiderDelimWith "a" "-" nm AlignStart DropExtra dfM
      colMT "x" out `shouldBe` map Just ["1","1","1","1","1"]
      colMT "y" out `shouldBe` map Just ["1","1","3","3","3"]
      colMT "z" out `shouldBe` map Just ["1","2","5","2","5"]
    it "too_many = merge: 余剰を最終列に再結合 (R4DS)" $ do
      let out = separateWiderDelimWith "a" "-" nm AlignStart MergeExtra dfM
      colMT "x" out `shouldBe` map Just ["1","1","1","1","1"]
      colMT "y" out `shouldBe` map Just ["1","1","3","3","3"]
      colMT "z" out `shouldBe` map Just ["1","2","5-6","2","5-7-9"]
    it "debug: 診断列 _ok / _pieces / _remainder を付与" $ do
      let out = separateWiderDelimWith "a" "-" nm TooFewError TooManyDebug dfM
      DF.columnNames out `shouldMatchList`
        ["x","y","z","a_ok","a_pieces","a_remainder"]
      colMT "a_remainder" out `shouldBe`
        [Just "", Just "", Just "6", Just "", Just "7-9"]

  describe "正規表現 (§15 Regular expressions・regex-tdfa)" $ do
    it "strDetect: 基本マッチ + \\d 変換 + \\b 単語境界" $ do
      strDetect "an" "banana"            `shouldBe` True
      strDetect "z"  "banana"            `shouldBe` False
      strDetect "\\d" "a1b"              `shouldBe` True   -- \d → [[:digit:]]
      strDetect "\\d" "abc"              `shouldBe` False
      strDetect "[[:digit:]]" "a1b"      `shouldBe` True
      strDetect "\\bcat\\b" "the cat sat" `shouldBe` True
      strDetect "\\bcat\\b" "category"    `shouldBe` False
    it "strDetectWith: ignore_case (§15.5)" $ do
      strDetect "ABC" "xabcy"            `shouldBe` False
      strDetectWith True "ABC" "xabcy"   `shouldBe` True
    it "strCount: マッチ回数" $
      strCount "a" "banana"              `shouldBe` 3
    it "strSubset / strWhich" $ do
      strSubset "^a" ["apple","banana","avocado"] `shouldBe` ["apple","avocado"]
      strWhich  "a"  ["xyz","cat","dog"]          `shouldBe` [2]
    it "strExtract / strExtractAll (\\d+)" $ do
      strExtract    "\\d+" "abc123def45" `shouldBe` Just "123"
      strExtractAll "\\d+" "abc123def45" `shouldBe` ["123","45"]
      strExtract    "\\d+" "no digits"   `shouldBe` Nothing
    it "strMatch: capture groups (whole + groups)" $
      strMatch "([a-z])-([0-9])" "x-5" `shouldBe`
        [Just "x-5", Just "x", Just "5"]
    it "strReplace / strReplaceAll + backref \\1\\2 (§15.3.3 swap)" $ do
      strReplace    "a" "_" "banana"           `shouldBe` "b_nana"
      strReplaceAll "a" "_" "banana"           `shouldBe` "b_n_n_"
      strReplaceAll "([a-z])([a-z])" "\\2\\1" "abcd" `shouldBe` "badc"
    it "strRemove / strRemoveAll" $ do
      strRemove    "a" "banana" `shouldBe` "bnana"
      strRemoveAll "a" "banana" `shouldBe` "bnn"
    it "strSplit (delim + \\s+)" $ do
      strSplit ","     "a,b,c"   `shouldBe` ["a","b","c"]
      strSplit "\\s+"  "a  b c"  `shouldBe` ["a","b","c"]
    it "strLocate: 1 始まり両端含む" $ do
      strLocate "an" "banana" `shouldBe` Just (2,3)
      strLocate "z"  "banana" `shouldBe` Nothing
    it "strEscape: メタ文字をエスケープ" $
      strEscape "a.b+c" `shouldBe` "a\\.b\\+c"
    it "separateWiderRegex: 名前付きグループで列分割 (§15.3.4)" $ do
      let dfR = DF.fromNamedColumns [("x", DF.fromList ["1-2","3-4" :: Text])]
          out = separateWiderRegex "x"
                  [(Just "a", "\\d+"), (Nothing, "-"), (Just "b", "\\d+")] dfR
      DF.columnNames out `shouldMatchList` ["a","b"]
      colMT "a" out `shouldBe` [Just "1", Just "3"]
      colMT "b" out `shouldBe` [Just "2", Just "4"]