hgg-core-0.2.0.0: test/Spec.hs
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Graphics.Hgg.Easy
import Graphics.Hgg.Validate
import Graphics.Hgg.Layout
import Graphics.Hgg.Render
import Graphics.Hgg.Render.Common (pointShapeAt, alphaVector,
resolveTheme, specThemePalette,
ThemePalette (..),
effectiveGridWidth, effectiveGridMinorWidth,
effectiveNonCartesianGridWidth, effectiveAxisLineWidth)
import Graphics.Hgg.Primitive (Point (..))
import Graphics.Hgg.Render.Special (renderDAGStandalone, primsBBoxDAG, dagToScreen)
import Graphics.Hgg.Layout.RangeOf (invNormCdf, qqPoints, ecdfPoints)
import Graphics.Hgg.Layout.Grid (GridCell (..), GridPlacement (..),
flattenSubplots, gridDims, toPTree)
import Graphics.Hgg.Math.Special (logGamma, regIncompleteBeta, betaQuantile)
import qualified Graphics.Hgg.Math.Griddata as Griddata
import qualified Graphics.Hgg.DAG
import Graphics.Hgg.DAG ((~>))
import qualified Graphics.Hgg.DAG.Internal.Sugiyama as Sugi
import qualified Graphics.Hgg.Render.EdgeRoute as ER
import qualified Data.Map.Strict as Map
import Data.List (sort)
import qualified Data.List
import qualified Data.Text
import Data.Monoid (First (..), Last (..))
import qualified Data.Vector as V
import Test.Hspec
-- Phase 7 A7: gallery primitive count 回帰 test 用
import qualified Data.ByteString.Lazy as BL
import Data.Aeson (eitherDecode, encode)
import Graphics.Hgg.Unit (Length (..), LUnit (..), (*~),
mm, inch, px, mmToPt, toPt, lengthToPt,
Pos (..), resolveLen)
import System.Directory (listDirectory, doesDirectoryExist, doesFileExist)
import System.FilePath ((</>), takeExtension)
main :: IO ()
main = hspec $ do
describe "P2a acyclic (Sugiyama.breakCycles)" $ do
it "acyclic 入力は順序保持で不変 (= 現行図に非破壊)" $ do
let es = [("a","b"),("b","c"),("a","c")]
Sugi.breakCycles ["a","b","c"] es `shouldBe` es
it "back-edge を反転して DAG 化する (a→b→c→a の c→a を反転)" $ do
Sugi.breakCycles ["a","b","c"] [("a","b"),("b","c"),("c","a")]
`shouldBe` [("a","b"),("b","c"),("a","c")]
it "self-loop は rank 制約に寄与しないので除去する" $ do
Sugi.breakCycles ["a","b"] [("a","b"),("a","a"),("b","b")]
`shouldBe` [("a","b")]
it "閉路でも rank が単調になる (従来の 0 仮置きは誤りだった)" $ do
let lg = Sugi.assignRanks (Sugi.buildLayoutGraph ["a","b","c"]
(Sugi.breakCycles ["a","b","c"] [("a","b"),("b","c"),("c","a")]))
rk = Map.fromList [ (Sugi.lnId n, Sugi.lnRank n) | n <- Sugi.lgNodes lg ]
-- a<b<c が保たれる (a=0,b=1,c=2)
(Map.lookup "a" rk, Map.lookup "b" rk, Map.lookup "c" rk)
`shouldBe` (Just 0, Just 1, Just 2)
describe "Graphics.Hgg.Layout.Grid (Phase 37 A2 統一グリッド平坦化)" $ do
-- 各 leaf を title で識別し、 占有セルを title で引く。
let leaf nm = title (Data.Text.pack nm)
cellOf nm gp =
case [ c | (s, c) <- gpPanels gp, getLast (vsTitle s) == Just (Data.Text.pack nm) ] of
(c:_) -> c
[] -> error ("panel not found: " ++ nm)
it "leaf 単体は 1x1" $
gridDims (toPTree (leaf "a")) `shouldBe` (1, 1)
it "a <-> b <-> c は 1 行 3 列・各 1x1" $ do
let gp = flattenSubplots (leaf "a" <-> leaf "b" <-> leaf "c")
(gpCols gp, gpRows gp) `shouldBe` (3, 1)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "b" gp `shouldBe` GridCell 0 1 1 1
cellOf "c" gp `shouldBe` GridCell 0 1 2 1
it "a <:> b <:> c は 3 行 1 列・各 1x1" $ do
let gp = flattenSubplots (leaf "a" <:> leaf "b" <:> leaf "c")
(gpCols gp, gpRows gp) `shouldBe` (1, 3)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "b" gp `shouldBe` GridCell 1 1 0 1
cellOf "c" gp `shouldBe` GridCell 2 1 0 1
it "(a<->b<->c) <:> d は d が下段全幅 (colSpan=3) で左端整列" $ do
let gp = flattenSubplots ((leaf "a" <-> leaf "b" <-> leaf "c") <:> leaf "d")
(gpCols gp, gpRows gp) `shouldBe` (3, 2)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "c" gp `shouldBe` GridCell 0 1 2 1
cellOf "d" gp `shouldBe` GridCell 1 1 0 3 -- 上段左 a と下段 d の左端が col0 で一致
it "(a<:>b) <-> c は c が右列全高 (rowSpan=2)" $ do
let gp = flattenSubplots ((leaf "a" <:> leaf "b") <-> leaf "c")
(gpCols gp, gpRows gp) `shouldBe` (2, 2)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "b" gp `shouldBe` GridCell 1 1 0 1
cellOf "c" gp `shouldBe` GridCell 0 2 1 1
it "Phase 59: a <:> b <-> c (無括弧) は (a<:>b)<->c と同結合 (both infixl 6 = 左結合)" $ do
-- fixity 回帰: 旧 <:>=infixl 5 では a <:> (b<->c) と別構造にパースされ fail する。
let gp = flattenSubplots (leaf "a" <:> leaf "b" <-> leaf "c")
(gpCols gp, gpRows gp) `shouldBe` (2, 2)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "b" gp `shouldBe` GridCell 1 1 0 1
cellOf "c" gp `shouldBe` GridCell 0 2 1 1
it "(a<->b) <:> (c<->d) は 2x2 グリッド" $ do
let gp = flattenSubplots ((leaf "a" <-> leaf "b") <:> (leaf "c" <-> leaf "d"))
(gpCols gp, gpRows gp) `shouldBe` (2, 2)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "b" gp `shouldBe` GridCell 0 1 1 1
cellOf "c" gp `shouldBe` GridCell 1 1 0 1
cellOf "d" gp `shouldBe` GridCell 1 1 1 1
it "深いネスト (a<->b<->c)<:>(d<->e) も span 整列" $ do
let gp = flattenSubplots ((leaf "a" <-> leaf "b" <-> leaf "c") <:> (leaf "d" <-> leaf "e"))
(gpCols gp, gpRows gp) `shouldBe` (3, 2)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "c" gp `shouldBe` GridCell 0 1 2 1
-- 下段 d<->e は 2 要素を 3 列に詰める (hbox 幅 2 < グループ幅 3)。
cellOf "d" gp `shouldBe` GridCell 1 1 0 1
cellOf "e" gp `shouldBe` GridCell 1 1 1 1
it "subplots 4 枚 + subplotCols 2 は 2x2 wrap grid" $ do
let gp = flattenSubplots (subplots [leaf "a", leaf "b", leaf "c", leaf "d"]
<> subplotCols 2)
(gpCols gp, gpRows gp) `shouldBe` (2, 2)
cellOf "a" gp `shouldBe` GridCell 0 1 0 1
cellOf "d" gp `shouldBe` GridCell 1 1 1 1
describe "Phase 38 凡例 content-based 幅" $ do
it "isWideChar: ASCII は半角・CJK/かな/全角記号は全角" $ do
map isWideChar "Ab1_-" `shouldBe` [False, False, False, False, False]
map isWideChar "あ漢A%" `shouldBe` [True, True, True, True]
it "textWidthEm: 字種別 advance (小文字0.58/全角1.0/細字0.30) を加算" $ do
textWidthEm "ab" `shouldBe` 1.16 -- 0.58 + 0.58
textWidthEm "あい" `shouldBe` 2.0 -- 1.0 + 1.0
textWidthEm "a漢" `shouldBe` 1.58 -- 0.58 + 1.0
textWidthEm "il" `shouldBe` 0.6 -- 0.30 + 0.30 (細字 < 小文字)
textWidthEm "WM" `shouldBe` 1.84 -- 0.92 + 0.92 (幅広 > 小文字)
textWidthEm "" `shouldBe` 0.0
it "legendGuideWidth: 最長ラベル(幅基準)で colW を駆動" $ do
-- colW = legendKeyW + ggHalfLine/2 + fItem*maxEm + ggHalfLine (★A13: spec 引数追加)
let fItem = 8.8; fTitle = 11.0
w = legendGuideWidth mempty fItem fTitle "" ["aa", "bbbb"] -- 最長 = "bbbb" (em 4*0.58=2.32)
w `shouldBe` legendKeyW + ggHalfLine/2 + fItem * 2.32 + ggHalfLine
it "legendGuideWidth: 全角ラベルは半角同字数より広い" $ do
let f t = legendGuideWidth mempty 8.8 11.0 "" [t]
f "東京" `shouldSatisfy` (> f "ab") -- 全角2 (2.0em) > 半角2 (1.2em)
it "legendGuideWidth: タイトルが最長アイテムより広ければタイトル幅" $ do
-- 短いラベル + 長いタイトル → titleW が勝つ
let w = legendGuideWidth mempty 8.8 11.0 "verylongtitlexxxx" ["a"]
w `shouldBe` 11.0 * textWidthEm "verylongtitlexxxx"
it "legendGuideWidth: ラベル空集合でも key+pad 分の最小幅は確保" $ do
legendGuideWidth mempty 8.8 11.0 "" [] `shouldBe` legendKeyW + ggHalfLine/2 + ggHalfLine
describe "Graphics.Hgg.Unit (Phase 33 単位系)" $ do
it "(*~) はスカラ倍で単位保存" $
(7 *~ inch) `shouldBe` Length 7 In
it "lengthToPt: inch は dpi 非依存 (7in = 504pt)" $
lengthToPt 96 (7 *~ inch) `shouldBe` 504
it "lengthToPt: mm は mmToPt 係数" $
abs (lengthToPt 96 (1 *~ mm) - mmToPt) `shouldSatisfy` (< 1e-9)
it "lengthToPt: px は dpi 依存 (800px@96dpi = 600pt)" $
lengthToPt 96 (800 *~ px) `shouldBe` 600
it "px 遅延解決: pt→px 戻しで元の px に一致 (dpi 不問)" $
let n = 800; dpiV = 137
in abs (lengthToPt dpiV (n *~ px) * (dpiV/72) - n) `shouldSatisfy` (< 1e-9)
it "toPt: 物理単位は Just" $
toPt (7 *~ inch) `shouldBe` Just 504
it "toPt: px は Nothing (dpi 必須を型で表現)" $
toPt (800 *~ px) `shouldBe` Nothing
it "JSON round-trip" $
eitherDecode (encode (180 *~ mm)) `shouldBe` Right (Length 180 Mm)
it "JSON は {v,u} 順固定・tag 小文字" $
encode (180 *~ mm) `shouldBe` "{\"v\":180.0,\"u\":\"mm\"}"
describe "Graphics.Hgg.Unit Pos + resolver (Phase 33 B3)" $ do
-- panel rect: x=10,y=20,w=200,h=100。x scale: data 0..10→pt 10..210、
-- y scale: data 0..5→pt 120(下)..20(上) の反転 (rY=上端 規約と整合)。
let ctx = UCtx { uDpi = 96
, uRect = Rect 10 20 200 100
, uXScale = LinearScale 0 10 10 210
, uYScale = LinearScale 0 5 120 20 }
it "resolvePosX PNpc: 0=左端, 1=右端, 0.5=中央" $ do
resolvePosX ctx (PNpc 0) `shouldBe` 10
resolvePosX ctx (PNpc 1) `shouldBe` 210
resolvePosX ctx (PNpc 0.5) `shouldBe` 110
it "resolvePosY PNpc: 1=上端 rY, 0=下端 rY+rH" $ do
resolvePosY ctx (PNpc 1) `shouldBe` 20
resolvePosY ctx (PNpc 0) `shouldBe` 120
it "resolvePosX PNative: scaleApply 経由" $
resolvePosX ctx (PNative 5) `shouldBe` 110
it "resolvePosY PNative: 反転 scale が処理" $
resolvePosY ctx (PNative 0) `shouldBe` 120
it "resolvePosX PAbs: rX + 物理長 pt (1in=72pt)" $
resolvePosX ctx (PAbs (1 *~ inch)) `shouldBe` 82
it "resolveLen = lengthToPt" $
resolveLen 96 (7 *~ inch) `shouldBe` 504
it "Pos JSON round-trip (abs/npc/native)" $ do
eitherDecode (encode (PNative 3.5)) `shouldBe` Right (PNative 3.5)
eitherDecode (encode (PNpc 0.25)) `shouldBe` Right (PNpc 0.25)
eitherDecode (encode (PAbs (180 *~ mm))) `shouldBe` Right (PAbs (180 *~ mm))
it "Pos JSON tag 形 (byte 安定・PS とミラー)" $ do
encode (PNpc 0.5) `shouldBe` "{\"t\":\"npc\",\"p\":0.5}"
encode (PNative 3.5) `shouldBe` "{\"t\":\"native\",\"p\":3.5}"
encode (PAbs (180.5 *~ mm)) `shouldBe` "{\"t\":\"abs\",\"l\":{\"v\":180.5,\"u\":\"mm\"}}"
describe "scalePrimitives (Phase 33 B5・pt→device)" $ do
let rct = PRect (Rect 1 2 10 20) (FillStyle "#000" 1.0) (Just (StrokeStyle "#111" 3))
cir = PCircle (Point 4 6) 5 (FillStyle "#000" 1.0) Nothing Nothing
txt = PText (Point 2 3) "x" (TextStyle "#000" 11 "sans-serif" AnchorStart 0 "normal" False)
it "k=1 は恒等" $
scalePrimitives 1 [rct, cir, txt] `shouldBe` [rct, cir, txt]
it "k=2: rect 座標+サイズ+stroke 幅を倍化" $
scalePrimitives 2 [rct] `shouldBe`
[PRect (Rect 2 4 20 40) (FillStyle "#000" 1.0) (Just (StrokeStyle "#111" 6))]
it "k=2: circle 中心+半径を倍化" $
scalePrimitives 2 [cir] `shouldBe`
[PCircle (Point 8 12) 10 (FillStyle "#000" 1.0) Nothing Nothing]
it "k=2: text 位置+font size を倍化" $
scalePrimitives 2 [txt] `shouldBe`
[PText (Point 4 6) "x" (TextStyle "#000" 22 "sans-serif" AnchorStart 0 "normal" False)]
-- Phase 64 A7: 多角形 clip も dpi scale の対象 (頂点を全て k 倍)
it "k=2: PClipPath の全頂点を倍化" $
scalePrimitives 2 [PClipPath [Point 1 2, Point 3 4, Point 5 6]] `shouldBe`
[PClipPath [Point 2 4, Point 6 8, Point 10 12]]
describe "Annotation Pos API (Phase 33 B6)" $ do
it "annotTextP は Pos をそのまま格納" $
vsAnnotations (annotTextP (PNpc 0.95) (PNative 3) "R")
`shouldBe` [AnnText (PNpc 0.95) (PNative 3) "R" "" 12]
it "annotRect (旧 x,y,w,h) は 2 隅 PNative に変換" $
vsAnnotations (annotRect 2 5 1 3 "grey")
`shouldBe` [AnnRect (PNative 2) (PNative 5) (PNative 3) (PNative 8)
"grey" "" 0 0.2]
it "Annotation JSON round-trip (native/npc/abs 混在)" $ do
let a1 = AnnText (PNpc 0.95) (PNative 3) "R" "#000" 12
a2 = AnnArrow (PNative 1) (PNative 2) (PAbs (5 *~ mm)) (PNpc 0.5) "#444" 1.5
eitherDecode (encode a1) `shouldBe` Right a1
eitherDecode (encode a2) `shouldBe` Right a2
it "PNpc 注釈が panel 相対で解決 (旧 HS の Frac 無視バグ修正)" $
-- npc(0,1) = panel 左上 = (rX, rY)。旧実装は coord を無視し data 扱いだった。
let spec = layer (scatter (inline [0.0, 1.0, 2.0]) (inline [0.0, 1.0, 2.0]))
<> annotTextP (PNpc 0) (PNpc 1) "tl"
lay = computeLayout emptyResolver spec
a = lpPlotArea lay
ps = renderToPrimitives emptyResolver lay spec
in [ p | PText p "tl" _ <- ps ] `shouldBe` [Point (rX a) (rY a)]
describe "ColRef + OverloadedStrings" $ do
it "\"weight\" :: ColRef を ColByName に" $
("weight" :: ColRef) `shouldBe` ColByName "weight"
it "inline (Vector Double) → ColNum" $
case inline (V.fromList [1.0, 2.0, 3.0]) of
ColNum v -> V.length v `shouldBe` 3
_ -> expectationFailure "wrong tag"
it "inline [Int] → ColNum (auto-promotion)" $
case inline [1, 2, 3 :: Int] of
ColNum v -> V.toList v `shouldBe` [1.0, 2.0, 3.0]
_ -> expectationFailure "wrong tag"
it "inlineCat [String] → ColTxt" $
case inlineCat (["a", "b", "c"] :: [String]) of
ColTxt v -> V.length v `shouldBe` 3
_ -> expectationFailure "wrong tag"
it "resolveNum inline は resolver 不要で解決" $
resolveNum emptyResolver (inline [10.0, 20.0])
`shouldBe` Just (V.fromList [10, 20])
it "resolveNum ColByName は resolver を引く" $
let r n = if n == "x" then Just (NumData (V.fromList [1, 2])) else Nothing
in resolveNum r "x" `shouldBe` Just (V.fromList [1, 2])
it "resolveTxt 文字列列を解決" $
let r n = if n == "g" then Just (TxtData (V.fromList ["a", "b"])) else Nothing
in resolveTxt r "g" `shouldBe` Just (V.fromList ["a", "b"])
it "resolveTxt は 数値 inline では Nothing" $
resolveTxt emptyResolver (inline [1.0]) `shouldBe` Nothing
describe "Layer Monoid" $ do
it "scatter sets kind = MScatter" $
getFirst (lyKind (scatter "x" "y")) `shouldBe` Just MScatter
it "alpha 2 回 → 後勝ち (Last)" $
let l = scatter "x" "y" <> alpha 0.5 <> alpha 0.7
in getLast (lyAlpha l) `shouldBe` Just 0.7
it "kind は First (= 先勝ち)、 別 kind を <> しても上書きされない" $
let l = scatter "x" "y" <> line "x" "z"
in getFirst (lyKind l) `shouldBe` Just MScatter
it "mempty <> l == l (Monoid law)" $
let l = scatter "x" "y" <> alpha 0.5
in (mempty <> l) `shouldBe` l
it "結合則 (a <> b) <> c == a <> (b <> c)" $
let a = scatter "x" "y"
b = alpha 0.5
c = size 6
in ((a <> b) <> c) `shouldBe` (a <> (b <> c))
describe "Phase 30 A7: Point2 inline 形 (3D scatter3DPoints と対称)" $ do
it "scatterPoints == scatter (inline xs) (inline ys)" $
scatterPoints [Point2 1 2, Point2 3 4]
`shouldBe` scatter (inline [1.0, 3.0]) (inline [2.0, 4.0])
it "linePoints == line (inline xs) (inline ys)" $
linePoints [Point2 1 2, Point2 3 4]
`shouldBe` line (inline [1.0, 3.0]) (inline [2.0, 4.0])
it "scatterPoints の kind = MScatter" $
getFirst (lyKind (scatterPoints [Point2 0 0])) `shouldBe` Just MScatter
it "Point2 JSON = positional array [x, y] (decode 往復)" $
(eitherDecode "[1.5,2.5]" :: Either String Point2) `shouldBe` Right (Point2 1.5 2.5)
describe "Phase 30 A8: alphaBy 連続 alpha encoding (= ggplot scale_alpha)" $ do
it "alphaBy で lyAlphaBy が設定される" $
getLast (lyAlphaBy (alphaBy "w")) `shouldBe` Just (ColByName "w")
it "alphaVector: 列値 min..max → alpha [0.1, 1.0] に線形 map" $
let ly = scatter "x" "y" <> alphaBy (inline [0.0, 5.0, 10.0])
v = alphaVector emptyResolver ly 0.85 3
in (V.toList v) `shouldBe` [0.1, 0.55, 1.0]
it "alphaVector: lyAlphaBy 無指定なら baseAlpha を全点に" $
let ly = scatter "x" "y"
v = alphaVector emptyResolver ly 0.85 3
in (V.toList v) `shouldBe` [0.85, 0.85, 0.85]
it "alphaVector: 定数列 (min==max) は baseAlpha にフォールバック" $
let ly = scatter "x" "y" <> alphaBy (inline [4.0, 4.0])
v = alphaVector emptyResolver ly 0.85 2
in (V.toList v) `shouldBe` [0.85, 0.85]
describe "colorRGBA: 8 桁 RGBA hex 便利関数 (= color (fromHex …) <> alpha …)" $ do
it "colorRGBA \"#00887766\" == color (fromHex \"#008877\") <> alpha (0x66/255)" $
colorRGBA "#00887766"
`shouldBe` (color (fromHex "#008877") <> alpha (102/255))
it "6 桁 (alpha 無し) は alpha=1.0 で不透明" $
colorRGBA "#008877" `shouldBe` (color (fromHex "#008877") <> alpha 1.0)
it "4 桁省略形 #rgba を展開 (#0876 → #008877 + alpha 0x66/255)" $
colorRGBA "#0876" `shouldBe` (color (fromHex "#008877") <> alpha (102/255))
it "fromHexAMaybe: 不正 hex は Nothing" $
fromHexAMaybe "#zz" `shouldBe` Nothing
it "colorRGBAMaybe: 正しい hex は Just" $
colorRGBAMaybe "#00887766" `shouldBe` Just (color (fromHex "#008877") <> alpha (102/255))
describe "VisualSpec Monoid" $ do
it "purePlot == mempty" $
purePlot `shouldBe` (mempty :: VisualSpec)
it "title 2 回 → 後勝ち" $
getLast (vsTitle (title "a" <> title "b")) `shouldBe` Just "b"
it "layer を 2 つ <> すると vsLayers が 2 要素" $
length (vsLayers (layer (scatter "x" "y") <> layer (line "x" "z")))
`shouldBe` 2
it "結合則 (top-level)" $
let a = layer (scatter "x" "y")
b = title "t"
c = theme ThemeDark
in ((a <> b) <> c) `shouldBe` (a <> (b <> c))
it "layers [a, b] == layer (a <> b) (Phase 66 リスト別名)" $
layers [scatter "x" "y", colorBy "group"]
`shouldBe` layer (scatter "x" "y" <> colorBy "group")
it "layers [] == layer mempty (空 list = 空 Layer 1 枚、 purePlot ではない)" $
layers [] `shouldBe` layer mempty
describe "Layout" $ do
it "computeLayout default viewport 468x288pt (= 6.5x4in・Phase 33 B8)" $
let l = computeLayout emptyResolver mempty
in (vsW (lpViewport l), vsH (lpViewport l)) `shouldBe` (468, 288)
-- ★ Phase 70 A3: ess の y domain は encY の実 ESS 値から [0, max(100, 最大値)]
-- + baseline (下端 0 固定・上端 5% pad)。 旧実装は encX の長さを MCMC の N と
-- 誤用し、 Text nameCol では n=1000 fallback で ESS 実値を不参照だった。
it "ess の y domain = [0, ESS 最大値 × 1.05] (encY 実値基準・Phase 70 A3)" $
let spec = layer (ess (inlineCat (["a", "b", "c"] :: [String]))
(inline [1500, 800, 2000]))
l = computeLayout emptyResolver spec
in (lsDomainLo (lpYScale l), lsDomainHi (lpYScale l)) `shouldBe` (0, 2100)
it "ess の x domain = categorical 経路 [-0.6, n-0.4] (Phase 70 A3)" $
let spec = layer (ess (inlineCat (["a", "b", "c"] :: [String]))
(inline [1500, 800, 2000]))
l = computeLayout emptyResolver spec
in (lsDomainLo (lpXScale l), lsDomainHi (lpXScale l)) `shouldBe` (-0.6, 2.6)
it "ess の ESS 値が全て閾値 100 未満でも y domain 上端は 100 起点 (閾値線可視)" $
let spec = layer (ess (inlineCat (["a"] :: [String])) (inline [40]))
l = computeLayout emptyResolver spec
in (lsDomainLo (lpYScale l), lsDomainHi (lpYScale l)) `shouldBe` (0, 105)
it "spec 指定 size は pt 空間で viewport に反映 (px は dpi で pt 化)" $
-- ★ Phase 33 B4: layout は純 pt。1024px@96dpi = 768pt / 768px = 576pt
-- (backend が k=dpi/72=4/3 を掛けて device px を復元するのは B5)。
let l = computeLayout emptyResolver (widthUnit (1024 *~ px) <> heightUnit (768 *~ px))
in (vsW (lpViewport l), vsH (lpViewport l)) `shouldBe` (768, 576)
it "niceTicks 5 0 10 == [0,2..10]" $
niceTicks 5 0 10 `shouldBe` [0, 2, 4, 6, 8, 10]
-- Phase 8 C (§5 G3): extendedBreaks = R labeling::extended 移植。
-- 既知の R 出力と照合 (Talbot-Lin-Hanrahan 2010 / ggplot2 既定 breaks)。
it "G3 extendedBreaks 5 0 10 == [0,2.5,5,7.5,10]" $
extendedBreaks 5 0 10 `shouldBe` [0, 2.5, 5, 7.5, 10]
it "G3 extendedBreaks 5 0 100 == [0,25,50,75,100]" $
extendedBreaks 5 0 100 `shouldBe` [0, 25, 50, 75, 100]
it "G3 extendedBreaks 5 0 1 == [0,0.25,0.5,0.75,1]" $
extendedBreaks 5 0 1 `shouldBe` [0, 0.25, 0.5, 0.75, 1]
it "G3 extendedBreaks 5 1 9 == [0,2.5,5,7.5,10] (censor 前のデータ範囲基準)" $
extendedBreaks 5 1 9 `shouldBe` [0, 2.5, 5, 7.5, 10]
it "G3 extendedBreaks 退化域 (lo==hi) は単点" $
extendedBreaks 5 2 2 `shouldBe` [2]
-- Phase 8 C (gtable §E-1): solveTracks = Fixed 先取り → 残りを Null 重み比で配分。
it "A-gtable solveTracks: Fixed 先取り + 単一 Null に残り" $
solveTracks 0 100 [Fixed 20, Null 1, Fixed 30] `shouldBe` [(0,20),(20,50),(70,30)]
it "A-gtable solveTracks: Null 重み比 (1:3 = 25:75)" $
solveTracks 0 100 [Null 1, Null 3] `shouldBe` [(0,25),(25,75)]
it "A-gtable solveTracks: origin offset 反映" $
solveTracks 10 100 [Fixed 20, Null 1] `shouldBe` [(10,20),(30,80)]
it "A-gtable solveTracks: Fixed 超過なら Null=0 (パネル潰れ)" $
solveTracks 0 30 [Fixed 20, Fixed 20, Null 1] `shouldBe` [(0,20),(20,20),(40,0)]
-- Phase 8 C G8: insetElement (patchwork 左下原点) = insetAt (左上原点) への変換。
-- (left,bottom,right,top)=(0.5,0.5,1,1) 右上 → insetAt(x=0.5,y=0,w=0.5,h=0.5)。
it "G8 insetElement (0.5,0.5,1,1) == insetAt (0.5,0,0.5,0.5)" $
insetElement 0.5 0.5 1.0 1.0 mempty `shouldBe` insetAt 0.5 0.0 0.5 0.5 mempty
it "scaleApply Linear 0..1 → 100..200 中点 150" $
scaleApply (LinearScale 0 1 100 200) 0.5 `shouldBe` 150
it "Phase 26 §C-2 #1: scaleApply Log 1..1000 → 0..300 中点 (=10) は ≈100" $
abs (scaleApply (LogScale 1 1000 0 300) 10 - 100.0) `shouldSatisfy` (< 1e-9)
it "Phase 26 §C-2 #1: niceTicksLog 5 1 10000 = [1,10,100,1000,10000]" $
niceTicksLog 5 1 10000 `shouldBe` [1, 10, 100, 1000, 10000]
it "Phase 26 §C-2 #1: xAxis logAxis を spec に与えると LogScale が出る" $
let spec = layer (scatter (inline [1.0, 10.0, 100.0]) (inline [1.0, 4.0, 9.0]))
<> xAxis logAxis
in case lpXScale (computeLayout emptyResolver spec) of
LogScale{} -> True `shouldBe` True
LinearScale{} -> expectationFailure "expected LogScale"
it "Phase 26 §E-1: traceLines (multi-chain) で chain ごとに線が分離 (= PLine 多数)" $
let r n = case n of
"iter" -> Just (NumData (V.fromList [0, 1, 2, 0, 1, 2]))
"value" -> Just (NumData (V.fromList [0.1, 0.2, 0.5, 0.0, 0.4, 0.6]))
"chain" -> Just (TxtData (V.fromList ["1", "1", "1", "2", "2", "2"]))
_ -> Nothing
spec = layer (traceLines "iter" "value" "chain")
ps = renderToPrimitives r (computeLayout r spec) spec
lines_ = length [() | PLine{} <- ps]
in lines_ `shouldSatisfy` (>= 4) -- 2 chain × 2 segment 以上
it "Phase 26 §E-6: dag で 3 node 2 edge の primitive 全体数 > 5 (= node shape + arrow + label)" $
let nodes = [ dagNode "a" "alpha" NodeLatent 0.0 0.0
, dagNode "b" "beta" NodeLatent 1.0 0.0
, dagNode "c" "y" NodeObserved 0.5 1.0
]
edges_ = [ dagEdge "a" "c"
, dagEdge "b" "c"
]
spec = layer (dag nodes edges_)
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
in length ps `shouldSatisfy` (> 5)
it "Phase 26 §E-6: dagPlot (Graph builder + ~>) で arrow PPath 含む" $
let g = ("alpha" :: Data.Text.Text) ~> "y" <> "beta" ~> "y"
spec = layer (Graphics.Hgg.DAG.dagPlot g)
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
paths = length [() | PPath{} <- ps]
in paths `shouldSatisfy` (>= 2) -- arrow head + node 楕円 で複数
it "Phase 26 A2: quiver は零でない矢印 1 本につき 3 PLine (本線 + 矢じり 2)" $
-- 軸/格子線も PLine なので、 同じ x/y で全零ベクトル版との差分 = 矢印分だけ。
-- 非零 2 本 (2 本目は零ベクトルで非描画) → 差分 = 2 × 3 = 6。
-- ★ Phase 36 A: 矢印は元レンジのまま plotArea でクリップ (range 非拡張・clip は
-- primitive 数不変) なので、 両版の軸/格子線は一致し差分 = 矢印分だけ。
let xs = inline [0.0, 1.0, 2.0]; ys = inline [0.0, 0.0, 0.0]
mkLines us vs =
let spec = layer (quiver xs ys us vs)
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
in length [() | PLine{} <- ps]
withArrows = mkLines (inline [1.0, 0.0, 1.0]) (inline [0.0, 0.0, 1.0])
noArrows = mkLines (inline [0.0, 0.0, 0.0]) (inline [0.0, 0.0, 0.0])
in (withArrows - noArrows) `shouldBe` 6
it "Phase 26 A2: quiver requiredAes = x/y/u/v・layerCols で 4 列解決" $ do
let ly = quiver (inline [0.0]) (inline [0.0]) (inline [1.0]) (inline [1.0])
requiredAes MQuiver `shouldBe` [AesX, AesY, AesU, AesV]
length (layerCols ly) `shouldBe` 4
it "Phase 26 §C-2 #13: parallelCoords 3 列 で N+1 軸線 (= 3 軸) が出る" $
let spec = layer (parallelCoords [ inline [1.0, 2.0, 3.0]
, inline [4.0, 5.0, 6.0]
, inline [7.0, 8.0, 9.0] ])
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
-- 縦軸 3 本以上 (= 軸 + 各 row の polyline)
lines_ = length [() | PLine{} <- ps]
in lines_ `shouldSatisfy` (>= 3)
it "Phase 26 §C-2 #10: marginal で X/Y histogram の PRect が追加される" $
let baseSpec = layer (scatter (inline [0.0, 1.0, 2.0, 3.0, 4.0])
(inline [0.0, 1.0, 4.0, 9.0, 16.0]))
extSpec = baseSpec <> marginal
n0 = length [() | PRect{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver baseSpec) baseSpec]
n1 = length [() | PRect{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver extSpec) extSpec]
in (n1 - n0) `shouldSatisfy` (>= 20) -- 20 bins × 2 軸 minimum
it "Phase 26 §C-2 #12: facet 3 値 で panel が 3 つ出る (= 各 panel の header PText)" $
let r n = case n of
"x" -> Just (NumData (V.fromList [1, 2, 3, 1, 2, 3, 1, 2, 3]))
"y" -> Just (NumData (V.fromList [1, 4, 9, 1, 4, 9, 1, 4, 9]))
"g" -> Just (TxtData (V.fromList ["A", "A", "A", "B", "B", "B", "C", "C", "C"]))
_ -> Nothing
spec = layer (scatter "x" "y") <> facet "g"
ps = renderToPrimitives r (computeLayout r spec) spec
texts = [t | PText _ t _ <- ps]
in do
("A" `elem` texts) `shouldBe` True
("B" `elem` texts) `shouldBe` True
("C" `elem` texts) `shouldBe` True
it "Phase 26 §C-2 #8: statMean が水平 PLine を 1 本生成 (= renderStatLine 直接 check)" $
let r n = case n of
"y" -> Just (NumData (V.fromList [0, 1, 4, 9, 16]))
_ -> Nothing
spec = layer (statMean "y")
ps = renderToPrimitives r (computeLayout r spec) spec
-- 軸 tick の PLine も含まれるが、 lyKind = MStatMean の layer は 1 本だけ生成
-- 確認用: PLine の中で plot area 幅の水平線 = stat line
a = lpPlotArea (computeLayout r spec)
isHorizFull (PLine (Point x1 _) (Point x2 _) _) =
abs (x1 - rX a) < 0.01 && abs (x2 - (rX a + rW a)) < 0.01
isHorizFull _ = False
fullHoriz = filter isHorizFull ps
in length fullHoriz `shouldSatisfy` (>= 1)
it "Phase 26 §C-2 #15: MScatter3D を含めても render は通る (= placeholder)" $
let spec = layer (mempty { lyKind = pure MScatter3D })
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
in length [() | PCircle{} <- ps] `shouldBe` 0 -- 3D は描画しない
it "Phase 60: tile が連続軸で 4 セルを隙間なくベタ塗り + カテゴリ 2 色 (離散 colorBy)" $
-- 2×2 の決定グリッド (x∈{0,1}, y∈{0,1}, class A/B) を tile で塗る。
let r n = case n of
"x" -> Just (NumData (V.fromList [0, 1, 0, 1]))
"y" -> Just (NumData (V.fromList [0, 0, 1, 1]))
"c" -> Just (TxtData (V.fromList ["A", "A", "B", "B"]))
_ -> Nothing
spec = layer (tile "x" "y" "c")
ps = renderToPrimitives r (computeLayout r spec) spec
-- tile セル = 枠なし PRect・非白・大 (背景や凡例 chip を幅で除外)
cells = [ (x, y, w, col)
| PRect (Rect x y w _) (FillStyle col _) Nothing <- ps
, col /= "#ffffff", w > 100 ]
colors = Data.List.nub [ c | (_, _, _, c) <- cells ]
rows = Data.List.groupBy (\(_,y1,_,_) (_,y2,_,_) -> abs (y1 - y2) < 0.01)
(Data.List.sortOn (\(_,y,_,_) -> y) cells)
-- 同一 row の隣接 2 セル: 左の右端 == 右の左端 (隙間なし)
gapFree row = case Data.List.sortOn (\(x,_,_,_) -> x) row of
((x1,_,w1,_) : (x2,_,_,_) : _) -> abs ((x1 + w1) - x2) < 0.01
_ -> False
in do
length cells `shouldBe` 4 -- 2×2 = 4 セル
length colors `shouldBe` 2 -- カテゴリ A/B → 離散 2 色
all gapFree rows `shouldBe` True -- 隙間なし (格子間隔で敷き詰め)
it "Phase 26 §C-2 #6: errorY で各点 3 本 (vertical + 2 cap) 追加、 3 点 = 9 本" $
let r n = case n of
"x" -> Just (NumData (V.fromList [0, 1, 2]))
"y" -> Just (NumData (V.fromList [0, 1, 4]))
"ey" -> Just (NumData (V.fromList [0.5, 0.3, 0.8]))
_ -> Nothing
baseSpec = layer (scatter "x" "y")
errSpec = layer (scatter "x" "y" <> errorY "ey")
n0 = length [() | PLine{} <- renderToPrimitives r
(computeLayout r baseSpec) baseSpec]
n1 = length [() | PLine{} <- renderToPrimitives r
(computeLayout r errSpec) errSpec]
in (n1 - n0) `shouldBe` 9
it "Phase 26 §C-2 #5: scatter + connect で PLine が n-1 本追加" $
let baseSpec = layer (scatter (inline [0.0, 1.0, 2.0, 3.0])
(inline [0.0, 1.0, 4.0, 9.0]))
withCSpec = layer (scatter (inline [0.0, 1.0, 2.0, 3.0])
(inline [0.0, 1.0, 4.0, 9.0])
<> connect)
n0 = length [() | PLine{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver baseSpec) baseSpec]
n1 = length [() | PLine{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver withCSpec) withCSpec]
in (n1 - n0) `shouldBe` 3
it "Phase 26 §C-2 #4: hoverCols で PCircle の title が col 値を含む" $
let r n = case n of
"x" -> Just (NumData (V.fromList [0, 1, 2]))
"y" -> Just (NumData (V.fromList [0, 1, 4]))
"g" -> Just (NumData (V.fromList [10, 20, 30]))
_ -> Nothing
spec = layer (scatter "x" "y" <> hoverCols ["g"])
ps = renderToPrimitives r (computeLayout r spec) spec
labels = [t | PCircle _ _ _ _ (Just t) <- ps]
in any (Data.Text.isInfixOf "g: 10") labels `shouldBe` True
it "Phase 26 §C-2 #3: refIdentity を付けると y=x の PLine が 1 本 追加" $
let baseSpec = layer (scatter (inline [0.0, 1.0, 2.0]) (inline [0.0, 1.0, 4.0]))
plain = renderToPrimitives emptyResolver
(computeLayout emptyResolver baseSpec) baseSpec
withRef = renderToPrimitives emptyResolver
(computeLayout emptyResolver (baseSpec <> refIdentity))
(baseSpec <> refIdentity)
n1 = length [() | PLine{} <- plain]
n2 = length [() | PLine{} <- withRef]
in (n2 - n1) `shouldBe` 1
it "Phase 26 §C-2 #3: refHorizontal 3 + refVertical 1 で計 +2 PLine" $
let baseSpec = layer (scatter (inline [0.0, 1.0, 2.0]) (inline [0.0, 1.0, 4.0]))
extSpec = baseSpec <> refHorizontal 3 <> refVertical 1
n1 = length [() | PLine{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver baseSpec) baseSpec]
n2 = length [() | PLine{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver extSpec) extSpec]
in (n2 - n1) `shouldBe` 2
it "Phase 26 §C-2 #2: AxisDecimalFmt 2 が tick 表示に反映 ('1.50' 等)" $
let spec = layer (scatter (inline [0.0, 1.0, 2.0]) (inline [0.0, 1.0, 4.0]))
<> yAxis (axisFormat (AxisDecimalFmt 2))
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
texts = [t | PText _ t _ <- ps]
hasDot2 t = case Data.Text.breakOn "." t of
(_, suffix) | Data.Text.length suffix == 3 -> True
_ -> False
decimal2 = filter hasDot2 texts
in length decimal2 `shouldSatisfy` (>= 1)
describe "Render" $ do
it "scatter 3 点で PCircle 3 個" $
let spec = layer (scatter (inline [0, 1, 2 :: Double])
(inline [0, 1, 4 :: Double]))
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
in length [() | PCircle{} <- ps] `shouldBe` 3
it "line 4 点で PLine 3 本 (= n-1 本)" $
let spec = layer (line (inline [0, 1, 2, 3 :: Double])
(inline [0, 1, 4, 9 :: Double]))
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
-- axisFrame + tickMarks にも PLine が混ざるので line layer 由来だけ
-- 抽出するのは難しい。 ここでは全体の PLine 数だけ check (= 軸 tick
-- 6 個 + line 3 本 + xMark/yMark 各 12 本程度 = それなりの数)
nLines = length [() | PLine{} <- ps]
in nLines `shouldSatisfy` (>= 3)
it "ColByName で resolver から解決して描画" $
let r n = case n of
"x" -> Just (NumData (V.fromList [0, 1, 2]))
"y" -> Just (NumData (V.fromList [0, 1, 4]))
_ -> Nothing
spec = layer (scatter "x" "y")
ps = renderToPrimitives r (computeLayout r spec) spec
in length [() | PCircle{} <- ps] `shouldBe` 3
it "boxplot は PRect (箱) + PLine (median/髭) の組合せを出す" $
let spec = layer (boxplot (inline [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]))
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
nRects = length [() | PRect{} <- ps]
in nRects `shouldSatisfy` (>= 2) -- axis frame + box の最低 2 個
it "density は PPath を 1 つ出す" $
let spec = layer (density (inline [1.0, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0]))
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
in length [() | PPath{} <- ps] `shouldBe` 1
-- Phase 52.B1: subplots の入れ子が再帰描画される (renderSingle → renderToPrimitives、
-- PS Render/Layer.purs:442 と同一方式)。 外側 subplots が内側 subplots を含むとき、
-- 内側 panel の scatter 点まで描かれることを確認 (修正前は内側が無視され 3 点のみ)。
it "B1 入れ子 subplots: 内側 scatter の点まで全部描画される" $
let pts = layer (scatter (inline [0, 1, 2 :: Double])
(inline [0, 1, 4 :: Double]))
inner = subplots [pts, pts] <> subplotCols 2 -- 内側: 2 panel × 3 点 = 6
outer = subplots [inner, pts] <> subplotCols 1 -- 外側: 入れ子 + 単独 3 点
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver outer) outer
in length [() | PCircle{} <- ps] `shouldBe` 9 -- 6 (入れ子) + 3 (単独)
-- Phase 52.D (concat 合成): hconcat/vconcat ラッパ + 演算子 <-> (横) / <:> (縦)。
-- subplots+subplotCols の薄ラッパで render/parity 影響なし。 演算子は同方向チェーンを
-- 平坦化する (a <-> b <-> c = 3 等分列、 二項ネストにしない)。
it "concat: hconcat [a,b,c] = subplots 3 要素 + subplotCols 3" $
let s = hconcat [purePlot, purePlot, purePlot]
in (length (vsSubplots s), getLast (vsSubplotCols s)) `shouldBe` (3, Just 3)
it "concat: vconcat [a,b] = subplots 2 要素 + subplotCols 1" $
let s = vconcat [purePlot, purePlot]
in (length (vsSubplots s), getLast (vsSubplotCols s)) `shouldBe` (2, Just 1)
it "concat: a <-> b <-> c は 3 要素に平坦化 (二項ネストでなく 3 等分列)" $
let s = purePlot <-> purePlot <-> purePlot
in (length (vsSubplots s), getLast (vsSubplotCols s)) `shouldBe` (3, Just 3)
it "concat: <:> は横グループを単位として扱う (外側 cols 1・2 要素)" $
let s = (purePlot <-> purePlot <-> purePlot) <:> purePlot
in (length (vsSubplots s), getLast (vsSubplotCols s)) `shouldBe` (2, Just 1)
it "concat: (a <-> b <-> c) <:> d == vconcat [hconcat [a,b,c], d] (同一 spec 構造)" $
let a = purePlot
shape s = ( getLast (vsSubplotCols s), length (vsSubplots s)
, [ (getLast (vsSubplotCols x), length (vsSubplots x)) | x <- vsSubplots s ] )
opForm = (a <-> a <-> a) <:> a
listForm = vconcat [hconcat [a, a, a], a]
in shape opForm `shouldBe` shape listForm
it "concat: (a <-> b <-> c) <:> d を実描画すると 1 行目 3 列 + 2 行目で全パネル描画" $
let a = layer (scatter (inline [0, 1, 2 :: Double]) (inline [0, 1, 4 :: Double]))
spec = (a <-> a <-> a) <:> a
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
in length [() | PCircle{} <- ps] `shouldBe` 12 -- 4 パネル × 3 点
-- Phase 18 A1: selectPanels (= subplot panel の名前選択 + 列挙順並べ替え)。
-- panel 名 = 子 spec の vsTitle。 ggplot discrete limits と同じ「選択 + 順序」。
let selPts = layer (scatter (inline [0, 1, 2 :: Double]) (inline [0, 1, 4 :: Double]))
selPanel nm = selPts <> title nm
selGrid = subplots [selPanel "a", selPanel "b", selPanel "c"]
it "P18 selectPanels: 名前で選択し列挙順に並べ替える" $
let s = selGrid <> selectPanels ["c", "a"]
in map (getLast . vsTitle) (selectedSubplots s)
`shouldBe` [Just "c", Just "a"]
it "P18 selectPanels: 不一致名は無視 (存在する名前だけ残る)" $
let s = selGrid <> selectPanels ["zzz", "b"]
in map (getLast . vsTitle) (selectedSubplots s) `shouldBe` [Just "b"]
it "P18 selectPanels: 未指定なら全 panel をそのまま返す (従来不変)" $
map (getLast . vsTitle) (selectedSubplots selGrid)
`shouldBe` [Just "a", Just "b", Just "c"]
it "P18 selectPanels: title 無し panel は選択時に落ちる" $
let s = subplots [selPts, selPanel "a"] <> selectPanels ["a"]
in length (selectedSubplots s) `shouldBe` 1
it "P18 selectPanels: 実描画で選択 panel の点だけ描かれる" $
let s = selGrid <> selectPanels ["a", "c"] <> subplotCols 2
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver s) s
in length [() | PCircle{} <- ps] `shouldBe` 6 -- 2 パネル × 3 点
-- Phase 18 A2: scale{X,Y}DiscreteLimits (= ggplot scale_*_discrete(limits=))。
-- ColTxt encoding の layer のカテゴリ行を選択 + 列挙順に並べ替え (全 encoding 整合)。
it "P18 discrete limits (Y): forest の行を選択 + 列挙順に並べ替え (encX/errorX も追従)" $
let s = layer (forest (inlineCat ["a", "b", "c" :: Data.Text.Text])
(inline [1, 2, 3 :: Double])
(inline [0.1, 0.2, 0.3 :: Double]))
<> scaleYDiscreteLimits ["c", "a"]
l = head (vsLayers (applyDiscreteLimits emptyResolver s))
cat = case getLast (lyEncY l) of Just (ColTxt v) -> V.toList v; _ -> []
est = case getLast (lyEncX l) of Just (ColNum v) -> V.toList v; _ -> []
err = case getLast (lyErrorX l) of Just (ColNum v) -> V.toList v; _ -> []
in (cat, est, err) `shouldBe` (["c", "a"], [3, 1], [0.3, 0.1])
it "P18 discrete limits (X): bar の実描画で選択カテゴリの本数だけ PRect が出る" $
let mk lim = let s = layer (bar (inlineCat ["p", "q", "r" :: Data.Text.Text])
(inline [1, 2, 3 :: Double])) <> lim
in length [ () | PRect{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver s) s ]
in (mk (scaleXDiscreteLimits ["r", "p"]), mk mempty)
`shouldBe` (mk mempty - 1, mk mempty) -- bar 3→2 本 (他 PRect は不変)
it "P18 discrete limits: coord_flip と直交 (flip 後も aes 基準で効く)" $
let mk lim = let s = layer (bar (inlineCat ["p", "q", "r" :: Data.Text.Text])
(inline [1, 2, 3 :: Double]))
<> coordFlip <> lim
in length [ () | PRect{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver s) s ]
in mk (scaleXDiscreteLimits ["p"]) `shouldBe` mk mempty - 2 -- 3→1 本
it "P18 discrete limits: ColByName 列も resolver 経由 (bake) で filter される" $
let res n = case n of
"g" -> Just (TxtData (V.fromList ["p", "q", "r"]))
"v" -> Just (NumData (V.fromList [1, 2, 3]))
_ -> Nothing
s = layer (bar (ColByName "g") (ColByName "v"))
<> scaleXDiscreteLimits ["q"]
l = head (vsLayers (applyDiscreteLimits res s))
cat = case getLast (lyEncX l) of Just (ColTxt v) -> V.toList v; _ -> []
in cat `shouldBe` ["q"]
-- Phase 52.D2: streamgraph (= 中心化積層 area)。 color aes で系列分割し、 各系列を
-- 塗り polygon (PPath) で描く。 baseline は -(Σy)/2 から (silhouette 中心化)。
let streamR n = case n of
"t" -> Just (NumData (V.fromList [0,1,2, 0,1,2 :: Double]))
"v" -> Just (NumData (V.fromList [1,2,3, 2,2,1 :: Double]))
"g" -> Just (TxtData (V.fromList ["a","a","a","b","b","b"]))
_ -> Nothing
it "D2 stream: 2 系列で PPath を 2 枚 (= 系列数ぶん) 出す" $
let spec = layer (stream "t" "v" <> colorBy "g")
ps = renderToPrimitives streamR (computeLayout streamR spec) spec
in length [() | PPath{} <- ps] `shouldBe` 2
it "D2 stream: 1 系列なら PPath 1 枚" $
let r1 n = case n of
"t" -> Just (NumData (V.fromList [0,1,2 :: Double]))
"v" -> Just (NumData (V.fromList [1,2,3 :: Double]))
"g" -> Just (TxtData (V.fromList ["a","a","a"]))
_ -> Nothing
spec = layer (stream "t" "v" <> colorBy "g")
ps = renderToPrimitives r1 (computeLayout r1 spec) spec
in length [() | PPath{} <- ps] `shouldBe` 1
it "D2 stream: 中心化積層で y domain が負側に広がる (baseline=-Σy/2)" $
let spec = layer (stream "t" "v" <> colorBy "g")
layout = computeLayout streamR spec
-- 各 x 総和 max M=4 (x=1,2 で 2+2 / 3+1) → range [-2,2] を含む (pad で更に外側)
in lsDomainLo (lpYScale layout) `shouldSatisfy` (< 0)
-- Phase 52.D1: repeatFields = フィールド名を反復し 1 view/フィールドを生成して
-- subplots に並べる (Vega-Lite repeat 相当)。 3 フィールド × 3 点 scatter = 9 circle。
it "D1 repeatFields: フィールド数ぶんの panel が subplots に展開される" $
let mk _f = layer (scatter (inline [0, 1, 2 :: Double])
(inline [0, 1, 4 :: Double]))
spec = repeatFields (["a", "b", "c"] :: [Data.Text.Text]) mk
<> subplotCols 3
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
in length [() | PCircle{} <- ps] `shouldBe` 9
-- Phase 52.A11: DAG (MDAG・renderDAGOnly 経路) を subplot セル内に置くと、 修正前は
-- area を viewport (subplot では 0 に潰れる) から絶対原点 (40,50) で作っていたため、
-- DAG が自セルを無視し図全体の左上に漏れていた。 修正後は viewport=0 を subplot 文脈と
-- 見て base 矩形を lpPlotArea (panelRect) に切替えるため各セルに収まる。 2 列に DAG を
-- 並べ、 ノードラベル (PText) が左半分・右半分の両方に出ることを確認 (修正前は全て左上)。
it "A11 subplot 内 DAG: 各セルに収まる (左右両半分にノードが出る)" $
let dagSpec = layer (Graphics.Hgg.DAG.dagPlot
(("a" :: Data.Text.Text) ~> "b"))
spec = subplots [dagSpec, dagSpec] <> subplotCols 2 -- 横 2 セル
lay = computeLayout emptyResolver spec
-- 図中点 (= viewport 幅の半分)。既定サイズ非依存に左右セルを判定する。
midX = fromIntegral (vsW (lpViewport lay)) / 2
ps = renderToPrimitives emptyResolver lay spec
textXs = [ x | PText (Point x _) _ _ <- ps ]
in (any (> midX) textXs, any (< midX) textXs) `shouldBe` (True, True)
-- Phase 8 C G7: facet_wrap 複数行 (5 群 ncol=3 → 2 行)。 全点が各 panel に描かれ、
-- panel frame が 5 枚 + background で PRect >= 6 (= 折り返しても panel が潰れない)。
it "G7 facetWrap 5 群 ncol=3: 全 20 点描画 + panel frame 5 枚" $
let r n = case n of
"x" -> Just (NumData (V.fromList (concat (replicate 5 [1,2,3,4]))))
"y" -> Just (NumData (V.fromList
[1,4,9,16,2,5,8,12,3,6,9,15,2,3,7,10,4,8,11,14]))
"g" -> Just (TxtData (V.fromList
(concatMap (replicate 4) ["A","B","C","D","E"])))
_ -> Nothing
spec = layer (scatter "x" "y" <> size 6) <> facetWrap "g" 3
ps = renderToPrimitives r (computeLayout r spec) spec
nCircles = length [() | PCircle{} <- ps]
nRects = length [() | PRect{} <- ps]
in (nCircles, nRects >= 6) `shouldBe` (20, True)
it "ColorByCol で categorical 3 値 → 3 色の Okabe-Ito palette" $
let r n = case n of
"x" -> Just (NumData (V.fromList [0, 1, 2, 3, 4, 5]))
"y" -> Just (NumData (V.fromList [0, 1, 4, 9, 16, 25]))
"g" -> Just (TxtData (V.fromList ["a", "b", "c", "a", "b", "c"]))
_ -> Nothing
spec = layer (scatter "x" "y" <> colorBy "g")
ps = renderToPrimitives r (computeLayout r spec) spec
colors = [c | PCircle _ _ (FillStyle c _) _ _ <- ps]
in length (Data.List.nub colors) `shouldBe` 3
-- Phase 62: facet が inline encoding (ColNum/ColTxt) を分割しない不具合の回帰。
-- 「例外が出ない」 だけの smoke にせず **PCircle 数 + 座標** で分割を検証する
-- (旧テストは ColByName 経路のみで inline を素通りさせていた)。
describe "Phase 62: facet × inline encoding の分割" $ do
let xs4 = [1, 2, 3, 4] :: [Double]
ys4 = [10, 20, 30, 40] :: [Double]
gs4 = ["g1", "g1", "g2", "g2"] :: [Data.Text.Text]
rP n = case n of
"x" -> Just (NumData (V.fromList xs4))
"y" -> Just (NumData (V.fromList ys4))
"g" -> Just (TxtData (V.fromList gs4))
"h" -> Just (TxtData (V.fromList ["h1", "h1", "h1", "h1"]))
_ -> Nothing
circlesOf r spec =
[ (x, y) | PCircle (Point x y) _ _ _ _ <-
renderToPrimitives r (computeLayout r spec) spec ]
it "inline encoding + inline facet 列 → 4 点 (報告者ケースの回帰)" $
let spec = layer (scatter (inline xs4) (inline ys4))
<> facet (inlineCat gs4)
in length (circlesOf emptyResolver spec) `shouldBe` 4
it "inline encoding + 名前参照 facet 列 → 4 点" $
let spec = layer (scatter (inline xs4) (inline ys4)) <> facet "g"
in length (circlesOf rP spec) `shouldBe` 4
it "名前参照 + scaleXDiscreteLimits + facet → 4 点 (経路 2 = bakeSpec 強制 inline 化の回帰)" $
let spec = layer (scatter "x" "y") <> facet "g"
<> scaleXDiscreteLimits ["dummy"]
in length (circlesOf rP spec) `shouldBe` 4
it "facetGrid + inline encoding → 4 点 (grid 経路の回帰)" $
let spec = layer (scatter (inline xs4) (inline ys4)) <> facetGrid "g" "h"
in length (circlesOf rP spec) `shouldBe` 4
-- 座標レベル: どの点がどの panel に入ったかを検証 (総数だけでは分割先の
-- 入れ替わりを検出できない)。 panel はアルファベット順で g1=左, g2=右。
-- fixed 共有 y scale では screen y は data y に単調減少なので、 g1 の 2 点
-- (y=10,20) の screen y は g2 の 2 点 (y=30,40) より必ず大きい。
it "座標レベル: 左 panel = g1 (y=10,20)、 右 panel = g2 (y=30,40)" $
let spec = layer (scatter (inline xs4) (inline ys4))
<> facet (inlineCat gs4)
cs = Data.List.sortOn fst (circlesOf emptyResolver spec)
(leftPts, rightPts) = splitAt 2 cs
allBelow = and [ yl > yr | (_, yl) <- leftPts, (_, yr) <- rightPts ]
in (length cs, allBelow) `shouldBe` (4, True)
-- §3: facet 列と長さの合わない inline は黙って切り詰めず据え置き (= 未分割の
-- まま全 panel に描かれる) + 警告診断。 その挙動を固定する。
it "長さ不一致 inline (5 行 vs facet 4 行) は据え置き = 全 panel に 5 点ずつ" $
let spec = layer (scatter (inline [1, 2, 3, 4, 5 :: Double])
(inline [1, 2, 3, 4, 5 :: Double]))
<> facet (inlineCat gs4)
in length (circlesOf emptyResolver spec) `shouldBe` 10
it "長さ不一致は facetInlineDiagnostics が警告 (x, y の 2 本)" $
let spec = layer (scatter (inline [1, 2, 3, 4, 5 :: Double])
(inline [1, 2, 3, 4, 5 :: Double]))
<> facet (inlineCat gs4)
in length (facetInlineDiagnostics emptyResolver spec) `shouldBe` 2
it "limits の行 drop で facet と desync した場合も警告 (既知の限界の検出)" $
let rc n = case n of
"xc" -> Just (TxtData (V.fromList ["a", "b", "c", "d"]))
"y" -> Just (NumData (V.fromList ys4))
"g" -> Just (TxtData (V.fromList gs4))
_ -> Nothing
spec = layer (bar "xc" "y") <> facet "g"
<> scaleXDiscreteLimits ["a", "b", "c"]
in length (facetInlineDiagnostics rc spec) `shouldBe` 2
it "全長一致なら警告ゼロ" $
let spec = layer (scatter (inline xs4) (inline ys4))
<> facet (inlineCat gs4)
in facetInlineDiagnostics emptyResolver spec `shouldBe` []
describe "Phase 1 A2: Sugiyama rank assignment (= network simplex framework)" $ do
it "linear chain a→b→c は rank 0,1,2" $
let lg = Sugi.assignRanks
(Sugi.buildLayoutGraph ["a", "b", "c"]
[("a", "b"), ("b", "c")])
rankOf x = head [ Sugi.lnRank n | n <- Sugi.lgNodes lg, Sugi.lnId n == x ]
in (rankOf "a", rankOf "b", rankOf "c") `shouldBe` (0, 1, 2)
it "diamond a→b, a→c, b→d, c→d は a=0, b=c=1, d=2" $
let lg = Sugi.assignRanks
(Sugi.buildLayoutGraph ["a", "b", "c", "d"]
[("a","b"),("a","c"),("b","d"),("c","d")])
rankOf x = head [ Sugi.lnRank n | n <- Sugi.lgNodes lg, Sugi.lnId n == x ]
in [rankOf "a", rankOf "b", rankOf "c", rankOf "d"] `shouldBe` [0, 1, 1, 2]
it "孤立 node は rank 0" $
let lg = Sugi.assignRanks (Sugi.buildLayoutGraph ["x"] [])
in map Sugi.lnRank (Sugi.lgNodes lg) `shouldBe` [0]
it "結果は常に feasible (= rank(v) - rank(u) ≥ δ)" $
let lg = Sugi.assignRanks
(Sugi.buildLayoutGraph ["a","b","c","d","e"]
[("a","b"),("a","c"),("b","d"),("c","d"),("d","e"),("a","e")])
in Sugi.isFeasible lg `shouldBe` True
describe "Step3.1: 汎用 network simplex (networkSimplex, P4a x 座標ソルバ)" $ do
let feasibleAll es r = all (\(t, h, d, _) ->
Map.findWithDefault 0 h r - Map.findWithDefault 0 t r >= d) es
obj es r = sum [ w * fromIntegral (Map.findWithDefault 0 h r - Map.findWithDefault 0 t r)
| (t, h, _, w) <- es ] :: Double
it "一様 δ=ω=1 diamond は longest-path と一致 (a0 b1 c1 d2)" $
let es = [("a","b",1,1),("a","c",1,1),("b","d",1,1),("c","d",1,1)]
r = Sugi.networkSimplex ["a","b","c","d"] es
in (Map.findWithDefault (-1) "a" r, Map.findWithDefault (-1) "b" r,
Map.findWithDefault (-1) "c" r, Map.findWithDefault (-1) "d" r)
`shouldBe` (0, 1, 1, 2)
it "longest-path が非最適な異δ案件で最適目的値に到達 (a→c δ1, b→c δ5 → obj 6)" $
let es = [("a","c",1,1),("b","c",5,1)]
r = Sugi.networkSimplex ["a","b","c"] es
in (feasibleAll es r, obj es r) `shouldBe` (True, 6)
it "Ω 重み (1:8) で重い chain を直線化 (t0 m1 b2)" $
let es = [("t","m",1,8),("m","b",1,8),("t","b",2,1)]
r = Sugi.networkSimplex ["t","m","b"] es
in (Map.findWithDefault (-1) "t" r, Map.findWithDefault (-1) "m" r,
Map.findWithDefault (-1) "b" r, feasibleAll es r)
`shouldBe` (0, 1, 2, True)
it "孤立 node は 0" $
Sugi.networkSimplex ["x","y"] [] `shouldBe` Map.fromList [("x",0),("y",0)]
it "非連結成分は独立に解け各成分の最小が 0" $
let es = [("a","b",1,1),("c","d",3,1)]
r = Sugi.networkSimplex ["a","b","c","d"] es
in (feasibleAll es r,
Map.findWithDefault (-1) "a" r, Map.findWithDefault (-1) "b" r,
Map.findWithDefault (-1) "c" r, Map.findWithDefault (-1) "d" r)
`shouldBe` (True, 0, 1, 0, 3)
describe "Step3.2: aux-graph x 座標 (P4a, dummy 直線化 + chain body 外分離)" $ do
-- 長 edge (= 自 chain と並走する skip) の dummy 列が、 chain node の body の
-- 外へ出て、 かつ Ω=8 直線化で collinear (= 同 x) になることを assignCoords 経由で検証。
-- これが P4a の核心 (= large funnel collapse の layout 層 主因の根治)。
it "並走 skip の dummy は collinear (= 同 x、 |Δx| < 1e-9)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph
["a0","a1","a2","a3","a4"]
[("a0","a1"),("a1","a2"),("a2","a3"),("a3","a4") -- chain
,("a0","a4")] -- 並走 skip (dummy 3 個)
(g1, om) = Sugi.assignOrder g0
coords = Sugi.assignCoords [] g1 om
dumXs = [ x | (k, x) <- Map.toList coords, Data.Text.isPrefixOf "__dummy_" k ]
in case dumXs of
[] -> expectationFailure "dummy が無い (skip が dummy 化されていない)"
_ -> maximum dumXs - minimum dumXs `shouldSatisfy` (< 1e-9)
it "並走 skip の dummy 列は chain node 列から分離 (= 同 x でない)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph
["a0","a1","a2","a3","a4"]
[("a0","a1"),("a1","a2"),("a2","a3"),("a3","a4"),("a0","a4")]
(g1, om) = Sugi.assignOrder g0
coords = Sugi.assignCoords [] g1 om
dumX = head [ x | (k, x) <- Map.toList coords, Data.Text.isPrefixOf "__dummy_" k ]
chainX = Map.findWithDefault (-1) "a1" coords -- 中間 chain node
in abs (dumX - chainX) `shouldSatisfy` (> 1e-6)
-- Phase 39 Step8 (P8) A1: cluster border 制約 (graphviz pos_clusters) を
-- P4a aux simplex へ注入。 plate メンバに左右 border node + contain/keepout
-- edge を張り、 非メンバが box の外へ・box が tight になることを raw 座標で検証。
it "P8 A1 keepout: 非メンバ q が plate メンバ x 区間の外 (auxSimplexCoords)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph
["r","p0","p1","q"]
[("r","p0"),("r","p1"),("r","q")]
(g1, om0) = Sugi.assignOrder g0
om = Sugi.applyPlateConstraints [["p0","p1"]] om0
c = Sugi.auxSimplexCoords [["p0","p1"]] g1 om
ps = [c Map.! "p0", c Map.! "p1"]
q = c Map.! "q"
in (q < minimum ps || q > maximum ps) `shouldBe` True
it "P8 A1 keepout: plate 有りは非メンバ⇄member 間隔が plate 無し以上 (border margin)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph
["r","p0","p1","q"]
[("r","p0"),("r","p1"),("r","q")]
(g1, om0) = Sugi.assignOrder g0
-- 同一 order (plate 制約済) に対し border edge の有無だけ変える公正比較
om = Sugi.applyPlateConstraints [["p0","p1"]] om0
gap pl = let c = Sugi.auxSimplexCoords pl g1 om
ps = [c Map.! "p0", c Map.! "p1"]
in minimum [ abs (c Map.! "q" - p) | p <- ps ]
in gap [["p0","p1"]] `shouldSatisfy` (>= gap [])
-- Phase 39 P8 A4-2 separate_subclust: 同 rank に並ぶ兄弟 plate (= 包含関係に無い)
-- の隣接 border 間に graphviz @make_aux_edge(rn_left, ln_right, CL_OFFSET, 0)@ を
-- 張り、 兄弟 plate box が重ならないよう CL_OFFSET ぶんの隙間を simplex 解に確保する。
-- faithful 証拠 = 兄弟 plate 間の member gap が plate 内 member gap より広いこと
-- (= border contain margin + CL_OFFSET が plate 内 nodesep を上回る・raw 座標で検証)。
it "P8 A4-2 separate_subclust: 兄弟 plate 間 gap > plate 内 gap (raw simplex)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph
["r","p0","p1","q0","q1"]
[("r","p0"),("r","p1"),("r","q0"),("r","q1")]
(g1, om0) = Sugi.assignOrder g0
plates = [["p0","p1"], ["q0","q1"]]
om = Sugi.applyPlateConstraints plates om0
c = Sugi.auxSimplexCoords plates g1 om
-- 4 member の x を昇順に。 plate 内 2 member は連続するので
-- 並びは [plateL_m0, plateL_m1, plateR_m0, plateR_m1]。
[a, b, cc, d] = sort [c Map.! k | k <- ["p0","p1","q0","q1"]]
gMid = cc - b -- 兄弟 plate 間 (separate_subclust + border margin)
gLeft = b - a -- 左 plate 内 (nodesep のみ)
gRight = d - cc -- 右 plate 内 (nodesep のみ)
in (gMid > gLeft, gMid > gRight) `shouldBe` (True, True)
-- Phase 39 P8 A4-2 完全忠実 point pipeline: 'auxSimplexCoordsW' は per-node 実半幅
-- (hwMap) を LR 制約 'auxSepOf' に反映する (= graphviz の point 一貫 layout)。
-- 幅広 node は隣接 sep を押し広げるため、 同 rank 全体の span が広がることを検証する。
it "P8 A4-2 point pipeline: 幅広 node は同 rank の span を広げる (size-aware)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph
["r","a","b","c"]
[("r","a"),("r","b"),("r","c")]
(g1, om) = Sugi.assignOrder g0
spanOf m = let xs = [m Map.! k | k <- ["a","b","c"]]
in maximum xs - minimum xs
narrow = Sugi.auxSimplexCoordsW Map.empty [] g1 om -- 一律 fallback 半幅
wide = Sugi.auxSimplexCoordsW (Map.fromList [("b", 80)]) [] g1 om
in spanOf wide `shouldSatisfy` (> spanOf narrow)
-- Phase 19 A4: rank 引き締め (source 引き下げ + エッジ無し plate メンバ)
it "tightenSourceRanks: 深い消費者を持つ source は直前 rank へ (a→b→c, s→c)" $
let lg = Sugi.tightenSourceRanks []
(Sugi.assignRanks
(Sugi.buildLayoutGraph ["a", "b", "c", "s"]
[("a","b"),("b","c"),("s","c")]))
rankOf x = head [ Sugi.lnRank n | n <- Sugi.lgNodes lg, Sugi.lnId n == x ]
in (rankOf "a", rankOf "b", rankOf "c", rankOf "s", Sugi.isFeasible lg)
`shouldBe` (0, 1, 2, 1, True)
it "tightenSourceRanks: エッジ無し node は所属 plate の最小 rank へ" $
let lg = Sugi.tightenSourceRanks [["b", "c", "g"]]
(Sugi.assignRanks
(Sugi.buildLayoutGraph ["a", "b", "c", "g"]
[("a","b"),("b","c")]))
rankOf x = head [ Sugi.lnRank n | n <- Sugi.lgNodes lg, Sugi.lnId n == x ]
in (rankOf "g", rankOf "b") `shouldBe` (1, 1)
it "tightenSourceRanks: 浅い source / plate 無しは no-op (既存図ビット不変)" $
let mk = Sugi.assignRanks
(Sugi.buildLayoutGraph ["a","b","c","d"]
[("a","b"),("a","c"),("b","d"),("c","d")])
in Sugi.tightenSourceRanks [] mk `shouldBe` mk
-- Phase 19 A5 → Phase 39 P8: plate 枠の重なり解消。 旧 cosmetic 'applyPlateBands'
-- (帯分離) は撤去済 (Step8)。 現在は P8 cluster 制約 (border node + contain/keepout)
-- が simplex 内で member x 区間を分離するため、 同じ構造的不変条件が faithful 経路で成立する。
it "P8 cluster 制約: 2 plate のメンバ x 区間が分離し非メンバは帯外 (旧 applyPlateBands 置換)" $
let mkN i = DAGNode i i NodeLatent Nothing 0 0
nodes = map mkN ["h", "b0", "b1", "x", "mu", "y", "s"]
es = [ DAGEdge f t Nothing Nothing
| (f, t) <- [("h","b0"),("h","b1"),("b0","mu"),("b1","mu")
,("x","mu"),("mu","y"),("s","y")] ]
plates = [ DAGPlate "G" ["b0", "b1"], DAGPlate "O" ["x", "mu", "y"] ]
(pos, _) = Graphics.Hgg.DAG.layoutHierarchicalFullWithPlates nodes es plates
xOf i = head [ dnX n | n <- pos, dnId n == i ]
gXs = [xOf "b0", xOf "b1"]
oXs = [xOf "x", xOf "mu", xOf "y"]
disjoint = maximum gXs < minimum oXs || maximum oXs < minimum gXs
-- s (rank 2 = O の rank 範囲内・非メンバ) は O メンバ区間の外
sOut = xOf "s" < minimum oXs || xOf "s" > maximum oXs
in (disjoint, sOut) `shouldBe` (True, True)
-- Phase 20 → Phase 39 P8: nested の兄弟 plate 分離。 旧 'applyPlateBands' 再帰版は
-- 撤去済 (Step8)。 現在は P8 cluster 制約 + separate_subclust が同 rank の兄弟 cluster
-- 間に CL_OFFSET を確保することで faithful に区間分離する。
it "P8 cluster 制約: nested の兄弟 plate の x 区間が分離 (旧 applyPlateBands 置換)" $
let mkN i = DAGNode i i NodeLatent Nothing 0 0
-- school plate ⊃ {classA, classB} の入れ子。 各 class に 2 ノード +
-- school 直下に s0。 root h → 各ノード → 観測 y。
ids = ["h", "a0", "a1", "b0", "b1", "s0", "y"]
nodes = map mkN ids
es = [ DAGEdge f t Nothing Nothing
| (f, t) <- [("h","a0"),("h","a1"),("h","b0"),("h","b1")
,("h","s0")
,("a0","y"),("a1","y"),("b0","y"),("b1","y")
,("s0","y")] ]
plates = [ DAGPlate "school" ["a0", "a1", "b0", "b1", "s0"]
, DAGPlate "classA" ["a0", "a1"]
, DAGPlate "classB" ["b0", "b1"] ]
(pos, _) = Graphics.Hgg.DAG.layoutHierarchicalFullWithPlates nodes es plates
xOf i = head [ dnX n | n <- pos, dnId n == i ]
aXs = [xOf "a0", xOf "a1"]
bXs = [xOf "b0", xOf "b1"]
-- 兄弟 nested plate (classA / classB) の x 区間が交わらない
sibDisjoint = maximum aXs < minimum bXs || maximum bXs < minimum aXs
-- s0 (school メンバ・非 class メンバ) は両 class 区間の外
s0Out = all (\xs -> xOf "s0" < minimum xs || xOf "s0" > maximum xs)
[aXs, bXs]
-- 全 nested メンバは school の帯 (= school メンバ全体の包) に居る前提で
-- 帯内に収まる (parent bbox を壊さない)
schoolXs = [xOf i | i <- ["a0","a1","b0","b1","s0"]]
inParent = all (\x -> x >= minimum schoolXs && x <= maximum schoolXs)
(aXs ++ bXs)
in (sibDisjoint, s0Out, inParent) `shouldBe` (True, True, True)
-- ★ Phase 44.1: skip edge が plate 箱を貫通しない (edge 幾何回帰ゲート)。
-- a → plate{b,c} → d + skip a→d で、a→d の routing が plate 箱の **内部**へ侵入しない
-- ことを pt 空間で検証する。graphviz は skip edge を cluster 箱の外へ回す (= 箱貫通 0)。
-- ★ Phase 39 P8 A2 (e164df01) の stopgap (applyPlateBands) 撤去で a→d が箱の角を抉る
-- 回帰が入ったが、layout keepout / path 本数 test では捕まらなかった (= 本 test で恒久検出)。
-- 制御点だけでなく cubic Bézier を実サンプルする (角抉りは制御点が箱外でも曲線が箱に入るため)。
it "Phase 44.1 回帰ゲート: skip edge a→d が plate 箱を貫通しない (cubic 実サンプル)" $
let mkN i = DAGNode i i NodeLatent Nothing 0 0
nodes = map mkN ["a", "b", "c", "d"]
es = [ DAGEdge f t Nothing Nothing
| (f, t) <- [("a","b"),("b","d"),("a","c"),("c","d"),("a","d")] ]
plates = [ DAGPlate "plate" ["b", "c"] ]
(pos, routed) = Graphics.Hgg.DAG.layoutHierarchicalFullWithPlates nodes es plates
radius = 20 :: Double
toScreen = dagToScreen radius pos LayoutHierarchical
nodeMap = [ (dnId n, n) | n <- pos ]
look k = head [ n | n <- pos, dnId n == k ]
obs = ER.dagObstacles toScreen radius pos nodeMap plates routed
ad = head [ e | e@(DAGEdge f t _ _) <- routed, f == "a", t == "d" ]
adPath = (\(DAGEdge _ _ p _) -> p) ad
rt = ER.routeEdge toScreen obs (look "a") (look "d") adPath radius 0 1
-- EdgeRoute → 細サンプル点列。 CubicPath は 3 点ずつの cubic Bézier を評価、
-- それ以外は制御点間を線形補間 (折れ線近似)。
bez (Point ax ay) (Point bx by) (Point cx cy) (Point dx dy) t =
let u = 1 - t
in Point (u*u*u*ax + 3*u*u*t*bx + 3*u*t*t*cx + t*t*t*dx)
(u*u*u*ay + 3*u*u*t*by + 3*u*t*t*cy + t*t*t*dy)
sampleCubic (p0:c1:c2:p3:rest) =
[ bez p0 c1 c2 p3 t | t <- [0, 0.05 .. 1.0] ] ++ sampleCubic (p3:rest)
sampleCubic _ = []
lerp (Point x1 y1) (Point x2 y2) t = Point (x1+(x2-x1)*t) (y1+(y2-y1)*t)
samplePoly ps = concat [ [ lerp p q t | t <- [0, 0.1 .. 1.0] ] | (p, q) <- zip ps (drop 1 ps) ]
samples = case rt of
ER.CubicPath ps -> sampleCubic ps
ER.BezierPath ps -> samplePoly ps
ER.SplinePath ps -> samplePoly ps
ER.StraightArrow p q -> samplePoly [p, q]
box = ER.plateBoxPt toScreen radius nodeMap plates (head plates)
inside (Point x y) = case box of
Just (xlo, ylo, xhi, yhi) -> x > xlo && x < xhi && y > ylo && y < yhi
Nothing -> False
in any inside samples `shouldBe` False
-- ★ Phase 53 A4: per-edge box 回廊 (= 他 edge の dummy lane 侵入禁止) の回帰ゲート。
-- 並走する 2 本の skip edge (a→z / b→z、 dummy lane が隣接) で、 各 edge の spline が
-- **相手 lane の box (半幅 9pt)** の内側へ入らないことを rank band 近傍の実サンプルで
-- 検証する。 graphviz maximal_bbox の「隣接 virtual node で clip」 の忠実化 (= corr6
-- braid の根治機構) を恒久検出する。
it "Phase 53 A4 回帰ゲート: 並走 skip edge が相手の dummy lane に侵入しない" $
let mkN i = DAGNode i i NodeLatent Nothing 0 0
nodes = map mkN ["a", "b", "p", "q", "z"]
es = [ DAGEdge f t Nothing Nothing
| (f, t) <- [("a","p"),("b","p"),("p","q"),("q","z"),("a","z"),("b","z")] ]
(pos, routed) = Graphics.Hgg.DAG.layoutHierarchicalFull nodes es
radius = 20 :: Double
toScreen = dagToScreen radius pos LayoutHierarchical
nodeMap = [ (dnId n, n) | n <- pos ]
look k = head [ n | n <- pos, dnId n == k ]
obs = ER.dagObstacles toScreen radius pos nodeMap [] routed
pathOf f t = (\(DAGEdge _ _ p _) -> p)
(head [ e | e@(DAGEdge f' t' _ _) <- routed, f' == f, t' == t ])
routeOf f t = ER.routeEdge toScreen obs (look f) (look t) (pathOf f t) radius 0 1
-- 相手 lane の dummy 座標 (screen)
dummiesOf f t = case pathOf f t of
Just chain -> [ toScreen x y
| (x, y) <- take (length chain - 2) (drop 1 chain) ]
Nothing -> []
bez (Point ax ay) (Point bx by) (Point cx cy) (Point dx dy) t =
let u = 1 - t
in Point (u*u*u*ax + 3*u*u*t*bx + 3*u*t*t*cx + t*t*t*dx)
(u*u*u*ay + 3*u*u*t*by + 3*u*t*t*cy + t*t*t*dy)
sampleCubic (p0:c1:c2:p3:rest) =
[ bez p0 c1 c2 p3 t | t <- [0, 0.05 .. 1.0] ] ++ sampleCubic (p3:rest)
sampleCubic _ = []
samplesOf r = case r of
ER.CubicPath ps -> sampleCubic ps
ER.SplinePath ps -> ps
ER.BezierPath ps -> ps
ER.StraightArrow p' q' -> [p', q']
-- spline (f,t) が相手 lane (f',t') の dummy へ x 距離 5pt 未満に近づく
-- rank band 近傍 (|y差| ≤ 4pt) のサンプルが無いこと
invades (f, t) (f', t') = or
[ abs (px - dx) < 5
| Point dx dy <- dummiesOf f' t'
, Point px py <- samplesOf (routeOf f t)
, abs (py - dy) <= 4 ]
lanesSeparate = case (dummiesOf "a" "z", dummiesOf "b" "z") of
(da@(_:_), db@(_:_)) -> and [ abs (ax - bx) >= 18 - 1e-6
| (Point ax _, Point bx _) <- zip da db ]
_ -> False
in ( lanesSeparate
, invades ("a", "z") ("b", "z")
, invades ("b", "z") ("a", "z") ) `shouldBe` (True, False, False)
-- Phase 39 A3: fit の bbox ≤ canvas 回帰ゲート。 renderDAGStandalone は
-- fitPrimsToArea で全 primitive (plate 枠・ラベル・ノード・矢印・skip edge) を
-- area 内へ収めるはず。 plate + free node (σ) + plate 跨ぎ skip edge を含む DAG を
-- 縦横様々な canvas 寸法で描き、 bbox が area を一切超えないことを数値検証する。
it "renderDAGStandalone: 全 primitive bbox が canvas area 内 (A3 はみ出しゼロ)" $
let g = ("mu" :: Data.Text.Text) ~> "t1" <> "mu" ~> "t2"
<> "t1" ~> "y" <> "t2" ~> "y" <> "s" ~> "y" <> "mu" ~> "y"
plate = DAGPlate "grp (n=2)" ["t1", "t2"]
lyr = Graphics.Hgg.DAG.dagPlotWithPlates g [plate]
pal = themePalette ThemeLight
eps = 0.5 -- FP 誤差許容
fits (w, h) =
let prims = renderDAGStandalone (Rect 0 0 w h) pal lyr
in case primsBBoxDAG prims of
Nothing -> False
Just (xlo, ylo, xhi, yhi) ->
xlo >= negate eps && ylo >= negate eps
&& xhi <= w + eps && yhi <= h + eps
in map fits [(600, 400), (300, 500), (800, 220), (220, 800)]
`shouldBe` [True, True, True, True]
-- Phase 23: plate 枠 = glyph bbox (中心 ± nodeExtent)。 旧実装 (中心 bbox +
-- 固定 pad radius*1.6) では label ≥ 9 文字のノードが水平端で枠を超えていた
-- (analyze Phase 63.2 の実測再現 = 長 label Data box が 22.3px 突き抜け)。
it "renderPlate: 長 label ノードの glyph box が plate 枠に収まる (Phase 23)" $
let nodes = [ DAGNode "x_duration_long" "x_duration_long" NodeData
(Just "Data") 0 0
, DAGNode "y" "y" NodeObserved (Just "NegativeBinomial") 0 0 ]
es = [ DAGEdge "x_duration_long" "y" Nothing Nothing ]
plates = [ DAGPlate "obs (4)" ["x_duration_long", "y"] ]
(pos, routed) =
Graphics.Hgg.DAG.layoutHierarchicalFullWithPlates nodes es plates
spec = layer (dagFromListsWithPlates pos routed LayoutHierarchical plates)
<> widthUnit (760 *~ px) <> heightUnit (520 *~ px)
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
-- plate 枠 = fill-opacity 0 の PRect / node glyph 箱 = opacity > 0 の
-- PRect (背景の白 rect は除外)
frames = [ r | PRect r (FillStyle _ o) _ <- ps, o == 0 ]
boxes = [ r | PRect r (FillStyle c o) _ <- ps
, o > 0, c /= Data.Text.pack "#ffffff" ]
contains (Rect fx fy fw fh) (Rect bx by bw bh) =
fx <= bx && bx + bw <= fx + fw && fy <= by && by + bh <= fy + fh
in case frames of
[frame] -> (length boxes >= 1, all (contains frame) boxes)
`shouldBe` (True, True)
_ -> expectationFailure ("plate 枠 PRect が 1 個でない: "
<> show (length frames))
it "一様 δ=ω=1 では longest-path が edge length sum 最適 (= assignRanks と一致)" $
let g0 = Sugi.buildLayoutGraph ["a","b","c","d"]
[("a","b"),("a","c"),("b","d"),("c","d")]
lpOnly = Sugi.longestPathRanking g0
full = Sugi.assignRanks g0
in Sugi.edgeLengthSum full `shouldBe` Sugi.edgeLengthSum lpOnly
it "決定論性: 同 input は同 rank (= 2 回実行で完全一致)" $
let g = Sugi.buildLayoutGraph ["x","y","z","w"]
[("x","y"),("y","z"),("x","w"),("w","z")]
r1 = Sugi.assignRanks g
r2 = Sugi.assignRanks g
in r1 `shouldBe` r2
it "後方互換: dagPlot の y 座標は新 rank 経由でも旧 longest-path と一致" $
let g = ("alpha" :: Data.Text.Text) ~> "y" <> "beta" ~> "y" <> "alpha" ~> "sigma" <> "sigma" ~> "y"
spec = layer (Graphics.Hgg.DAG.dagPlot g)
-- 旧実装と同じ rank 構造: alpha=0, beta=0, sigma=1, y=2
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
in length [() | PPath{} <- ps] `shouldSatisfy` (>= 4) -- 4 node 形状 + arrow
describe "Step6 R2: funnel (Mononen, graphviz Pshortestpath 相当)" $ do
-- 規約: portal.left=小x / portal.right=大x、path は下方向 (y 増加)。
it "wide channel (障害物なし) → 直線 (src,goal のみ・重複なし)" $
let portals = [ (Point 5 0, Point 5 0)
, (Point 0 10, Point 10 10)
, (Point 0 20, Point 10 20)
, (Point 5 30, Point 5 30) ]
in ER.funnel portals `shouldBe` [Point 5 0, Point 5 30]
it "右側障害物 → 左の角で taut に曲がる (cone 不変条件 OK)" $
-- y=10 で free 区間 [2,10] (= x<2 が塞がれる)。 src/goal は x=0。
-- 最短路は (0,0)→(2,10)→(0,20) で角 (2,10) を通る。
let portals = [ (Point 0 0, Point 0 0)
, (Point 2 10, Point 10 10)
, (Point 0 20, Point 0 20) ]
in ER.funnel portals `shouldBe` [Point 0 0, Point 2 10, Point 0 20]
it "taut 折れ線は左右往復しない (旧 zigzag 回帰防止)" $
-- 3 連続 gate が右側を x≥2 に制限。 taut 路の x は単峰 (出て戻る) で、
-- 局所 peak は高々 1 個 = 左右往復ジグザグでないこと。
let portals = [ (Point 0 0, Point 0 0)
, (Point 2 10, Point 10 10)
, (Point 2 20, Point 10 20)
, (Point 2 30, Point 10 30)
, (Point 0 40, Point 0 40) ]
xs = [ x | Point x _ <- ER.funnel portals ]
peaks = length [ () | (a, b, c) <- zip3 xs (drop 1 xs) (drop 2 xs)
, b > a, b > c ]
in peaks `shouldSatisfy` (<= 1)
it "buildChannel+funnel: 端点が片寄っても dummy lane に沿う (L字 shortcut しない)" $
-- dummy lane = x13、 端点は x70/x52 (右寄り)。 旧 (free 区間全幅 portal) は funnel が
-- lane を無視し x≈52 へ shortcut → L字 → R3 bulge。 狭い窓 portal なら経路内部は
-- dummy lane (x13±portalHalfWidth=6) 近傍に留まる。
let guide = [ Point 70 0, Point 13 30, Point 13 60, Point 13 90, Point 52 120 ]
taut = ER.funnel (ER.buildChannel [] guide)
interiorXs = [ x | Point x _ <- drop 1 (init taut) ]
in interiorXs `shouldSatisfy` all (<= 19 + 1e-9)
describe "Step6 R3: cubic solver + Proutespline (graphviz route.c)" $ do
let approxRoots want got = case got of
Right rs -> let s = sort rs
in length s == length want
&& and (zipWith (\a b -> abs (a - b) < 1e-6) s (sort want))
Left () -> False
it "solve3: (x-1)(x-2)(x-3) → {1,2,3}" $
-- x³ -6x² +11x -6
ER.solve3 (-6, 11, -6, 1) `shouldSatisfy` approxRoots [1, 2, 3]
it "solve3: x³ - x → {-1,0,1}" $
ER.solve3 (0, -1, 0, 1) `shouldSatisfy` approxRoots [-1, 0, 1]
it "solve3: 二重根 (x)(x-2)² → {0,2}" $
-- x³ -4x² +4x
ER.solve3 (0, 4, -4, 1) `shouldSatisfy` approxRoots [0, 2]
it "solve3: 線形 2x+4 → {-2}" $
ER.solve3 (4, 2, 0, 0) `shouldSatisfy` approxRoots [-2]
it "proutespline: 障害物なし直線 taut → 始点/終点を保持した cubic" $
let inps = [Point 0 0, Point 0 30, Point 0 60]
ctrl = ER.proutespline [] inps (Point 0 1) (Point 0 1)
in (head ctrl, last ctrl) `shouldBe` (Point 0 0, Point 0 60)
it "proutespline: 制御点列は 始点 + 3k 個 (cubic segment の倍数)" $
let inps = [Point 0 0, Point 0 30, Point 0 60]
ctrl = ER.proutespline [] inps (Point 0 1) (Point 0 1)
in (length ctrl - 1) `mod` 3 `shouldBe` 0
describe "Phase 1 A3: order assignment (= dummy + median + transpose)" $ do
it "insertDummies: 長 edge (rank 差 3) で dummy 2 個 + 短 edge 3 本に展開" $
let g0 = Sugi.buildLayoutGraph ["a", "b"] [("a", "b")]
-- 手動で b の rank を 3 に
g1 = g0 { Sugi.lgNodes = [ Sugi.LNode "a" 0 False
, Sugi.LNode "b" 3 False ] }
g2 = Sugi.insertDummies g1
dummies = [ n | n <- Sugi.lgNodes g2, Sugi.lnDummy n ]
in (length dummies, length (Sugi.lgEdges g2)) `shouldBe` (2, 3)
it "insertDummies: rank 差 1 の edge は触らない (= 元のまま)" $
let g = Sugi.assignRanks (Sugi.buildLayoutGraph ["a","b"] [("a","b")])
g2 = Sugi.insertDummies g
in (length (Sugi.lgNodes g2), length (Sugi.lgEdges g2)) `shouldBe` (2, 1)
it "bilayerCrossings: 2 edge 交差ペアで 1" $
let edges_ = [("a", "y"), ("b", "x")]
in Sugi.bilayerCrossings edges_ ["a", "b"] ["x", "y"] `shouldBe` 1
it "bilayerCrossings: 平行 edge は 0" $
let edges_ = [("a", "x"), ("b", "y")]
in Sugi.bilayerCrossings edges_ ["a", "b"] ["x", "y"] `shouldBe` 0
it "K3,3 風 reverse pattern (= A→Z, B→Y, C→X) は median sweep で crossings 3 → 0" $
let g0 = Sugi.assignRanks $
Sugi.buildLayoutGraph ["A","B","C","X","Y","Z"]
[("A","Z"),("B","Y"),("C","X")]
ini = Sugi.initialOrder g0
(g1, finalOrd) = Sugi.assignOrder g0
cIni = Sugi.countCrossings g0 ini
cFin = Sugi.countCrossings g1 finalOrd
in (cIni, cFin) `shouldBe` (3, 0)
it "決定論性: 同 input → 同 OrderMap (= 2 回 assignOrder 一致)" $
let g0 = Sugi.assignRanks $
Sugi.buildLayoutGraph ["a","b","c","d","e","f"]
[("a","d"),("a","e"),("b","f"),("c","d")]
(_, o1) = Sugi.assignOrder g0
(_, o2) = Sugi.assignOrder g0
in o1 `shouldBe` o2
it "countCrossings は最終 ≤ 初期 (= sweep が必ず改善 or 維持)" $
let g0 = Sugi.assignRanks $
Sugi.buildLayoutGraph ["a","b","c","p","q","r"]
[("a","q"),("a","r"),("b","p"),("c","p"),("c","r")]
ini = Sugi.initialOrder g0
(g1, fin) = Sugi.assignOrder g0
cIni = Sugi.countCrossings g0 ini
cFin = Sugi.countCrossings g1 fin
in cFin <= cIni `shouldBe` True
it "dummy 込み全 LayoutGraph で feasible (= rank 差 = δ = 1 を保つ)" $
let g0 = Sugi.assignRanks $
Sugi.buildLayoutGraph ["a","b","c"] [("a","c"),("a","b"),("b","c")]
(g1, _) = Sugi.assignOrder g0
in Sugi.isFeasible g1 `shouldBe` True
describe "Phase 1 A4: Brandes-Köpfe coordinate assignment (= TD+BU median)" $ do
it "単一 chain a→b→c は全 node 同 x (= 垂直整列、 |Δx| < 1e-9)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["a","b","c"]
[("a","b"),("b","c")]
(g1, om) = Sugi.assignOrder g0
coords = Sugi.assignCoords [] g1 om
[xa, xb, xc] = map (\k -> coords Map.! k) ["a","b","c"]
in maximum (map abs [xa - xb, xb - xc]) `shouldSatisfy` (< 1e-9)
it "対称 diamond a→b,a→c,b→d,c→d で a と d は同 x、 b と c が対称" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["a","b","c","d"]
[("a","b"),("a","c"),("b","d"),("c","d")]
(g1, om) = Sugi.assignOrder g0
coords = Sugi.assignCoords [] g1 om
[xa, xb, xc, xd] = map (\k -> coords Map.! k) ["a","b","c","d"]
in do
abs (xa - xd) `shouldSatisfy` (< 1e-9)
-- b と c は a/d の中心 (= (xa) と対称) → xb + xc ≈ 2 * xa
abs ((xb + xc) - 2 * xa) `shouldSatisfy` (< 1e-9)
it "coord 範囲 [0, 1] (= 正規化)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["p","q","r","s"]
[("p","r"),("q","r"),("r","s")]
(g1, om) = Sugi.assignOrder g0
coords = Map.elems (Sugi.assignCoords [] g1 om)
in do
minimum coords `shouldSatisfy` (>= 0)
maximum coords `shouldSatisfy` (<= 1)
it "決定論性: 同 input → 同 coords (= 2 回 assignCoords 一致)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["a","b","c","d","e"]
[("a","b"),("a","c"),("b","d"),("c","d"),("d","e")]
(g1, om) = Sugi.assignOrder g0
c1 = Sugi.assignCoords [] g1 om
c2 = Sugi.assignCoords [] g1 om
in c1 `shouldBe` c2
it "rank が 1 つ (= source 群のみ) は等間隔 [0..1]" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["a","b","c"] []
(g1, om) = Sugi.assignOrder g0
coords = Sugi.assignCoords [] g1 om
xs = sort [ coords Map.! k | k <- ["a","b","c"] ]
in (head xs, last xs) `shouldBe` (0, 1)
it "computeOneDir 単独: top-down は source 側 anchor、 bottom-up は sink 側 anchor (= 2 候補で値が違う)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["a","b","c","d","e"]
[("a","c"),("b","c"),("c","d"),("c","e")]
(g1, om) = Sugi.assignOrder g0
xTD = Sugi.computeOneDir True g1 om
xBU = Sugi.computeOneDir False g1 om
in xTD `shouldNotBe` xBU
describe "Phase 1 A5: edge routing (= dummy 経由 + Catmull-Rom spline)" $ do
it "insertDummiesWithChains: 長 edge (rank 差 3) chain は 4 要素 [from, d1, d2, to]" $
let g0 = Sugi.buildLayoutGraph ["a","b"] [("a","b")]
g1 = g0 { Sugi.lgNodes = [ Sugi.LNode "a" 0 False
, Sugi.LNode "b" 3 False ] }
(_, chainMap) = Sugi.insertDummiesWithChains g1
chain = chainMap Map.! ("a", "b")
in length chain `shouldBe` 4
it "insertDummiesWithChains: 短 edge は chain 2 要素 [from, to]" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["a","b"] [("a","b")]
(_, chainMap) = Sugi.insertDummiesWithChains g0
in chainMap Map.! ("a", "b") `shouldBe` ["a", "b"]
it "assignOrderFull: chainMap が assignOrder 結果と整合 (= 全 edge に対応 chain あり)" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph ["a","b","c","d"]
[("a","b"),("a","d"),("c","d")]
(_, _, chainMap) = Sugi.assignOrderFull g0
keys = Map.keys chainMap
in do
("a","b") `elem` keys `shouldBe` True
("a","d") `elem` keys `shouldBe` True
("c","d") `elem` keys `shouldBe` True
it "DAG.dagPlot 長 edge を含む graph で routedEdges の dePath が Just (= spline 描画)" $
let g = ("a" :: Data.Text.Text) ~> "d" -- 直接 long edge (rank 0 → 3 予定)
<> "a" ~> "b" <> "b" ~> "c" <> "c" ~> "d" -- 経路 chain
spec = layer (Graphics.Hgg.DAG.dagPlot g)
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
-- spline edge は PPath (= curve)、 矢印ヘッドも PPath。 多めに含まれるはず。
paths = length [() | PPath{} <- ps]
in paths `shouldSatisfy` (>= 8) -- 4 node 楕円 + 4 短 edge 矢印 + 1 long edge spline + 1 long edge 矢印 = 10 程度
it "DAG.dagPlot 短 edge のみ graph では dePath が全て Nothing (= 直線描画)" $
let g = ("a" :: Data.Text.Text) ~> "b" <> "b" ~> "c"
spec = layer (Graphics.Hgg.DAG.dagPlot g)
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
-- 短 edge では PLine (= 直線) が edge ごとに 1 本
plines = length [() | PLine{} <- ps]
in plines `shouldSatisfy` (>= 2)
it "DAGEdge backward compat: dagEdge は dePath = Nothing default" $
let e = dagEdge "x" "y"
in dePath e `shouldBe` Nothing
describe "Phase 1 A6: plate-aware ordering" $ do
it "applyPlateConstraints: 2 plate ([a1,a2] と [b1,b2]) で同 rank 内 contiguous" $
let -- 初期 order が [a1, b1, a2, b2] (= 交互) であっても plate 制約後は a1,a2 隣接 / b1,b2 隣接
om0 = Map.fromList [(0, ["a1", "b1", "a2", "b2"])]
plates = [["a1", "a2"], ["b1", "b2"]]
om1 = Sugi.applyPlateConstraints plates om0
row = om1 Map.! 0
-- 同 plate の index 差が 1 (= 隣接) であること
ixOf v = head [ i | (i, x) <- zip [0 :: Int ..] row, x == v ]
in do
abs (ixOf "a1" - ixOf "a2") `shouldBe` 1
abs (ixOf "b1" - ixOf "b2") `shouldBe` 1
it "applyPlateConstraints 空 plates: 入力 OrderMap と同一" $
let om0 = Map.fromList [(0, ["a", "b", "c"])]
in Sugi.applyPlateConstraints [] om0 `shouldBe` om0
it "applyPlateConstraints: 非 plate node は元順序を保つ" $
let om0 = Map.fromList [(0, ["x", "a1", "y", "a2", "z"])]
plates = [["a1", "a2"]]
om1 = Sugi.applyPlateConstraints plates om0
row = om1 Map.! 0
-- x, y, z の元順序が破壊されていない (= median 安定 sort)
posMap = Map.fromList (zip row [0 :: Int ..])
in do
(posMap Map.! "x") < (posMap Map.! "y") `shouldBe` True
(posMap Map.! "y") < (posMap Map.! "z") `shouldBe` True
it "dagPlotWithPlates: plate 渡しても layout 走る (= PRect plate box が出る)" $
let g = ("a1" :: Data.Text.Text) ~> "y"
<> "a2" ~> "y" <> "b1" ~> "y" <> "b2" ~> "y"
plates = [ DAGPlate "plate-a" ["a1", "a2"]
, DAGPlate "plate-b" ["b1", "b2"]
]
spec = layer (Graphics.Hgg.DAG.dagPlotWithPlates g plates)
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
-- plate 2 個分の bounding box (= PRect) + plate label
rects = length [() | PRect{} <- ps]
in rects `shouldSatisfy` (>= 2)
it "Phase 1 A7 (port snap): latent (ellipse) 水平方向 port は cx ± rx に snap" $
let n = Graphics.Hgg.Easy.dagNode "v" "v" NodeLatent 0 0
-- baseR = 20、 dist 無し → rx = ry = 20
p = edgePortPoint n (Point 100 100) (Point 200 100) 20
in case p of
Point px py -> do
abs (px - 120) `shouldSatisfy` (< 1e-9)
abs (py - 100) `shouldSatisfy` (< 1e-9)
it "Phase 1 A7 (port snap): data (rect) 水平方向 port は cx + rx に snap" $
let n = Graphics.Hgg.Easy.dagNode "v" "v" NodeData 0 0
p = edgePortPoint n (Point 0 0) (Point 100 0) 20
in case p of
Point px py -> do
abs (px - 20) `shouldSatisfy` (< 1e-9)
abs py `shouldSatisfy` (< 1e-9)
it "Phase 1 A8 決定論性: 全 pipeline (= layoutHierarchicalFullWithPlates) を 2 回実行で完全一致" $
let nodes = [ Graphics.Hgg.Easy.dagNode i i NodeLatent 0 0
| i <- ["a","b","c","d","e","f"] ]
edges_ = [ dagEdge "a" "c", dagEdge "b" "c", dagEdge "c" "d"
, dagEdge "c" "e", dagEdge "d" "f", dagEdge "e" "f"
, dagEdge "a" "f" -- long edge → dummy 入る
]
plates = [ DAGPlate "P" ["c", "d"] ]
run = Graphics.Hgg.DAG.layoutHierarchicalFullWithPlates nodes edges_ plates
r1 = run
r2 = run
in r1 `shouldBe` r2
it "graphviz parity bench: small case (N=10, 13 edges) で crossings = 0 (= 内部基準値)" $
let nodeIds = ["a","b","c","d","e","f","g","h","i","j"]
es = [ ("a","c"),("b","c"),("c","d"),("c","e")
, ("d","f"),("e","f"),("d","g"),("e","h")
, ("f","i"),("g","j"),("h","j"),("i","j"),("a","j") ]
g0 = Sugi.assignRanks (Sugi.buildLayoutGraph nodeIds es)
(g1, om, _) = Sugi.assignOrderFull g0
in Sugi.countCrossings g1 om `shouldBe` 0
it "Phase 1 並列 edge: 同 (from, to) を 3 本書くと PPath spline が 3 本 描画される" $
let g = ("a" :: Data.Text.Text) ~> "b" <> "a" ~> "b" <> "a" ~> "b"
spec = layer (Graphics.Hgg.DAG.dagPlot g)
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
-- 並列 3 本それぞれ spline edge (PPath) + 矢印 (PPath) = 6 PPath 増加 (+ node 2 個)
paths = length [() | PPath{} <- ps]
in paths `shouldSatisfy` (>= 8) -- 2 node 楕円 + 3 spline + 3 矢印 = 8
it "Phase 1 並列 edge: 1 本のみ (= parCount=1) なら従来の PLine 直線 (= spline 化しない)" $
let g = ("a" :: Data.Text.Text) ~> "b"
spec = layer (Graphics.Hgg.DAG.dagPlot g)
ps = renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec
plines = length [() | PLine{} <- ps]
in plines `shouldSatisfy` (>= 1)
it "Phase 1 A8 決定論性: assignRanks + assignOrder + applyPlateConstraints + assignCoords 全体" $
let g0 = Sugi.assignRanks $ Sugi.buildLayoutGraph
["x","y","z","w","u"]
[("x","y"),("y","z"),("x","w"),("w","z"),("z","u")]
(g1, o, _) = Sugi.assignOrderFull g0
op = Sugi.applyPlateConstraints [["w","z"]] o
c1 = Sugi.assignCoords [] g1 op
c2 = Sugi.assignCoords [] g1 op
in c1 `shouldBe` c2
it "Phase 1 A7 (port snap): rect 対角 45° は短辺の方向で先に交点 (= min(rx/|ux|, ry/|uy|))" $
let n = Graphics.Hgg.Easy.dagNode "v" "v" NodeData 0 0
p = edgePortPoint n (Point 0 0) (Point 100 100) 20
in case p of
-- ★A15: nodeExtent で可変サイズ。 NodeData "v" (1 行・dist 無し) は
-- rx = max 20 (1*6.6/2+8) = 20、 ry = max (20*0.7) (1*14/2+4) = 14。
-- ux = uy = √2/2 ゆえ短辺 ry=14 が先 → t = 14/(√2/2)、 port = (14, 14)。
Point px py -> do
abs (px - 14) `shouldSatisfy` (< 1e-9)
abs (py - 14) `shouldSatisfy` (< 1e-9)
it "dagPlotWithPlates: plate メンバが contiguous (= 同 plate の x が近い)" $
let g = ("a1" :: Data.Text.Text) ~> "z"
<> "a2" ~> "z" <> "b1" ~> "z" <> "b2" ~> "z"
plates = [ DAGPlate "A" ["a1", "a2"]
, DAGPlate "B" ["b1", "b2"]
]
spec = layer (Graphics.Hgg.DAG.dagPlotWithPlates g plates)
dagSpec = case getLast (lyDAG (head (vsLayers spec))) of
Just ds -> ds
Nothing -> error "no dag"
ns = dsNodes dagSpec
xOf nid = case [dnX n | n <- ns, dnId n == nid] of
(x:_) -> x
_ -> 999
a1 = xOf "a1"; a2 = xOf "a2"; b1 = xOf "b1"; b2 = xOf "b2"
insideA = abs (a1 - a2)
insideB = abs (b1 - b2)
between = min (abs (a1 - b1)) (abs (a2 - b2))
in do
insideA `shouldSatisfy` (< between)
insideB `shouldSatisfy` (< between)
-- =======================================================================
-- Phase 53 A3: rank=same (assignRanksGrouped + P3e flat-edge ordering)
-- =======================================================================
it "Phase 53 A3-2: assignRanksGrouped group 無し = 旧 pipeline (breakCycles→assignRanks→tighten) とビット一致" $
let ids = ["s","a","b","t","c"]
es = [("s","a"),("a","b"),("b","a"),("b","t"),("c","c"),("s","c")]
plateIds = [["c","t"]]
old = Sugi.tightenSourceRanks plateIds $ Sugi.assignRanks $
Sugi.buildLayoutGraph ids (Sugi.breakCycles ids es)
new = Sugi.assignRanksGrouped [] plateIds ids es
in new `shouldBe` old
it "Phase 53 A3-2: rank group で member が同 rank + group 内 edge が flat 化 (原方向保持)" $
let lg = Sugi.assignRanksGrouped [["b","c"]] []
["a","b","c","d"]
[("a","b"),("a","c"),("b","c"),("b","d"),("c","d")]
rk i = head [Sugi.lnRank n | n <- Sugi.lgNodes lg, Sugi.lnId n == i]
flats = [ (Sugi.leFrom e, Sugi.leTo e)
| e <- Sugi.lgEdges lg
, rk (Sugi.leFrom e) == rk (Sugi.leTo e) ]
in do
rk "b" `shouldBe` rk "c"
rk "a" `shouldSatisfy` (< rk "b")
rk "d" `shouldSatisfy` (> rk "b")
flats `shouldBe` [("b","c")]
it "Phase 53 A3-3: flatReorder で flat edge が左→右 (from が to より左) に並ぶ" $
let lg = Sugi.assignRanksGrouped [["b","c"]] []
["a","b","c","d"]
[("a","b"),("a","c"),("c","b"),("b","d"),("c","d")] -- flat: c→b
(_, om) = Sugi.assignOrder lg
rk i = head [Sugi.lnRank n | n <- Sugi.lgNodes lg, Sugi.lnId n == i]
orderAt = Map.findWithDefault [] (rk "b") om
ixOf v = length (takeWhile (/= v) orderAt)
in ixOf "c" `shouldSatisfy` (< ixOf "b") -- 初期 ID 辞書順 [b,c] からの反転を要求
it "Phase 53 A3-3: flat 閉路 (b⇄c) でも落ちず決定論的" $
let lg = Sugi.assignRanksGrouped [["b","c"]] [] ["a","b","c"]
[("a","b"),("a","c"),("b","c"),("c","b")]
(_, om1) = Sugi.assignOrder lg
(_, om2) = Sugi.assignOrder lg
in om1 `shouldBe` om2
it "Phase 53 A3: dagPlotWithRankGroups end-to-end (同 dnY + 非隣接 flat edge の迂回 dePath)" $
let g = ("r" :: Data.Text.Text) ~> "a" <> "r" ~> "m" <> "r" ~> "b"
<> "a" ~> "m" <> "m" ~> "b" <> "a" ~> "b"
spec = layer (Graphics.Hgg.DAG.dagPlotWithRankGroups g [["a","m","b"]])
dagSpec = case getLast (lyDAG (head (vsLayers spec))) of
Just ds -> ds
Nothing -> error "no dag"
ns = dsNodes dagSpec
yOf nid = case [dnY n | n <- ns, dnId n == nid] of
(y:_) -> y
_ -> 999
pathOf f t = case [ dePath e | e <- dsEdges dagSpec
, deFrom e == f, deTo e == t ] of
(p:_) -> p
_ -> Nothing
in do
yOf "a" `shouldBe` yOf "m"
yOf "m" `shouldBe` yOf "b"
-- 隣接 flat edge (a→m / m→b) = 水平直線 (dePath 無し)
pathOf "a" "m" `shouldBe` Nothing
pathOf "m" "b" `shouldBe` Nothing
-- 非隣接 flat edge (a→b、 間に m) = rank 上側 gap の waypoint 1 点
case pathOf "a" "b" of
Just [(_, y0), (_, ym), (_, y1)] -> do
y0 `shouldBe` yOf "a"
y1 `shouldBe` yOf "b"
ym `shouldBe` yOf "a" - 0.5
other -> expectationFailure ("unexpected dePath: " <> show other)
-- =========================================================================
-- Phase 11 A1: validate / compile / diagnostics
-- =========================================================================
describe "Validate (Phase 11 A1)" $ do
let rXY n = case n of
"x" -> Just (NumData (V.fromList [1, 2, 3]))
"y" -> Just (NumData (V.fromList [4, 5, 6]))
"grp" -> Just (TxtData (V.fromList ["a", "b", "a"]))
_ -> Nothing
it "完全な scatter は診断ゼロ" $
validatePlot rXY (layer (scatter "x" "y")) `shouldBe` []
it "必須 aesthetic 欠落を検出 (histogram は x 必須、 空 layer)" $
let emptyHist = mempty { lyKind = First (Just MHistogram) } :: Layer
diags = validatePlot emptyResolver (purePlot { vsLayers = [emptyHist] })
in any isMissing diags `shouldBe` True
it "解決できない列名で ColumnNotFound" $
let diags = validatePlot rXY (layer (scatter "xxx" "y"))
in any isNotFound diags `shouldBe` True
it "ColumnNotFound に編集距離 suggestion が付く (validatePlotWith)" $
let known = ["x", "y", "grp"]
diags = validatePlotWith known rXY (layer (scatter "yy" "x"))
sugg = [cs | PlotError (ColumnNotFound _ cs) _ <- diags]
in case sugg of
(cs : _) -> cs `shouldSatisfy` (\xs -> "y" `elem` xs)
[] -> expectationFailure "ColumnNotFound が出ていない"
it "errorX に文字列列で ColumnTypeMismatch" $
let diags = validatePlot rXY (layer (forest "y" "grp" "grp"))
-- forest errCol = "grp" (文字列) → errorX 数値要求に不一致
in any isTypeMismatch diags `shouldBe` True
it "空プロットは EmptyPlot error" $
validatePlot emptyResolver purePlot `shouldBe` [PlotError EmptyPlot (DiagnosticContext Nothing Nothing)]
it "compilePlot: error があれば Left" $
case compilePlot emptyResolver purePlot of
Left _ -> True `shouldBe` True
Right _ -> expectationFailure "EmptyPlot を素通しした"
it "compilePlot: 正常 spec は Right" $
case compilePlot rXY (layer (scatter "x" "y")) of
Right c -> length (vsLayers (compiledSpec c)) `shouldBe` 1
Left ds -> expectationFailure ("予期せぬ error: " <> show ds)
it "capability: hover + SVG backend は BackendUnsupported warning" $
let spec = layer (scatter "x" "y" <> hoverCols ["grp"])
warns = checkCapability svgCapability spec
in any isHoverWarn warns `shouldBe` True
it "capability: hover + Canvas backend は warning 無し" $
let spec = layer (scatter "x" "y" <> hoverCols ["grp"])
in filter isHoverWarn (checkCapability canvasCapability spec) `shouldBe` []
-- =========================================================================
-- Phase 11 A2: Monoid 合成規則の conformance (design/monoid-semantics.md と一致)
-- =========================================================================
describe "Monoid 合成規則 (Phase 11 A2)" $ do
it "Layer: lyKind は first wins (scatter<>line は MScatter)" $
let l = scatter "a" "b" <> line "c" "d"
in getFirst (lyKind l) `shouldBe` Just MScatter
it "Layer: lyEncX/Y は last wins (scatter<>line で c/d が残る)" $
let l = scatter "a" "b" <> line "c" "d"
in (getLast (lyEncX l), getLast (lyEncY l))
`shouldBe` (Just (ColByName "c"), Just (ColByName "d"))
it "Layer: lyHover は concat" $
let l = hoverCols ["a"] <> hoverCols ["b", "c"]
in lyHover l `shouldBe` [ColByName "a", ColByName "b", ColByName "c"]
it "Layer: lyAlpha は last wins" $
getLast (lyAlpha (alpha 0.3 <> alpha 0.7)) `shouldBe` Just 0.7
it "Layer: lyColorCats は last-nonempty wins (concat ではない)" $
lyColorCats (colorCats ["a", "b"] <> colorCats ["c"]) `shouldBe` ["c"]
it "Layer: 空 colorCats を後に合成しても前者が残る" $
lyColorCats (colorCats ["a", "b"] <> mempty) `shouldBe` ["a", "b"]
it "VisualSpec: vsLayers は concat (layer<>layer で 2 層)" $
length (vsLayers (layer (scatter "x" "y") <> layer (line "x" "z"))) `shouldBe` 2
it "VisualSpec: vsTitle は last wins" $
getLast (vsTitle (title "a" <> title "b")) `shouldBe` Just "b"
it "VisualSpec: vsRefLines は concat" $
length (vsRefLines (refHorizontal 0 <> refHorizontal 1)) `shouldBe` 2
it "Monoid 則: 左単位元 (mempty <> s == s) for VisualSpec" $
let s = layer (scatter "x" "y") <> title "t"
in (mempty <> s) `shouldBe` s
-- =========================================================================
-- Phase 11 A3: Easy 層 (値直接受け + overlay)
-- =========================================================================
describe "Easy 層 (Phase 11 A3)" $ do
it "points xs ys ≡ scatter (inline xs) (inline ys)" $
points [1, 2, 3] [4, 5, 6] `shouldBe` scatter (inline [1, 2, 3 :: Double]) (inline [4, 5, 6 :: Double])
it "lineXY ≡ line (inline ..) (inline ..)" $
lineXY [1, 2] [3, 4] `shouldBe` line (inline [1, 2 :: Double]) (inline [3, 4 :: Double])
it "hist xs ≡ histogram (inline xs)" $
hist [1, 2, 3] `shouldBe` histogram (inline [1, 2, 3 :: Double])
it "plotY は index を x に取る (= 0,1,2)" $
case getLast (lyEncX (plotY [10, 20, 30])) of
Just (ColNum v) -> V.toList v `shouldBe` [0, 1, 2]
_ -> expectationFailure "encX が ColNum でない"
it "overlay [a,b] は 2 layer の VisualSpec" $
length (vsLayers (overlay [points [1] [2], lineXY [1] [2]])) `shouldBe` 2
it "plots は overlay の別名" $
plots [points [1] [2]] `shouldBe` overlay [points [1] [2]]
-- =========================================================================
-- Phase 11 A4-a: scale reverse (軸反転 = range 入替)
-- =========================================================================
describe "scale reverse (Phase 11 A4-a)" $ do
let mk extra = computeLayout emptyResolver (overlay [points [0, 5, 10] [0, 5, 10]] <> extra)
normal = mk mempty
it "reverseX setter は vsReverseX のみ立てる" $
(getLast (vsReverseX reverseX), getLast (vsReverseY reverseX))
`shouldBe` (Just True, Nothing)
it "通常 X は単調増加 (x=0 が x=10 より小 px)" $
(scaleApply (lpXScale normal) 0 < scaleApply (lpXScale normal) 10) `shouldBe` True
it "reverseX で X が単調減少 (x=0 が x=10 より大 px)" $
let rev = mk reverseX
in (scaleApply (lpXScale rev) 0 > scaleApply (lpXScale rev) 10) `shouldBe` True
it "reverseX は range 入替なので px の和が保存 (rev v + normal v = 一定)" $
let rev = mk reverseX
s0 = scaleApply (lpXScale rev) 0 + scaleApply (lpXScale normal) 0
s10 = scaleApply (lpXScale rev) 10 + scaleApply (lpXScale normal) 10
in abs (s0 - s10) `shouldSatisfy` (< 1e-9)
it "reverseY で Y が単調増加 (通常は減少 = 上が大)" $
let revY' = mk reverseY
in (scaleApply (lpYScale revY') 0 < scaleApply (lpYScale revY') 10) `shouldBe` True
it "reverse 無指定なら scale は従来通り (X 増加・Y 減少)" $
( scaleApply (lpXScale normal) 0 < scaleApply (lpXScale normal) 10
, scaleApply (lpYScale normal) 0 > scaleApply (lpYScale normal) 10 )
`shouldBe` (True, True)
-- =========================================================================
-- Phase 11 A7-a: coord_cartesian(xlim,ylim) = データ非破棄 zoom
-- =========================================================================
describe "coord_cartesian zoom (Phase 11 A7-a)" $ do
-- 11 点 (x=0..10) の scatter。 zoom x∈[2,6] で窓外 8 点は描画 clip だが残る。
let xs11 = [0,1,2,3,4,5,6,7,8,9,10] :: [Double]
spec extra = overlay [points xs11 xs11] <> extra
mk extra = computeLayout emptyResolver (spec extra)
zoom = mk (coordCartesian 2 6 0 40)
a = lpPlotArea zoom
it "coordCartesianX setter は vsCoordXLim のみ立てる" $
( getLast (vsCoordXLim (coordCartesianX 2 6))
, getLast (vsCoordYLim (coordCartesianX 2 6)) )
`shouldBe` (Just (2, 6), Nothing)
it "coordCartesian は X/Y 両 lim を合成する" $
( getLast (vsCoordXLim (coordCartesian 2 6 0 40))
, getLast (vsCoordYLim (coordCartesian 2 6 0 40)) )
`shouldBe` (Just (2, 6), Just (0, 40))
it "zoom 範囲の下端/上端が panel 左/右端に張り付く (domain 上書き)" $
( abs (scaleApply (lpXScale zoom) 2 - rX a) < 1e-6
, abs (scaleApply (lpXScale zoom) 6 - (rX a + rW a)) < 1e-6 )
`shouldBe` (True, True)
it "窓外データ (x=0) は panel 左端より外に投影される (= clip 対象)" $
(scaleApply (lpXScale zoom) 0 < rX a) `shouldBe` True
it "データは落とさない (zoom でも 11 点すべて PCircle が出る)" $
let ps = renderToPrimitives emptyResolver zoom (spec (coordCartesian 2 6 0 40))
in length [() | PCircle{} <- ps] `shouldBe` 11
it "zoom 時は glyph を panel に clip (PClipPush/PClipPop が発行される)" $
let ps = renderToPrimitives emptyResolver zoom (spec (coordCartesian 2 6 0 40))
in ( length [() | PClipPush{} <- ps], length [() | PClipPop <- ps] )
`shouldBe` (1, 1)
it "zoom 無指定なら clip プリミティブは出ない (従来同一)" $
let l = mk mempty
ps = renderToPrimitives emptyResolver l (spec mempty)
in length [() | PClipPush{} <- ps] `shouldBe` 0
-- =========================================================================
-- Phase 11 A7-b: facet free scales (panel ごと独立 domain)
-- =========================================================================
describe "facet free scales (Phase 11 A7-b)" $ do
-- 2 群 A/B で y のスケールが大きく違う (A: 1..2, B: 100..200)。
let facetRes nm = case nm of
"x" -> Just (NumData (V.fromList [1, 2, 1, 2]))
"y" -> Just (NumData (V.fromList [1, 2, 100, 200]))
"g" -> Just (TxtData (V.fromList ["A", "A", "B", "B"]))
_ -> Nothing
baseSpec = layer (scatter "x" "y" <> colorBy "g") <> facet "g"
renderWith extra =
let s = baseSpec <> extra
in renderToPrimitives facetRes (computeLayout facetRes s) s
textCount ps = length [() | PText{} <- ps]
it "facetScales setter は vsFacetScales を立てる" $
getLast (vsFacetScales (facetScales FacetFree)) `shouldBe` Just FacetFree
it "freeScaleX / freeScaleY の真理値表" $
( map freeScaleX [FacetFixed, FacetFreeX, FacetFreeY, FacetFree]
, map freeScaleY [FacetFixed, FacetFreeX, FacetFreeY, FacetFree] )
`shouldBe` ( [False, True, False, True], [False, False, True, True] )
it "free scales は fixed より PText が多い (各 panel に独立 y 軸が出る)" $
(textCount (renderWith (facetScales FacetFree)) > textCount (renderWith mempty))
`shouldBe` True
it "free-y は panel B の大きい値の tick ラベル (150) を含む" $
let ps = renderWith (facetScales FacetFreeY)
texts = [t | PText _ t _ <- ps]
in elem "150" texts `shouldBe` True
-- facet_grid free scales + space (列ごと x / 行ごと y 共有 domain)
let gridRes nm = case nm of
"x" -> Just (NumData (V.fromList [0, 1, 0, 10, 0, 1, 0, 10])) -- col L: 0..1, col R: 0..10
"y" -> Just (NumData (V.fromList [1, 2, 1, 2, 100, 200, 100, 200])) -- row T: 1..2, row B: 100..200
"c" -> Just (TxtData (V.fromList ["L", "L", "R", "R", "L", "L", "R", "R"]))
"r" -> Just (TxtData (V.fromList ["T", "T", "T", "T", "B", "B", "B", "B"]))
_ -> Nothing
gridSpec extra = layer (scatter "x" "y") <> facetGrid "r" "c" <> extra
renderGrid extra =
let s = gridSpec extra
in renderToPrimitives gridRes (computeLayout gridRes s) s
it "facetSpace setter は vsFacetSpace を立てる" $
getLast (vsFacetSpace (facetSpace SpaceFree)) `shouldBe` Just SpaceFree
-- ★ Phase 34: tick ラベルは break ベクトル全体で小数桁統一 (formatTicksGG)。
-- col R (0..10) の break は [0,2.5,5,7.5,10] ゆえ "10.0" (ggplot も "0.0|2.5|..|10.0")。
it "facet_grid free-x は列ごとに x tick が異なる (col R の 10.0 が出る)" $
let texts = [t | PText _ t _ <- renderGrid (facetScales FacetFreeX)]
in elem "10.0" texts `shouldBe` True
it "facet_grid space free-x で列幅が x 範囲に比例 (R 列が L 列より広い)" $
let psFree = renderGrid (facetScales FacetFreeX <> facetSpace SpaceFreeX)
-- 上 strip 背景帯 (col 名) の PRect は h = stripTopH(18)。 幅 = 列幅。
-- col L (x 0..1) < col R (x 0..10) なので R が約 10 倍広い。
stripWidths = [ w | PRect (Rect _ _ w h) _ _ <- psFree, abs (h - 18) < 0.01 ]
in case stripWidths of
(wL : wR : _) -> (wR > wL * 5) `shouldBe` True
_ -> expectationFailure "col strip 幅が 2 つ取れない"
-- =========================================================================
-- Phase 11 A7-c: coord_polar (極座標投影)
-- =========================================================================
describe "coord_polar (Phase 11 A7-c)" $ do
let lay = computeLayout emptyResolver
(overlay [points [0, 1, 2, 3] [0, 1, 2, 3]] <> coordPolar)
(ccx, ccy, cmaxR) = polarCenter lay
it "coordPolar setter は vsCoord = CoordPolarX を立てる" $
getLast (vsCoord coordPolar) `shouldBe` Just (CoordPolarX defaultPolarOpts)
it "coordPolarY setter は vsCoord = CoordPolarY を立てる" $
getLast (vsCoord coordPolarY) `shouldBe` Just (CoordPolarY defaultPolarOpts)
it "isPolar: polar のみ True" $
map isPolar [ CoordCartesian, CoordFlip, CoordPolarX defaultPolarOpts
, CoordPolarY defaultPolarOpts, CoordTernary defaultTernaryOpts ]
`shouldBe` [False, False, True, True, False]
it "polarPoint: r=0 は中心、 θ=0 r=1 は真上 (cx, cy-maxR)" $
let (x0, y0) = polarPoint lay 0 0
(xt, yt) = polarPoint lay 0 1
in ( abs (x0 - ccx) < 1e-9 && abs (y0 - ccy) < 1e-9
, abs (xt - ccx) < 1e-9 && abs (yt - (ccy - cmaxR)) < 1e-9 )
`shouldBe` (True, True)
it "polarPoint: θ=0.25 (= 90°) r=1 は右 (cx+maxR, cy)" $
let (xr, yr) = polarPoint lay 0.25 1
in ( abs (xr - (ccx + cmaxR)) < 1e-6, abs (yr - ccy) < 1e-6 )
`shouldBe` (True, True)
-- ★ Phase 64 A10 (= §3 + §4-1 合流): Coord ADT の JSON codec 後方互換 +
-- CoordTernary + polar start/direction。
it "Coord JSON: 既定 polar / cartesian / flip / ternary は文字列 tag (後方互換)" $
map encode [ CoordCartesian, CoordFlip, CoordPolarX defaultPolarOpts
, CoordPolarY defaultPolarOpts, CoordTernary defaultTernaryOpts ]
`shouldBe` [ "\"cartesian\"", "\"flip\"", "\"polarx\"", "\"polary\"", "\"ternary\"" ]
it "Coord JSON: 旧 spec の \"polarx\"/\"polary\" 文字列は既定 opts で読める" $
( eitherDecode "\"polarx\"", eitherDecode "\"polary\"", eitherDecode "\"ternary\"" )
`shouldBe` ( Right (CoordPolarX defaultPolarOpts)
, Right (CoordPolarY defaultPolarOpts)
, Right (CoordTernary defaultTernaryOpts) )
it "Coord JSON: 非既定 start/direction は object 形で往復する (文字列でなくなる)" $
let c = CoordPolarX (PolarOpts 1.5 (-1))
in ( eitherDecode (encode c), encode c /= "\"polarx\"" )
`shouldBe` ( Right c, True )
it "Coord JSON: object 形で start/direction 欠落は既定で補完" $
( eitherDecode "{\"tag\":\"polarx\"}"
, eitherDecode "{\"tag\":\"polary\",\"start\":0.5}" )
`shouldBe` ( Right (CoordPolarX defaultPolarOpts)
, Right (CoordPolarY (PolarOpts 0.5 1)) )
it "coordPolarWith / coordPolarYWith / coordTernary setter" $
( getLast (vsCoord (coordPolarWith 1 (-1)))
, getLast (vsCoord (coordPolarYWith 0 1))
, getLast (vsCoord coordTernary) )
`shouldBe` ( Just (CoordPolarX (PolarOpts 1 (-1)))
, Just (CoordPolarY defaultPolarOpts)
, Just (CoordTernary defaultTernaryOpts) )
-- ★ Phase 69 A4: 三角座標の向き opts (TernaryOpts / coordTernaryWith)。
it "Phase 69 A4: 既定 opts は \"ternary\" 文字列 (後方互換)・旧文字列を既定で読む" $
( encode (CoordTernary defaultTernaryOpts), eitherDecode "\"ternary\"" )
`shouldBe` ( "\"ternary\"", Right (CoordTernary defaultTernaryOpts) )
it "Phase 69 A4: 非既定 TernaryOpts は object 形で往復 (文字列でなくなる)" $
let c = CoordTernary (TernaryOpts True 120)
in ( eitherDecode (encode c), encode c /= "\"ternary\"" ) `shouldBe` ( Right c, True )
it "Phase 69 A4: object 形の clockwise/rotate 欠落は既定で補完" $
( eitherDecode "{\"tag\":\"ternary\"}"
, eitherDecode "{\"tag\":\"ternary\",\"clockwise\":true}" )
`shouldBe` ( Right (CoordTernary defaultTernaryOpts)
, Right (CoordTernary (TernaryOpts True 0)) )
it "Phase 69 A4: coordTernaryWith setter が opts を立てる" $
getLast (vsCoord (coordTernaryWith True 120))
`shouldBe` Just (CoordTernary (TernaryOpts True 120))
it "Phase 69 A4: clockwise は左下↔右下 頂点を入れ替える (top 不変)" $
let layD = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]] <> coordTernary)
layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]] <> coordTernaryWith True 0)
(aD, bD, cD) = ternaryVertices layD
(aC, bC, cC) = ternaryVertices layC
in (aD == aC, bD == cC, cD == bC) `shouldBe` (True, True, True)
it "Phase 69 A4: rotate 120 は成分→頂点を巡回 (a が旧 b=左下 の位置へ)" $
let layD = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]] <> coordTernary)
layR = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]] <> coordTernaryWith False 120)
(_, bD, _) = ternaryVertices layD
(aR, _, _) = ternaryVertices layR
in aR `shouldBe` bD
it "polarPoint: start=π/2 は θ=0 r=1 を右へ回す (既定の真上から 90° 回転)" $
let layS = computeLayout emptyResolver
(overlay [points [0, 1, 2, 3] [0, 1, 2, 3]] <> coordPolarWith (pi / 2) 1)
(sx0, sy0) = polarPoint layS 0 1
in ( abs (sx0 - (ccx + cmaxR)) < 1e-6, abs (sy0 - ccy) < 1e-6 )
`shouldBe` (True, True)
it "polarPoint: direction=-1 は θ=0.25 を右ではなく左へ (反時計回り)" $
let layR = computeLayout emptyResolver
(overlay [points [0, 1, 2, 3] [0, 1, 2, 3]] <> coordPolarWith 0 (-1))
(rx, ry) = polarPoint layR 0.25 1
in ( abs (rx - (ccx - cmaxR)) < 1e-6, abs (ry - ccy) < 1e-6 )
`shouldBe` (True, True)
-- ★ Phase 64 A11 (= §3-2): ternary 第 3 位置 aesthetic (encZ) + 第 3 scale + 正規化。
it "isTernary: CoordTernary のみ True" $
map isTernary [ CoordCartesian, CoordFlip, CoordPolarX defaultPolarOpts
, CoordPolarY defaultPolarOpts, CoordTernary defaultTernaryOpts ]
`shouldBe` [False, False, False, False, True]
-- ★ Phase 69 A3: mark 束ね (ternaryScatter/ternaryLine) + coord 推論。
it "Phase 69 A3: ternaryScatter = scatter <> encZ (Layer 等価)" $
ternaryScatter (ColByName "a") (ColByName "b") (ColByName "c")
`shouldBe` (scatter (ColByName "a") (ColByName "b") <> encZ (ColByName "c"))
it "Phase 69 A3: coordOf は encZ から CoordTernary を推論 (coord 未指定)" $
coordOf (layer (ternaryScatter (ColByName "a") (ColByName "b") (ColByName "c")))
`shouldBe` CoordTernary defaultTernaryOpts
it "Phase 69 A3: encZ 無しは CoordCartesian (推論しない)" $
coordOf (layer (scatter (ColByName "a") (ColByName "b"))) `shouldBe` CoordCartesian
it "Phase 69 A3: 明示 coord は推論より優先 (encZ ありでも coordFlip)" $
coordOf (layer (ternaryScatter (ColByName "a") (ColByName "b") (ColByName "c")) <> coordFlip)
`shouldBe` CoordFlip
it "Phase 69 A3: layer (ternaryScatter ...) は明示 coordTernary 形と同一 primitive" $
let sa = layer (ternaryScatter (inline [0.2, 0.3, 0.5 :: Double])
(inline [0.3, 0.4, 0.2]) (inline [0.5, 0.3, 0.3]))
sb = layer (scatter (inline [0.2, 0.3, 0.5 :: Double]) (inline [0.3, 0.4, 0.2])
<> encZ (inline [0.5, 0.3, 0.3])) <> coordTernary
in renderToPrimitives emptyResolver (computeLayout emptyResolver sa) sa
`shouldBe` renderToPrimitives emptyResolver (computeLayout emptyResolver sb) sb
it "normalizeTernary: 合計≠1 は a/(a+b+c) に正規化" $
normalizeTernary (1, 1, 2) `shouldBe` Just (0.25, 0.25, 0.5)
it "normalizeTernary: 合計=1 は恒等" $
normalizeTernary (0.2, 0.3, 0.5) `shouldBe` Just (0.2, 0.3, 0.5)
it "normalizeTernary: 退化行 (負値 / 合計≤0) は Nothing (行ごと除外)" $
( normalizeTernary (-1, 2, 3) -- 負値
, normalizeTernary (0, 0, 0) -- 合計 0
, normalizeTernary (2, -1, -1) ) -- 合計 0 かつ負値
`shouldBe` (Nothing, Nothing, Nothing)
it "encZ setter は lyEncZ を立てる / zLabel は vsZLabel を立てる" $
( getLast (lyEncZ (encZ (ColByName "c")))
, getLast (vsZLabel (zLabel "third")) )
`shouldBe` (Just (ColByName "c"), Just "third")
it "第 3 scale: ternary のみ Just [0,1] + tick、 非 ternary は Nothing/[]" $
let layT = computeLayout emptyResolver
(overlay [points [0, 1] [0, 1] <> encZ (ColByName "c")] <> coordTernary)
layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]])
in ( lpZScale layT, null (lpZTicks layT), lpZScale layC, lpZTicks layC )
`shouldBe`
( Just (LinearScale 0 1 0 1), False, Nothing, [] )
it "既存 2 軸の図に無影響: 非 ternary の lpXScale/lpYScale は encZ 追加で不変" $
let base = overlay [points [0, 1, 2] [0, 3, 6]]
withZ = overlay [points [0, 1, 2] [0, 3, 6] <> encZ (ColByName "c")]
lb = computeLayout emptyResolver base
lz = computeLayout emptyResolver withZ
in (lpXScale lb, lpYScale lb) `shouldBe` (lpXScale lz, lpYScale lz)
it "ternary spec (coordTernary + encZ + zLabel) は JSON 往復する" $
let s = layer (scatter (ColByName "a") (ColByName "b") <> encZ (ColByName "c"))
<> coordTernary <> zLabel "C"
in eitherDecode (encode s) `shouldBe` Right s
-- ★ Phase 64 A12 (= §3-3): ternaryPoint 投影の幾何。
it "ternaryPoint: 3 純成分は 3 頂点 (a=上・b=左下・c=右下)" $
let layT = computeLayout emptyResolver
(overlay [points [0, 1] [0, 1]] <> coordTernary)
(tcx, tcy, tr) = ternaryCenter layT
s = sqrt 3 / 2
near (x, y) (x', y') = abs (x - x') < 1e-6 && abs (y - y') < 1e-6
in ( near (ternaryPoint layT (1, 0, 0)) (tcx, tcy - tr) -- 上
, near (ternaryPoint layT (0, 1, 0)) (tcx - tr * s, tcy + tr / 2) -- 左下
, near (ternaryPoint layT (0, 0, 1)) (tcx + tr * s, tcy + tr / 2) ) -- 右下
`shouldBe` (True, True, True)
it "ternaryPoint: 重心 (1/3,1/3,1/3) は三角形の中心" $
let layT = computeLayout emptyResolver
(overlay [points [0, 1] [0, 1]] <> coordTernary)
(tcx, tcy, _) = ternaryCenter layT
(px, py) = ternaryPoint layT (1/3, 1/3, 1/3)
in ( abs (px - tcx) < 1e-6, abs (py - tcy) < 1e-6 ) `shouldBe` (True, True)
it "ternaryVertices: 正三角形 (3 辺長が等しい)" $
let layT = computeLayout emptyResolver
(overlay [points [0, 1] [0, 1]] <> coordTernary)
((ax, ay), (bx, by), (cx, cy)) = ternaryVertices layT
dist (x0, y0) (x1, y1) = sqrt ((x1-x0)^(2::Int) + (y1-y0)^(2::Int))
dAB = dist (ax, ay) (bx, by)
dBC = dist (bx, by) (cx, cy)
dCA = dist (cx, cy) (ax, ay)
in ( abs (dAB - dBC) < 1e-6, abs (dBC - dCA) < 1e-6 ) `shouldBe` (True, True)
it "projectXY CoordTernary: (a,b) は c=1-a-b を補完して ternaryPoint と一致" $
let layT = computeLayout emptyResolver
(overlay [points [0, 1] [0, 1]] <> coordTernary)
in projectXY (CoordTernary defaultTernaryOpts) layT 0.5 0.3
`shouldBe` ternaryPoint layT (0.5, 0.3, 0.2)
-- ★ Phase 64 A13 (= §3-4): geom が encZ を解決し 3 列で ternary 投影する経路。
it "A13 ternary scatter: encZ を解決し正規化 (c≠1-a-b でも真の点)" $
-- 行 (a,b,c)=(1,1,2): 合計 4 → 正規化 (0.25,0.25,0.5)。 旧 2 引数補完 (c=1-1-1=-1)
-- とは別の位置になる = encZ を実際に使っている証拠。
let spec = overlay [ points [1] [1] <> encZ (inline [2 :: Double]) ] <> coordTernary
layT = computeLayout emptyResolver spec
ps = renderToPrimitives emptyResolver layT spec
centers = [ (x, y) | PCircle (Point x y) _ _ _ _ <- ps ]
near (x, y) (x', y') = abs (x - x') < 1e-6 && abs (y - y') < 1e-6
in ( length centers
, all (\p -> near p (ternaryPoint layT (0.25, 0.25, 0.5))) centers )
`shouldBe` (1, True)
it "A13 ternary scatter: 退化行 (負 encZ) は落ちる (点数が減る)" $
-- 中央行 z=-5 は normalizeTernary が Nothing → NaN → 描画対象から除外。
let spec = overlay [ points [1, 1, 1] [1, 1, 1]
<> encZ (inline [1, -5, 1 :: Double]) ] <> coordTernary
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
in length [() | PCircle{} <- ps] `shouldBe` 2
it "A13 ternary line: 退化行は詰められ線分が n'-1 本 (NaN 除外)" $
-- 3 行のうち中央が退化 → 有効 2 点 → PLine 1 本 (line layer 由来)。 grid/frame の
-- PLine と区別するため、 退化を含む場合 (1 本) と含まない場合 (2 本) の差で見る。
let mk zs = let spec = overlay [ line (inline [0, 1, 2 :: Double]) (inline [0, 1, 2])
<> encZ (inline zs) ] <> coordTernary
in length [() | PLine{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver spec) spec ]
in (mk [1, 1, 1 :: Double] - mk [1, -5, 1 :: Double]) `shouldBe` 1
it "A13 ternary warn: 非対応 mark (bar) は TernaryUnsupportedMark 警告" $
let spec = layer (bar (inline [0, 1 :: Double]) (inline [1, 2 :: Double]))
<> coordTernary
ws = [ () | PlotWarning (TernaryUnsupportedMark _) _
<- validatePlot emptyResolver spec ]
in length ws `shouldBe` 1
it "A13 ternary warn: point/line/area/text は警告なし" $
let mkWarns mk = length
[ () | PlotWarning (TernaryUnsupportedMark _) _
<- validatePlot emptyResolver (layer mk <> coordTernary) ]
in map mkWarns
[ scatter (inline [0, 1 :: Double]) (inline [1, 2 :: Double])
, line (inline [0, 1 :: Double]) (inline [1, 2 :: Double])
, band (inline [0, 1 :: Double]) (inline [0, 0 :: Double])
(inline [1, 2 :: Double])
, text (inline [0, 1 :: Double]) (inline [1, 2 :: Double])
(inlineCat ["a", "b" :: Data.Text.Text]) ]
`shouldBe` [0, 0, 0, 0]
it "Phase 69 A3: encZ 追加は coord 推論で ternary 化し PCircle 位置が変わる (旧 A13 no-op 契約を更新)" $
-- Phase 64 A13 は「非 ternary で encZ は no-op (位置不変)」だったが、 Phase 69 A3 で
-- encZ を CoordTernary の推論トリガに契約変更 (encZ は ternary 専用 aesthetic ゆえ実害なし。
-- coordOf に「明示 coord 未指定 + encZ あり → ternary」を追加)。 明示 coord を置けば従来優先。
let base = layer (scatter (inline [0, 1, 2 :: Double]) (inline [0, 1, 4 :: Double]))
withZ = layer (scatter (inline [0, 1, 2 :: Double]) (inline [0, 1, 4 :: Double])
<> encZ (inline [9, 9, 9 :: Double]))
centers s = [ (x, y) | PCircle (Point x y) _ _ _ _
<- renderToPrimitives emptyResolver (computeLayout emptyResolver s) s ]
in (centers base /= centers withZ) `shouldBe` True
-- ★ Phase 64 A2: 投影層への集約口 (projectSegment / projectBar)
it "projectSegment Cartesian: 両端 2 点で projectXY と一致" $
let layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]])
pts = projectSegment CoordCartesian layC (0, 0) (1, 1)
p0 = uncurry Point (projectXY CoordCartesian layC 0 0)
p1 = uncurry Point (projectXY CoordCartesian layC 1 1)
in pts `shouldBe` [p0, p1]
it "projectSegment polar: θ 不変 (純 radial) は 2 点のまま" $
length (projectSegment (CoordPolarX defaultPolarOpts) lay (1, 0) (1, 3)) `shouldBe` 2
it "projectSegment polar: r 一定の 1/4 周は弧にサンプルされ全点が同半径" $
-- x domain 0..3 の x=0→x=1.5 は θfrac 0.5×(expansion 補正)。 サンプル数 ≥ 3 と
-- 「全点が中心から同距離」 (= 弧、 直線なら中点が凹む) を確認する。
let ptsArc = projectSegment (CoordPolarX defaultPolarOpts) lay (0, 3) (1.5, 3)
ds = [ sqrt ((x - ccx) ^ (2 :: Int) + (y - ccy) ^ (2 :: Int))
| Point x y <- ptsArc ]
in ( length ptsArc > 2
, maximum ds - minimum ds < 1e-6 )
`shouldBe` (True, True)
it "projectBar Cartesian: BarRect = projectBarRect と bit 一致" $
let layC = computeLayout emptyResolver
(layer (bars [1, 2, 3] [4, 7, 5]))
in projectBar CoordCartesian layC 1 0 4 0.45 20
`shouldBe` BarRect (projectBarRect CoordCartesian layC 1 0 4 20)
it "projectBar PolarX: BarWedge = wedgeSegments (旧 mkWedge の式) と一致" $
let s = layer (bars [1, 2, 3, 4] [4, 7, 5, 9]) <> coordPolar
layP = computeLayout emptyResolver s
spanX = lsDomainHi (lpXScale layP) - lsDomainLo (lpXScale layP)
hw = 0.45 / spanX
dfx = domFrac (lpXScale layP)
dfy = domFrac (lpYScale layP)
in projectBar (CoordPolarX defaultPolarOpts) layP 1 0 4 0.45 999
`shouldBe` BarWedge (wedgeSegments layP (dfx 1 - hw) (dfx 1 + hw)
(dfy 0) (dfy 4))
it "polar の grid は同心円 (PCircle) を含む (直交 grid line でなく円)" $
let ps = renderToPrimitives emptyResolver lay
(overlay [points [0, 1, 2, 3] [0, 1, 2, 3]] <> coordPolar)
in (length [() | PCircle{} <- ps] > 0) `shouldBe` True
it "polar + bar は扇形 (PPath) を bar の数だけ出す" $
let s = layer (bars [1, 2, 3, 4] [4, 7, 5, 9]) <> coordPolar
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver s) s
in length [() | PPath{} <- ps] `shouldBe` 4
-- =====================================================================
-- ★ Phase 64 A8: polar 外周円 clip (B-2) と θ ラベル位置 (B-3)。
-- ggplot2 coord-polar.R の npc 定数に準拠: データ最大半径 = 0.4・
-- θ ラベル / 外周円 = 0.45 (= polarOuterFrac)。 根拠は Layout.hs polarCenter。
-- =====================================================================
it "polarCenter: データ最大半径 = 0.4 * min(w,h) (ggplot2 npc donut 上限)" $
let ar = lpPlotArea lay
in abs (cmaxR - 0.4 * min (rW ar) (rH ar)) < 1e-9 `shouldBe` True
it "polarOuterFrac = 0.45 / 0.4 (θ ラベル npc 0.45 / データ npc 0.4)" $
abs (polarOuterFrac - 0.45 / 0.4) < 1e-12 `shouldBe` True
it "polarClipPath: 180 頂点で全点が外周円 (= maxR) 上に載る" $
let pts = polarClipPath lay
ds = [ sqrt ((x - ccx) ^ (2 :: Int) + (y - ccy) ^ (2 :: Int))
| (x, y) <- pts ]
in ( length pts, maximum ds - minimum ds < 1e-6
, abs (maximum ds - cmaxR) < 1e-6 )
`shouldBe` (180, True, True)
it "polar は外周円で clip される: PClipPath が 1 本出て 180 頂点 (B-2)" $
let s = layer (points [0, 1, 2, 3] [0, 1, 2, 3]) <> coordPolar
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver s) s
in [ length pts | PClipPath pts <- ps ] `shouldBe` [180]
it "Cartesian は PClipPath を出さない (polar 専用・既存図ゼロ diff)" $
let s = layer (points [0, 1, 2, 3] [0, 1, 2, 3])
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver s) s
in [ () | PClipPath{} <- ps ] `shouldBe` []
it "θ ラベルは外周円上 (= polarOuterFrac の半径) に置かれる (B-3)" $
-- polar-errorbar 相当 (pointRange x=1..6)。 θ ラベルの中心が npc 0.45 の
-- 円周上に載る (= 旧 1.12 決め打ち + panel 内接 maxR による panel はみ出しの解消)。
let s = layer (pointRange (inline [1.0, 2, 3, 4, 5, 6])
(inline [4.0, 5.5, 4.8, 6.2, 5.0, 5.8])
(inline [0.6, 0.5, 0.8, 0.4, 0.7, 0.5]))
<> coordPolar
layE = computeLayout emptyResolver s
(ex, ey, emaxR) = polarCenter layE
ps = renderToPrimitives emptyResolver layE s
-- θ ラベルは AnchorMiddle (r 軸ラベルは AnchorEnd) で区別できる。
-- 数字は θ (x=1..6) と r 軸 tick (4,5,6) で衝突するため anchor で絞る。
-- +4 の y offset を戻して半径を測る。
labelRs = [ sqrt ((x - ex) ^ (2 :: Int) + (y - 4 - ey) ^ (2 :: Int))
| PText (Point x y) t ts <- ps
, t `elem` ["1","2","3","4","5","6"]
, tsAnchor ts == AnchorMiddle ]
in ( length labelRs
, all (\r -> abs (r - emaxR * polarOuterFrac) < 1e-6) labelRs )
`shouldBe` (6, True)
it "θ ラベルが plotArea (panel) の内側に収まる (タイトルと重ならない)" $
let s = layer (pointRange (inline [1.0, 2, 3, 4, 5, 6])
(inline [4.0, 5.5, 4.8, 6.2, 5.0, 5.8])
(inline [0.6, 0.5, 0.8, 0.4, 0.7, 0.5]))
<> coordPolar
layE = computeLayout emptyResolver s
ar = lpPlotArea layE
ps = renderToPrimitives emptyResolver layE s
labelYs = [ y | PText (Point _ y) t ts <- ps
, t `elem` ["1","2","3","4","5","6"]
, tsAnchor ts == AnchorMiddle ]
in all (\y -> y >= rY ar - 1e-6 && y <= rY ar + rH ar + 1e-6) labelYs
`shouldBe` True
-- ★ Phase 64 A18 (= §4-3): θ 軸ラベルの回転を theme の axis.text 角に従わせる。
-- θ を担う軸は coord で変わる (PolarX=x / PolarY=y)。 既定 (角度未指定) は
-- rot 0 のまま = 既存 golden ゼロ diff。 θ ラベルは AnchorMiddle で識別。
-- =====================================================================
let polarThetaRots extra =
let s = layer (pointRange (inline [1.0, 2, 3, 4, 5, 6])
(inline [4.0, 5.5, 4.8, 6.2, 5.0, 5.8])
(inline [0.6, 0.5, 0.8, 0.4, 0.7, 0.5]))
<> extra
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver s) s
in [ tsRotate ts | PText _ t ts <- ps
, t `elem` ["1","2","3","4","5","6"]
, tsAnchor ts == AnchorMiddle ]
it "既定 (角度未指定) の polar θ ラベルは rot 0 (= 既存 golden ゼロ diff)" $
let rots = polarThetaRots coordPolar
in (length rots, all (== 0) rots) `shouldBe` (6, True)
it "themeAxisTextAngleX 45 で PolarX の θ ラベルが 45° 回転する" $
let rots = polarThetaRots (coordPolar <> themeAxisTextAngleX 45)
in (length rots, all (== 45) rots) `shouldBe` (6, True)
it "PolarY では θ = y 軸なので themeAxisTextAngleY が θ ラベルに効く" $
let rots = polarThetaRots (coordPolarY <> themeAxisTextAngleY 30)
in all (== 30) rots `shouldBe` True
it "PolarX の θ ラベルは Y 側角 (themeAxisTextAngleY) では回らない (軸の取り違え防止)" $
let rots = polarThetaRots (coordPolar <> themeAxisTextAngleY 60)
in all (== 0) rots `shouldBe` True
-- ★ Phase 64 A3: categorical-cross geom 用の投影口 (CrossLoc 系)。
-- 直線座標系は「旧 geom 内 px 式と bit 一致」 が契約 (golden 差分ゼロの根拠)。
it "projectCrossPoint Cartesian: Point (sx d + off) (sy v) と bit 一致" $
let layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]])
sx = scaleApply (lpXScale layC)
sy = scaleApply (lpYScale layC)
in projectCrossPoint CoordCartesian layC (CrossAt 1) 7 0.5
`shouldBe` Point (sx 1 + 7) (sy 0.5)
it "projectCrossPoint Flip: Point (syF v) (sxF d + off) と bit 一致" $
let layF = computeLayout emptyResolver
(overlay [points [0, 1] [0, 1]] <> coordFlip)
sxF = scaleApply (lpXScaleFlipped layF)
syF = scaleApply (lpYScaleFlipped layF)
in projectCrossPoint CoordFlip layF (CrossAt 1) 7 0.5
`shouldBe` Point (syF 0.5) (sxF 1 + 7)
it "projectCrossPoint CrossMid Cartesian: cross = plotArea 中央 + off" $
let layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]])
ar = lpPlotArea layC
sy = scaleApply (lpYScale layC)
in projectCrossPoint CoordCartesian layC CrossMid 3 0.5
`shouldBe` Point (rX ar + rW ar / 2 + 3) (sy 0.5)
it "projectCrossPoint PolarX off=0: projectXY と一致" $
projectCrossPoint (CoordPolarX defaultPolarOpts) lay (CrossAt 1) 0 2
`shouldBe` uncurry Point (projectXY (CoordPolarX defaultPolarOpts) lay 1 2)
it "projectCrossPoint PolarX の px offset は接線方向 (半径不変・弧長 ≈ off)" $
let Point x0 y0 = projectCrossPoint (CoordPolarX defaultPolarOpts) lay (CrossAt 1) 0 2
Point x1 y1 = projectCrossPoint (CoordPolarX defaultPolarOpts) lay (CrossAt 1) 5 2
rOf x y = sqrt ((x - ccx) ^ (2 :: Int) + (y - ccy) ^ (2 :: Int))
chord = sqrt ((x1 - x0) ^ (2 :: Int) + (y1 - y0) ^ (2 :: Int))
in ( abs (rOf x1 y1 - rOf x0 y0) < 1e-9 -- 半径が変わらない (= 回転)
, abs (chord - 5) < 0.1 ) -- 弧長 5px ≈ 弦長
`shouldBe` (True, True)
it "projectCrossPoint PolarY の px offset は radial (半径が off だけ増える)" $
let layY = computeLayout emptyResolver
(overlay [points [0, 1, 2, 3] [0, 1, 2, 3]] <> coordPolarY)
(cyx, cyy, _) = polarCenter layY
rOf (Point x y) = sqrt ((x - cyx) ^ (2 :: Int) + (y - cyy) ^ (2 :: Int))
p0 = projectCrossPoint (CoordPolarY defaultPolarOpts) layY (CrossAt 2) 0 1
p1 = projectCrossPoint (CoordPolarY defaultPolarOpts) layY (CrossAt 2) 5 1
in abs (rOf p1 - (rOf p0 + 5)) < 1e-9 `shouldBe` True
it "projectCrossSpan Cartesian: [off-half, off+half] の 2 点で bit 一致" $
let layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]])
in projectCrossSpan CoordCartesian layC (CrossAt 1) 2 8 0.45 0.5
`shouldBe` [ projectCrossPoint CoordCartesian layC (CrossAt 1) (2 - 8) 0.5
, projectCrossPoint CoordCartesian layC (CrossAt 1) (2 + 8) 0.5 ]
it "projectCrossSpan polar: data 半幅の弧 (3 点以上・全点同半径)" $
let ptsA = projectCrossSpan (CoordPolarX defaultPolarOpts) lay (CrossAt 1.5) 0 999 1.0 3
ds = [ sqrt ((x - ccx) ^ (2 :: Int) + (y - ccy) ^ (2 :: Int))
| Point x y <- ptsA ]
in ( length ptsA > 2, maximum ds - minimum ds < 1e-6 )
`shouldBe` (True, True)
it "projectCrossBar Cartesian: 旧 mkRect 式 (cc±halfPx × min/abs) と bit 一致" $
let layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]])
sx = scaleApply (lpXScale layC)
sy = scaleApply (lpYScale layC)
cc = sx 1 + 2
in projectCrossBar CoordCartesian layC (CrossAt 1) 2 8 0.45 0.2 0.7
`shouldBe` BarRect (Rect (cc - 8) (min (sy 0.2) (sy 0.7))
(2 * 8) (abs (sy 0.7 - sy 0.2)))
it "projectCrossBar polar: off=0 は projectBar の wedge と一致" $
projectCrossBar (CoordPolarX defaultPolarOpts) lay (CrossAt 1) 0 999 0.45 0 2
`shouldBe` projectBar (CoordPolarX defaultPolarOpts) lay 1 0 2 0.45 0
-- A1 実測の決定的証拠 (box は coordPolar 有無で geom PRect 完全一致 = Cartesian
-- 落ち) の解消ゲート: polar box の箱は wedge (PPath) で出る。
it "polar + boxplot は箱が wedge (PPath) になる (Phase 64 A3 polar 落ち解消)" $
let sp = layer (boxplot (inline [1.0, 2, 3, 4, 5, 6, 7, 100])) <> coordPolar
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver sp) sp
in (length [() | PPath{} <- ps] >= 1) `shouldBe` True
-- ★ Phase 64 A5: linerange / pointrange の区間。 旧実装は低端の px x を両端に
-- 流用して画面垂直の線分を組んでいたため、 flip では両端が同一点に潰れて
-- (= 長さ 0) 誤差棒が消え、 polar では半径方向にならなかった。 primitive の
-- 「本数」 は旧実装でも変わらないので gallery count 回帰では捕まらない。
-- ここでは幾何 (長さ・向き) を直接押さえる。
it "pointRange flip: 誤差棒が長さ 0 に潰れない (Phase 64 A5)" $
let sp = layer (pointRange (inline [1.0, 2, 3]) (inline [4.0, 5, 6])
(inline [0.5, 0.5, 0.5]))
<> coordFlip
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver sp) sp
degenerate = [ () | PLine (Point x1 y1) (Point x2 y2) _ <- ps
, abs (x1 - x2) < 1e-9 && abs (y1 - y2) < 1e-9 ]
in length degenerate `shouldBe` 0
it "pointRange polar: 誤差棒が中心を向く半径方向の線分になる (Phase 64 A5)" $
let sp = layer (pointRange (inline [1.0, 2, 3, 4]) (inline [4.0, 5, 6, 5])
(inline [0.5, 0.5, 0.5, 0.5]))
<> coordPolar
lay = computeLayout emptyResolver sp
ps = renderToPrimitives emptyResolver lay sp
(cx, cy, _) = polarCenter lay
-- 誤差棒 = grid の spoke (中心が端点) 以外の線分。 その延長線が中心を通る
-- = 中心・両端が同一直線上 (外積 ≒ 0)。
atCenter x y = abs (x - cx) < 1e-6 && abs (y - cy) < 1e-6
bars = [ (x1, y1, x2, y2)
| PLine (Point x1 y1) (Point x2 y2) _ <- ps
, not (atCenter x1 y1), not (atCenter x2 y2) ]
radial (x1, y1, x2, y2) =
abs ((x1 - cx) * (y2 - cy) - (y1 - cy) * (x2 - cx)) < 1e-6
in (length bars, all radial bars) `shouldBe` (4, True)
it "errorY の cap は polar で弧になる (弦のままでない、 Phase 64 A5)" $
let sp k = layer (scatter (inline [1.0, 2, 3]) (inline [4.0, 5, 6])
<> errorY (inline [0.5, 0.5, 0.5])) <> k
nLine k = let s = sp k
in length [ () | PLine{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver s) s ]
-- 直線座標系では cap は 2 点 (1 本) のまま。 polar では x 方向に跨るので
-- 0.1 rad 刻みでサンプルされ本数が増える。
in (nLine coordPolar > nLine mempty) `shouldBe` True
it "valueAxisPx: Cartesian = sy / Flip = syF と bit 一致" $
let layC = computeLayout emptyResolver (overlay [points [0, 1] [0, 1]])
layF = computeLayout emptyResolver
(overlay [points [0, 1] [0, 1]] <> coordFlip)
in ( valueAxisPx CoordCartesian layC 0.3
== scaleApply (lpYScale layC) 0.3
, valueAxisPx CoordFlip layF 0.3
== scaleApply (lpYScaleFlipped layF) 0.3 )
`shouldBe` (True, True)
-- ★ Phase 71 A2: crossbar の投影層経由化。 旧実装は px 空間の PRect 直書きで、
-- flip では pp x (y±e) の第 2 成分が両方 cross 位置になり箱の高さ 0 (実バグ、
-- A1 dump 実測: PRect h=0.0 が 6 本)、 polar では平面矩形のままだった。
-- primitive の本数は変わらないので count 回帰では捕まらない → 幾何を直接押さえる。
it "crossbar flip: 箱が高さ 0 に潰れない (Phase 71 A2)" $
let sp = layer (crossbar (inline [1.0, 2, 3]) (inline [4.0, 5, 6])
(inline [0.5, 0.5, 0.5]))
<> coordFlip
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver sp) sp
-- crossbar の箱 = fill opacity 0.15 の PRect (panel/背景と区別)
boxes = [ (w0, h0) | PRect (Rect _ _ w0 h0) (FillStyle _ o) _ <- ps
, abs (o - 0.15) < 1e-9 ]
in (length boxes, all (\(w0, h0) -> w0 > 0 && h0 > 0) boxes)
`shouldBe` (3, True)
it "crossbar polar: 箱が wedge (PPath) + 中央線が弧になる (Phase 71 A2)" $
let sp = layer (crossbar (inline [1.0, 2, 3, 4]) (inline [4.0, 5, 6, 5])
(inline [0.5, 0.5, 0.5, 0.5]))
<> coordPolar
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver sp) sp
wedges = [ () | PPath _ (FillStyle _ o) _ <- ps, abs (o - 0.15) < 1e-9 ]
in length wedges `shouldBe` 4
it "crossbar cartesian: 旧 px 式 (cc±halfW × min/abs) と bit 一致 (Phase 71 A2)" $
let sp = layer (crossbar (inline [1.0, 2, 3]) (inline [4.0, 5, 6])
(inline [0.5, 0.5, 0.5]))
layC = computeLayout emptyResolver sp
ps = renderToPrimitives emptyResolver layC sp
sx = scaleApply (lpXScale layC)
sy = scaleApply (lpYScale layC)
-- markWidth 既定 0.9 × resolution 1 × catUnitPx (crossbar 幅の既定式)
halfW = 0.5 * 0.9 * catUnitPx CoordCartesian layC
expected x y e = Rect (sx x - halfW) (min (sy (y - e)) (sy (y + e)))
(2 * halfW) (abs (sy (y + e) - sy (y - e)))
boxes = [ r | PRect r (FillStyle _ o) _ <- ps, abs (o - 0.15) < 1e-9 ]
in boxes `shouldBe` [ expected 1 4 0.5, expected 2 5 0.5, expected 3 6 0.5 ]
-- =========================================================================
-- Phase 11 A4-b: linetype aesthetic (固定 + categorical 群分け)
-- =========================================================================
describe "linetype (Phase 11 A4-b)" $ do
it "lineTypeDash: Solid=[] / Dashed=[4,4]" $
(lineTypeDash LtSolid, lineTypeDash LtDashed) `shouldBe` ([], [4, 4])
it "lineTypeForIndex 巡回: 0=Solid, 1=Dashed, 6=Solid" $
(lineTypeForIndex 0, lineTypeForIndex 1, lineTypeForIndex 6)
`shouldBe` (LtSolid, LtDashed, LtSolid)
it "linetype setter は lyLinetype を立てる" $
getLast (lyLinetype (linetype LtDashed)) `shouldBe` Just LtDashed
it "line + linetype LtDashed で線分 (3点=2本) の lsDash が [4,4]" $
let spec = layer (line (inline [0, 1, 2 :: Double]) (inline [0, 1, 2 :: Double])
<> linetype LtDashed)
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
in length [ () | PLine _ _ (LineStyle _ _ d) <- ps, d == [4, 4] ] `shouldBe` 2
it "linetypeBy で群 B (3点=2本) のみ dashed、 群 A は実線" $
let spec = layer (line (inline [0, 1, 2, 0, 1, 2 :: Double])
(inline [0, 1, 2, 3, 4, 5 :: Double])
<> linetypeBy (inlineCat (["A", "A", "A", "B", "B", "B"] :: [Data.Text.Text])))
ps = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
in length [ () | PLine _ _ (LineStyle _ _ d) <- ps, d == [4, 4] ] `shouldBe` 2
-- =========================================================================
-- Phase 11 A4-c: legendTitle (= scale name / labs(color=))
-- =========================================================================
describe "legendTitle (Phase 11 A4-c)" $ do
it "legendTitle setter は vsLegendTitle を立てる" $
getLast (vsLegendTitle (legendTitle "Series")) `shouldBe` Just (Data.Text.pack "Series")
it "未指定なら vsLegendTitle = Nothing (= 従来通り凡例タイトル非表示)" $
getLast (vsLegendTitle (mempty :: VisualSpec)) `shouldBe` Nothing
it "legendTitle 指定で凡例に PText 'Series' が出る (color group + legend)" $
let res k = case k of
"x" -> Just (NumData (V.fromList [0, 1, 2, 3 :: Double]))
"y" -> Just (NumData (V.fromList [0, 1, 2, 3 :: Double]))
"g" -> Just (TxtData (V.fromList ["A", "A", "B", "B"]))
_ -> Nothing
spec = layer (scatter (ColByName "x") (ColByName "y") <> colorBy (ColByName "g"))
<> legend <> legendTitle "Series"
ps = renderToPrimitives res (computeLayout res spec) spec
in any (\p -> case p of PText _ t _ -> t == Data.Text.pack "Series"; _ -> False) ps
`shouldBe` True
-- =========================================================================
-- Phase 11 A4-d: 明示 breaks / labels (= ggplot scale_*_continuous(breaks=,labels=))
-- =========================================================================
describe "explicit breaks/labels (Phase 11 A4-d)" $ do
let res k = case k of
"x" -> Just (NumData (V.fromList [0, 100 :: Double]))
"y" -> Just (NumData (V.fromList [0, 100 :: Double]))
_ -> Nothing
baseSpec extra = layer (scatter (ColByName "x") (ColByName "y")) <> extra
it "axisBreaksAt setter は axTickVals を立てる" $
axTickValsOf (Last (Just (axisBreaksAt [0, 25, 50]))) `shouldBe` [0, 25, 50]
it "axisBreaksLabeled は axTickVals/axTickLabels を対で立てる" $
let as = axisBreaksLabeled [(0, "lo"), (50, "mid"), (100, "hi")]
in ( axTickValsOf (Last (Just as))
, axTickLabelsOf (Last (Just as)) )
`shouldBe` ([0, 50, 100], map Data.Text.pack ["lo", "mid", "hi"])
it "axisBreaksAt で lpXTicks が明示値に上書きされる (範囲内のみ)" $
let spec = baseSpec (xAxis (axisBreaksAt [0, 25, 50, 75, 100]))
l = computeLayout res spec
in lpXTicks l `shouldBe` [0, 25, 50, 75, 100]
it "範囲外の break は censor される" $
-- padded range は概ね [-5,105] なので 200 は落ちる
let spec = baseSpec (xAxis (axisBreaksAt [0, 50, 200]))
l = computeLayout res spec
in lpXTicks l `shouldBe` [0, 50]
it "axisBreaksLabeled で lpXTickLabels が整列して入る" $
let spec = baseSpec (xAxis (axisBreaksLabeled [(0, "lo"), (50, "mid"), (100, "hi")]))
l = computeLayout res spec
in (lpXTicks l, lpXTickLabels l)
`shouldBe` ([0, 50, 100], map Data.Text.pack ["lo", "mid", "hi"])
it "breaks のみ (labels 無し) なら lpXTickLabels は空 (= 値 format に委ねる)" $
let spec = baseSpec (xAxis (axisBreaksAt [0, 50, 100]))
l = computeLayout res spec
in lpXTickLabels l `shouldBe` []
it "未指定なら従来通り (lpXTickLabels 空・auto tick)" $
let l = computeLayout res (baseSpec mempty)
in lpXTickLabels l `shouldBe` []
it "明示ラベルが render の tick PText に出る" $
let spec = baseSpec (xAxis (axisBreaksLabeled [(0, "start"), (100, "end")]))
ps = renderToPrimitives res (computeLayout res spec) spec
hasTxt s = any (\p -> case p of PText _ t _ -> t == Data.Text.pack s; _ -> False) ps
in (hasTxt "start", hasTxt "end") `shouldBe` (True, True)
-- =========================================================================
-- Phase 11 A4-e: 色/サイズ scale 拡充 (manual / gradient2 / size)
-- =========================================================================
describe "color/size scales (Phase 11 A4-e)" $ do
let circFills ps = [ c | PCircle _ _ (FillStyle c _) _ _ <- ps ]
circRadii ps = [ rad | PCircle _ rad _ _ _ <- ps ]
tp s = Data.Text.pack s
it "scaleColorManual setter は vsColorManual を立てる" $
getLast (vsColorManual (scaleColorManual [(tp "A", tp "#ff0000")]))
`shouldBe` Just [(tp "A", tp "#ff0000")]
it "scaleColorGradient2 setter は vsColorGradient2 を立てる" $
getLast (vsColorGradient2 (scaleColorGradient2 (tp "#00f") (tp "#fff") (tp "#f00") 0.0))
`shouldBe` Just (tp "#00f", tp "#fff", tp "#f00", 0.0)
it "scaleSize setter は vsSizeRange を立てる" $
getLast (vsSizeRange (scaleSize 2 12)) `shouldBe` Just (2, 12)
it "scaleColorManual で該当カテゴリが指定色になる (未登録は palette)" $
let res k = case k of
"x" -> Just (NumData (V.fromList [0, 1, 2, 3 :: Double]))
"y" -> Just (NumData (V.fromList [0, 1, 2, 3 :: Double]))
"g" -> Just (TxtData (V.fromList ["A", "A", "B", "B"]))
_ -> Nothing
spec = layer (scatter (ColByName "x") (ColByName "y") <> colorBy (ColByName "g"))
<> scaleColorManual [(tp "A", tp "#123456"), (tp "B", tp "#abcdef")]
fills = circFills (renderToPrimitives res (computeLayout res spec) spec)
-- 先頭 4 = データ点、 末尾 2 = 凡例 swatch。 両方とも manual 色 (= 凡例と panel が一致)。
in fills `shouldBe` map tp ["#123456", "#123456", "#abcdef", "#abcdef", "#123456", "#abcdef"]
it "scaleColorGradient2 で midpoint 値が mid 色になる" $
let res k = case k of
"x" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
"y" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
"z" -> Just (NumData (V.fromList [-1, 0, 1 :: Double])) -- midpoint 0 が中央
_ -> Nothing
spec = layer (scatter (ColByName "x") (ColByName "y") <> colorContinuousBy (ColByName "z"))
<> scaleColorGradient2 (tp "#0000ff") (tp "#ffffff") (tp "#ff0000") 0.0
fills = circFills (renderToPrimitives res (computeLayout res spec) spec)
in (fills !! 1) `shouldBe` tp "#ffffff" -- z=0 (midpoint) → mid 色 (白)
it "scaleSize で sizeBy の直径範囲が指定値になる (★Phase 34 A3: size=直径ゆえ半径=直径/2)" $
let res k = case k of
"x" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
"y" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
"s" -> Just (NumData (V.fromList [10, 20, 30 :: Double]))
_ -> Nothing
spec = layer (scatter (ColByName "x") (ColByName "y") <> sizeBy (ColByName "s"))
<> scaleSize 4 16
radii = circRadii (renderToPrimitives res (computeLayout res spec) spec)
in (minimum radii, maximum radii) `shouldBe` (2, 8) -- 直径範囲 (4,16) → 半径 (2,8)
-- =========================================================================
-- Phase 19: color 凡例整合 (glyph 色と凡例 swatch が同じ正本を参照する)
-- =========================================================================
describe "Phase 19: color 凡例整合" $ do
let circFills ps = [ c | PCircle _ _ (FillStyle c _) _ _ <- ps ]
tp = Data.Text.pack
-- A1 再現: `<>` 重畳の ColorByCol で glyph が layer 内 nub、 凡例が全 layer
-- union を引いてズレる。 layer2 ("C" のみ) の glyph は凡例 "C" swatch と
-- 同色でなければならない (旧バグ: palette 先頭 = 凡例 "A" の色になる)。
it "重畳 ColorByCol レイヤの glyph 色 = 凡例 swatch 色 (A1)" $
let res k = case k of
"x1" -> Just (NumData (V.fromList [0, 1 :: Double]))
"y1" -> Just (NumData (V.fromList [0, 1 :: Double]))
"g1" -> Just (TxtData (V.fromList ["A", "B"]))
"x2" -> Just (NumData (V.fromList [2 :: Double]))
"y2" -> Just (NumData (V.fromList [2 :: Double]))
"g2" -> Just (TxtData (V.fromList ["C"]))
_ -> Nothing
spec = layer (scatter (ColByName "x1") (ColByName "y1") <> colorBy (ColByName "g1"))
<> layer (scatter (ColByName "x2") (ColByName "y2") <> colorBy (ColByName "g2"))
fills = circFills (renderToPrimitives res (computeLayout res spec) spec)
-- 円 6 個 = data (A,B,C) + 凡例 swatch (A,B,C union 順)
in (length fills, fills !! 2 == fills !! 5, fills !! 2 /= fills !! 3)
`shouldBe` (6, True, True)
it "単一 ColorByCol layer は従来配色のまま (glyph = 凡例・回帰)" $
let res k = case k of
"x" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
"y" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
"g" -> Just (TxtData (V.fromList ["A", "B", "A"]))
_ -> Nothing
spec = layer (scatter (ColByName "x") (ColByName "y") <> colorBy (ColByName "g"))
fills = circFills (renderToPrimitives res (computeLayout res spec) spec)
-- data (A,B,A) + 凡例 (A,B): glyph と凡例が対応し、 A 2 点は同色
in (length fills, fills !! 0 == fills !! 3, fills !! 1 == fills !! 4,
fills !! 0 == fills !! 2, fills !! 0 /= fills !! 1)
`shouldBe` (5, True, True, True, True)
-- A2 再現: bar + ColorByCol が PosIdentity で無条件 renderBarSimple (単色)
-- に落ち、 本体単色なのに凡例は palette swatch を並べる。
it "bar PosIdentity + ColorByCol で本体が色分けされ凡例と一致 (A2)" $
let res k = case k of
"x" -> Just (TxtData (V.fromList ["a", "b"]))
"y" -> Just (NumData (V.fromList [1, 2 :: Double]))
"g" -> Just (TxtData (V.fromList ["A", "B"]))
_ -> Nothing
spec = layer (bar (ColByName "x") (ColByName "y") <> colorBy (ColByName "g"))
prims = renderToPrimitives res (computeLayout res spec) spec
-- 背景 PRect (#ffffff) と凡例キー背景 (grey95 #f2f2f2・Phase 34) を除外し
-- bar 本体 + 凡例 swatch のみ拾う
rectFills = [ c | PRect _ (FillStyle c _) _ <- prims
, c /= tp "#ffffff", c /= tp "#f2f2f2" ]
-- PRect = bar 本体 (A,B) + 凡例 swatch (A,B)。 本体 2 色が分かれ、
-- 凡例 swatch と pairwise 一致する
in (length rectFills, rectFills !! 0 == rectFills !! 2,
rectFills !! 1 == rectFills !! 3, rectFills !! 0 /= rectFills !! 1)
`shouldBe` (4, True, True, True)
-- Phase 30 A3: 固定 shape combinator (bare=固定・shapeBy より優先)
it "shape s は固定で全点に適用され shapeBy より優先 (A3)" $
let ly = scatter (ColByName "x") (ColByName "y")
<> shape MShTriangle <> shapeBy (ColByName "g")
in pointShapeAt ly emptyResolver 0 `shouldBe` MShTriangle
it "shape 未指定かつ shapeBy なしは MShCircle (A3)" $
let ly = scatter (ColByName "x") (ColByName "y")
in pointShapeAt ly emptyResolver 0 `shouldBe` MShCircle
-- A2 はみ出し fix: 旧実装は categorical x を row index (0..n-1) に置いて
-- おり、 カテゴリ重複行が x domain を超えて plot 域外に描かれていた。
-- cat index 配置で重複行は同 slot に重ね描き (ggplot identity 同型)。
it "bar categorical x の重複行が plot 域内 (cat index 配置・A2)" $
let res k = case k of
"x" -> Just (TxtData (V.fromList ["a", "b", "a"]))
"y" -> Just (NumData (V.fromList [1, 2, 3 :: Double]))
_ -> Nothing
spec = layer (bar (ColByName "x") (ColByName "y"))
lay = computeLayout res spec
area = lpPlotArea lay
rects = [ rc | PRect rc (FillStyle c _) _
<- renderToPrimitives res lay spec
, c /= tp "#ffffff" ]
in (length rects,
all (\rc -> rX rc + rW rc <= rX area + rW area + 1e-9) rects,
rX (rects !! 0) == rX (rects !! 2)) -- 重複 cat "a" は同 slot
`shouldBe` (3, True, True)
it "bar PosIdentity + ColorStatic は従来単色のまま (回帰)" $
let res k = case k of
"x" -> Just (TxtData (V.fromList ["a", "b"]))
"y" -> Just (NumData (V.fromList [1, 2 :: Double]))
_ -> Nothing
spec = layer (bar (ColByName "x") (ColByName "y")
<> color (fromHex "#336699"))
prims = renderToPrimitives res (computeLayout res spec) spec
rectFills = [ c | PRect _ (FillStyle c _) _ <- prims, c /= tp "#ffffff" ]
in rectFills `shouldBe` [tp "#336699", tp "#336699"]
-- =========================================================================
-- Phase 11 A5-a: labs サブシステム (subtitle / caption / tag + labs まとめ setter)
-- =========================================================================
describe "labs (Phase 11 A5-a)" $ do
let tp = Data.Text.pack
textsOf ps = [ t | PText _ t _ <- ps ]
it "subtitle / caption / tag setter は各 field を立てる" $
( getLast (vsSubtitle (subtitle (tp "sub")))
, getLast (vsCaption (caption (tp "cap")))
, getLast (vsTag (tag (tp "T"))) )
`shouldBe` (Just (tp "sub"), Just (tp "cap"), Just (tp "T"))
it "labs まとめ setter は指定した label だけ合成する" $
let s = labs emptyLabs { labsTitle = Just (tp "ti"), labsSubtitle = Just (tp "su")
, labsCaption = Just (tp "ca"), labsTag = Just (tp "tg")
, labsX = Just (tp "xx"), labsY = Just (tp "yy")
, labsColor = Just (tp "co") }
in ( getLast (vsTitle s), getLast (vsSubtitle s), getLast (vsCaption s)
, getLast (vsTag s), getLast (vsXLabel s), getLast (vsYLabel s)
, getLast (vsLegendTitle s) )
`shouldBe` ( Just (tp "ti"), Just (tp "su"), Just (tp "ca")
, Just (tp "tg"), Just (tp "xx"), Just (tp "yy"), Just (tp "co") )
it "subtitle / caption / tag は描画され PText に出る" $
let res k = case k of
"x" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
"y" -> Just (NumData (V.fromList [0, 1, 2 :: Double]))
_ -> Nothing
spec = layer (scatter (ColByName "x") (ColByName "y"))
<> title (tp "T") <> subtitle (tp "sub") <> caption (tp "cap") <> tag (tp "G")
ts = textsOf (renderToPrimitives res (computeLayout res spec) spec)
in all (`elem` ts) (map tp ["T", "sub", "cap", "G"]) `shouldBe` True
-- =========================================================================
-- Phase 11 A5-c: guides (reverse / ncol / nrow + guideColorNone)
-- =========================================================================
describe "guides (Phase 11 A5-c)" $ do
let tp = Data.Text.pack
gres k = case k of
"x" -> Just (NumData (V.fromList [0, 1, 2, 3 :: Double]))
"y" -> Just (NumData (V.fromList [0, 1, 2, 3 :: Double]))
"g" -> Just (TxtData (V.fromList ["A", "A", "B", "B"]))
_ -> Nothing
legendTexts spec =
[ (t, py) | PText (Point _ py) t _ <- renderToPrimitives gres (computeLayout gres spec) spec
, t `elem` map tp ["A", "B"] ]
baseSpec = layer (scatter (ColByName "x") (ColByName "y") <> colorBy (ColByName "g"))
it "legendReverse / legendNcol / legendNrow setter が各 field を立てる" $
( getLast (vsLegendReverse legendReverse)
, getLast (vsLegendNcol (legendNcol 2))
, getLast (vsLegendNrow (legendNrow 3)) )
`shouldBe` (Just True, Just 2, Just 3)
it "guideColorNone は色凡例を消す (= 凡例テキスト無し)" $
let spec = baseSpec <> legend <> guideColorNone
in legendTexts spec `shouldBe` []
it "legendReverse でキー順が逆になる (A が下、 B が上)" $
let spec = baseSpec <> legend <> legendReverse
ys = [ py | (lbl, py) <- legendTexts spec, lbl == tp "A" || lbl == tp "B" ]
yA = head [ py | (lbl, py) <- legendTexts spec, lbl == tp "A" ]
yB = head [ py | (lbl, py) <- legendTexts spec, lbl == tp "B" ]
in (yB < yA, length ys) `shouldBe` (True, 2)
it "legendReverse 無しは従来順 (A が上、 B が下)" $
let spec = baseSpec <> legend
yA = head [ py | (lbl, py) <- legendTexts spec, lbl == tp "A" ]
yB = head [ py | (lbl, py) <- legendTexts spec, lbl == tp "B" ]
in (yA < yB) `shouldBe` True
-- =========================================================================
-- Phase 11 A6: geom_text / geom_label (データ駆動ラベル)
-- =========================================================================
describe "text / label (Phase 11 A6)" $ do
let tp = Data.Text.pack
gres k = case k of
"x" -> Just (NumData (V.fromList [1, 2, 3 :: Double]))
"y" -> Just (NumData (V.fromList [1, 2, 3 :: Double]))
"l" -> Just (TxtData (V.fromList ["a", "b", "c"]))
_ -> Nothing
textsOf ps = [ t | PText _ t _ <- ps ]
rectsOf ps = [ r | r@PRect{} <- ps ]
it "text は MText + lyLabel を立てる" $
let ly = text (ColByName "x") (ColByName "y") (ColByName "l")
in (getFirst (lyKind ly), getLast (lyLabel ly))
`shouldBe` (Just MText, Just (ColByName "l"))
it "text で各点に label 列の文字が出る" $
let spec = layer (text (ColByName "x") (ColByName "y") (ColByName "l"))
ts = textsOf (renderToPrimitives gres (computeLayout gres spec) spec)
in all (`elem` ts) (map tp ["a", "b", "c"]) `shouldBe` True
it "label は文字 + 背景矩形 (各点) を出す" $
let spec = layer (label (ColByName "x") (ColByName "y") (ColByName "l"))
prims = renderToPrimitives gres (computeLayout gres spec) spec
ts = textsOf prims
-- 背景矩形 (label box) = panel 背景/枠 を除いた幅の狭い矩形が 3 個
boxes = [ () | PRect (Rect _ _ w _) _ _ <- prims, w < 100 ]
in (all (`elem` ts) (map tp ["a", "b", "c"]), length boxes) `shouldBe` (True, 3)
-- =========================================================================
-- Phase 11 A6-2: Q-Q plot (geom_qq)
-- =========================================================================
describe "qq (Phase 11 A6-2)" $ do
let sres k = case k of
"s" -> Just (NumData (V.fromList [3.0, 1.0, 4.0, 1.5, 5.0, 9.0, 2.0]))
_ -> Nothing
circlesOf ps = [ (cx, cy) | PCircle (Point cx cy) _ _ _ _ <- ps ]
it "qq は MQQ + encY を立てる (encX は持たない)" $
let ly = qq (ColByName "s")
in (getFirst (lyKind ly), getLast (lyEncY ly), getLast (lyEncX ly))
`shouldBe` (Just MQQ, Just (ColByName "s"), Nothing)
it "invNormCdf は対称で中央が 0 (Φ⁻¹(0.5)=0, Φ⁻¹(0.975)≈1.96)" $
let mid = abs (invNormCdf 0.5) < 1e-9
sym = abs (invNormCdf 0.975 + invNormCdf 0.025) < 1e-6
z975 = abs (invNormCdf 0.975 - 1.959964) < 1e-4
in (mid, sym, z975) `shouldBe` (True, True, True)
it "qqPoints は y を昇順 (order statistic) に並べ x も単調増加" $
let pts = qqPoints [3.0, 1.0, 4.0, 1.5, 5.0]
ys = map snd pts
xs = map fst pts
asc zs = and (zipWith (<=) zs (drop 1 zs))
in (ys, asc ys, asc xs) `shouldBe` ([1.0, 1.5, 3.0, 4.0, 5.0], True, True)
it "qq で sample 点数ぶんの円が出る (= 7 個)" $
let spec = layer (qq (ColByName "s"))
ps = renderToPrimitives sres (computeLayout sres spec) spec
in length (circlesOf ps) `shouldBe` 7
-- =========================================================================
-- Phase 11 A6-3: heatmap (geom_tile)
-- =========================================================================
describe "heatmap (Phase 11 A6-3)" $ do
-- 2×2 grid (long-form): (A,P)=1 (A,Q)=2 (B,P)=3 (B,Q)=4
let hres k = case k of
"hx" -> Just (TxtData (V.fromList ["A", "A", "B", "B"]))
"hy" -> Just (TxtData (V.fromList ["P", "Q", "P", "Q"]))
"hv" -> Just (NumData (V.fromList [1.0, 2.0, 3.0, 4.0]))
_ -> Nothing
-- セル矩形 = 連続色塗りの矩形 (白の panel/canvas 背景・h=3.5 の凡例 strip を除外)
cellRects ps = [ () | PRect (Rect _ _ w h) (FillStyle f _) _ <- ps
, w > 50, h > 50, f /= "#ffffff" ]
it "heatmap は MHeatmap + encX/encY + ColorByContinuous を立てる" $
let ly = heatmap (ColByName "hx") (ColByName "hy") (ColByName "hv")
isContinuous = case getLast (lyColor ly) of
Just (ColorByContinuous (ColByName "hv")) -> True
_ -> False
in ( getFirst (lyKind ly)
, getLast (lyEncX ly), getLast (lyEncY ly), isContinuous )
`shouldBe` ( Just MHeatmap, Just (ColByName "hx")
, Just (ColByName "hy"), True )
it "heatmap で grid セル数ぶんの矩形が出る (= 4 個)" $
let spec = layer (heatmap (ColByName "hx") (ColByName "hy") (ColByName "hv"))
ps = renderToPrimitives hres (computeLayout hres spec) spec
in length (cellRects ps) `shouldBe` 4
-- =========================================================================
-- contour (= 等高線図、 marching squares)
-- =========================================================================
describe "contour (等高線、 marching squares)" $ do
-- 連続 x/y/z (5×5 grid = 25 点)、 z = x+y。 等値線を描く。
let grid = [ (x, y) | x <- [0.0, 1.0, 2.0, 3.0, 4.0], y <- [0.0, 1.0, 2.0, 3.0, 4.0] ]
cres k = case k of
"cx" -> Just (NumData (V.fromList (map fst grid)))
"cy" -> Just (NumData (V.fromList (map snd grid)))
"cz" -> Just (NumData (V.fromList (map (\(x,y) -> x + y) grid)))
_ -> Nothing
-- 旧 binned heatmap のセル矩形 (白 0.3px 枠)。 等高線化で出なくなったことを確認。
cellRectsC ps = [ () | PRect _ (FillStyle f _) (Just (StrokeStyle sc sw)) <- ps
, f /= "#ffffff", sc == "#ffffff", sw == 0.3 ]
it "contour は MContour + encX/encY + ColorByContinuous を立てる" $
let ly = contour (ColByName "cx") (ColByName "cy") (ColByName "cz")
isCont = case getLast (lyColor ly) of
Just (ColorByContinuous (ColByName "cz")) -> True
_ -> False
in (getFirst (lyKind ly), getLast (lyEncX ly), getLast (lyEncY ly), isCont)
`shouldBe` (Just MContour, Just (ColByName "cx"), Just (ColByName "cy"), True)
it "contour は等値線 (PLine) を描き、 binned heatmap の塗り矩形は出さない" $
let spec = layer (contour (ColByName "cx") (ColByName "cy") (ColByName "cz"))
ps = renderToPrimitives cres (computeLayout cres spec) spec
nLines = length [ () | PLine{} <- ps ]
-- 等高線は多数の線分、 旧 binned heatmap の塗り矩形は 0。
in (cellRectsC ps == [], nLines > 30) `shouldBe` (True, True)
-- =========================================================================
-- Phase 11 A6-4: ECDF (stat_ecdf)
-- =========================================================================
describe "ecdf (Phase 11 A6-4)" $ do
let eres k = case k of
"es" -> Just (NumData (V.fromList [3.0, 1.0, 4.0, 1.0, 5.0]))
_ -> Nothing
linesOf ps = [ () | PLine{} <- ps ]
it "ecdf は MEcdf + encX を立てる (encY は持たない)" $
let ly = ecdf (ColByName "es")
in (getFirst (lyKind ly), getLast (lyEncX ly), getLast (lyEncY ly))
`shouldBe` (Just MEcdf, Just (ColByName "es"), Nothing)
it "ecdfPoints は右連続の階段頂点を返す (n=4 → (x1,0) から始まり 2n 頂点)" $
let pts = ecdfPoints [3.0, 1.0, 4.0, 2.0]
ys = map snd pts
in (length pts, head pts, last ys) `shouldBe` (8, (1.0, 0.0), 1.0)
it "ecdf の階段は 2n-1 本の線分 (n=5 → 9 本、 grid は別ストローク)" $
let spec = layer (ecdf (ColByName "es"))
ps = renderToPrimitives eres (computeLayout eres spec) spec
-- ecdf 線は default 色 (grid は pal.axis)。 default 色の線分のみ数える。
ecLines = [ () | PLine _ _ (LineStyle col _ _) <- ps, col == "#1f77b4" ]
in length ecLines `shouldBe` 9
-- =========================================================================
-- Phase 11 A6-4b: 区間 geom (linerange / pointrange / crossbar)
-- =========================================================================
describe "linerange / pointrange / crossbar (Phase 11 A6-4b)" $ do
let rres k = case k of
"rx" -> Just (NumData (V.fromList [1.0, 2.0, 3.0]))
"ry" -> Just (NumData (V.fromList [3.0, 4.0, 5.0]))
"re" -> Just (NumData (V.fromList [0.5, 0.6, 0.4]))
_ -> Nothing
render s = renderToPrimitives rres (computeLayout rres s) s
circlesN ps = length [() | PCircle{} <- ps]
rangeLines ps = length [() | PLine _ _ (LineStyle col _ _) <- ps, col == "#1f77b4"]
-- Phase 41: crossbar 箱幅はデータ単位 (≈0.9×catUnitPx) になり px 固定 20px から
-- 広がった (x=[1,2,3] で ≈139px)。 上限を 60→300 に緩め panel 等の全幅矩形だけ除外。
cellRectsR ps = length [() | PRect (Rect _ _ w _) (FillStyle f _) _ <- ps
, w < 300, w > 2, f == "#1f77b4"]
it "lineRange は MLineRange + x/y/errorY を立てる" $
let ly = lineRange (ColByName "rx") (ColByName "ry") (ColByName "re")
in (getFirst (lyKind ly), getLast (lyEncX ly), getLast (lyEncY ly), getLast (lyErrorY ly))
`shouldBe` (Just MLineRange, Just (ColByName "rx"), Just (ColByName "ry"), Just (ColByName "re"))
it "linerange は 3 本の縦線・点無し" $
let ps = render (layer (lineRange (ColByName "rx") (ColByName "ry") (ColByName "re")))
in (rangeLines ps, circlesN ps) `shouldBe` (3, 0)
it "pointrange は 3 本の縦線 + 3 中心点" $
let ps = render (layer (pointRange (ColByName "rx") (ColByName "ry") (ColByName "re")))
in (rangeLines ps, circlesN ps) `shouldBe` (3, 3)
it "crossbar は 3 箱 + 3 中央水平線" $
let ps = render (layer (crossbar (ColByName "rx") (ColByName "ry") (ColByName "re")))
in (cellRectsR ps, rangeLines ps) `shouldBe` (3, 3)
-- Phase 41: resolutionOf (ggplot resolution(x) = 最小正間隔)。 cap データ単位化の基準。
describe "resolutionOf (Phase 41)" $ do
it "等間隔グリッドは間隔を返す" $
resolutionOf [0, 2, 4, 6] `shouldBe` 2.0
it "categorical 整数位置は 1" $
resolutionOf [0, 1, 2, 3] `shouldBe` 1.0
it "単一値は 1 (間隔なし)" $
resolutionOf [5, 5, 5] `shouldBe` 1.0
it "不揃いは最小正間隔" $
resolutionOf [0, 1, 3, 3.5] `shouldBe` 0.5
it "空は 1" $
resolutionOf [] `shouldBe` 1.0
-- =========================================================================
-- Phase 11 A6-4c: stat_function (関数サンプリング → inline line)
-- =========================================================================
describe "statFunction (Phase 11 A6-4c)" $ do
it "statFunction は f を n 点サンプルした inline line (MLine + ColNum) を作る" $
let ly = statFunction (\x -> x * 2) 0.0 10.0 6
in case (getFirst (lyKind ly), getLast (lyEncX ly), getLast (lyEncY ly)) of
(Just MLine, Just (ColNum xs), Just (ColNum ys)) ->
(V.toList xs, V.toList ys)
`shouldBe` ([0.0, 2.0, 4.0, 6.0, 8.0, 10.0], [0.0, 4.0, 8.0, 12.0, 16.0, 20.0])
other -> expectationFailure ("unexpected: " <> show other)
it "statFunction の n<2 は 2 に切り上げ (端点 2 点)" $
let ly = statFunction (\x -> x) 1.0 5.0 1
in case getLast (lyEncX ly) of
Just (ColNum xs) -> V.toList xs `shouldBe` [1.0, 5.0]
_ -> expectationFailure "encX should be inline ColNum"
describe "Phase 16 stat-in (statLm / statSmooth)" $ do
it "statLm は MStatLM + encX/encY を持つ Layer" $
case (getFirst (lyKind (statLm "x" "y")), getLast (lyEncX (statLm "x" "y"))
, getLast (lyEncY (statLm "x" "y"))) of
(Just MStatLM, Just _, Just _) -> True `shouldBe` True
other -> expectationFailure ("unexpected: " <> show other)
it "statSmooth は MStatSmooth + lyBinCount=n" $
case (getFirst (lyKind (statSmooth "x" "y" 8)), getLast (lyBinCount (statSmooth "x" "y" 8))) of
(Just MStatSmooth, Just 8) -> True `shouldBe` True
other -> expectationFailure ("unexpected: " <> show other)
it "装飾が通常 geom と同じく Layer field に乗る (statLm <> stroke 2 <> colorStatic)" $
let ly = statLm "x" "y" <> stroke 2 <> color (fromHex "#d62728")
in getLast (lyStroke ly) `shouldBe` Just 2
it "renderer は未解決 MStat* を skip (band PPath = 0)" $
let r n = case n of
"x" -> Just (NumData (V.fromList [1,2,3,4,5]))
"y" -> Just (NumData (V.fromList [2,4,6,8,10]))
_ -> Nothing
spec = layer (statLm "x" "y")
ps = renderToPrimitives r (computeLayout r spec) spec
in length [() | PPath{} <- ps] `shouldBe` 0
-- =========================================================================
-- Phase 40 A3: hexbin binning core (hexbinCells = d3-hexbin)
-- =========================================================================
describe "Phase 40 A3: hexbinCells (六角ビニング)" $ do
it "件数の総和 = 範囲内の点数 (件数保存)" $
let pts = [ (x, y) | x <- [0.05, 0.15 .. 0.95], y <- [0.05, 0.15 .. 0.95] ]
cells = hexbinCells 6 (0, 1) (0, 1) pts
in sum (map hexCount cells) `shouldBe` length pts
it "同一座標の点は 1 セルに集約 (件数 = 点数)" $
let cells = hexbinCells 8 (0, 1) (0, 1) (replicate 7 (0.5, 0.5))
in (length cells, map hexCount cells) `shouldBe` (1, [7])
it "各セルは 6 頂点 (pointy-top)" $
let cells = hexbinCells 4 (0, 1) (0, 1) [(0.3, 0.3), (0.7, 0.8)]
in all ((== 6) . length . hexVerts) cells `shouldBe` True
it "退化入力 (bins<=0 / 空) は空" $
(hexbinCells 0 (0,1) (0,1) [(0.5,0.5)], hexbinCells 5 (0,1) (0,1) [])
`shouldBe` ([], [])
-- =========================================================================
-- Phase 7 A7: gallery primitive count 回帰 test (golden)
-- 全 gallery spec を render し Primitive 本数を golden と突合。 1 chart を直すと
-- 別が静かに壊れる連鎖を機械検知する (目視に頼らない回帰検知の土台)。
-- =========================================================================
describe "gallery primitive count 回帰 (Phase 7 A7)" $
it "全 gallery spec の primitive 本数が golden と一致" $ do
mGalleryDir <- findGalleryDir
case mGalleryDir of
-- fixture (design/gallery) 非同梱の環境 (公開ツリー等) では skip。
Nothing -> pendingWith "design/gallery fixture が無い環境のため skip"
Just galleryDir -> do
actual <- galleryCountsString galleryDir
let goldenPath = galleryDir ++ "/primitive-counts.golden"
exists <- doesFileExist goldenPath
if not exists
then writeFile goldenPath actual
>> pendingWith "golden 初回生成 (次回実行から比較)"
else do golden <- readFile goldenPath
actual `shouldBe` golden
-- =========================================================================
-- Phase 24 A4: contour バグ修正 (規則 grid 直入力) + griddata + level + filled
-- =========================================================================
describe "Phase 24 A4: Griddata (規則 grid 検出 + k 近傍 IDW)" $ do
it "detectGrid: 規則 grid を補間なしで厳密復元 (行 = y)" $
Griddata.detectGrid [ (x, y, x * 10 + y) | x <- [0, 1, 2], y <- [0, 1] ]
`shouldBe` Just ([0, 1, 2], [0, 1], [[0, 10, 20], [1, 11, 21]])
it "detectGrid: 歯抜けの散布は Nothing (resampleKNN へ fallback)" $
Griddata.detectGrid [(0, 0, 1), (1, 0, 2), (0, 1, 3)] `shouldBe` Nothing
it "resampleKNN: データ点と一致するノードはその z に収束 (局所重み)" $
let (_, _, g) = Griddata.resampleKNN 4 3 3 [ (x, y, x + y) | x <- [0, 1, 2], y <- [0, 1, 2] ]
in abs ((g !! 0 !! 0) - 0) + abs ((g !! 2 !! 2) - 4) < 1e-6 `shouldBe` True
describe "Phase 24 A4: contour level 指定 + filled contour" $ do
let gridPts = [ (x, y, x * x + y * y) | i <- [0 .. 10 :: Int], j <- [0 .. 10 :: Int]
, let x = -2 + 0.4 * fromIntegral i, let y = -2 + 0.4 * fromIntegral j ]
xs3 = [a | (a, _, _) <- gridPts]; ys3 = [b | (_, b, _) <- gridPts]
zs3 = [c | (_, _, c) <- gridPts]
mkSpec extra = layer (contour (inline xs3) (inline ys3) (inline zs3) <> extra)
primsOf spec = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
lineColors spec = Data.List.nub
[ c | PLine _ _ (LineStyle c _ _) <- primsOf spec, c /= tpA "#888888", c /= tpA "#bbbbbb"
, c /= tpA "#dddddd", c /= tpA "#444444", c /= tpA "#333333" ]
tpA = Data.Text.pack
it "既定 8 レベル (内側等間隔・クランプ廃止)" $
length (lineColors (mkSpec mempty)) `shouldBe` 8
it "contourLevels 4 で 4 レベル" $
length (lineColors (mkSpec (contourLevels 4))) `shouldBe` 4
it "contourBreaks [2] で 1 レベルのみ" $
length (lineColors (mkSpec (contourBreaks [2]))) `shouldBe` 1
it "contourFilled: 塗り PPath が出る (帯色 = level+1 種)" $
let spec = layer (contourFilled (inline xs3) (inline ys3) (inline zs3)
<> contourLevels 4)
fills = Data.List.nub [ c | PPath _ (FillStyle c _) _ <- primsOf spec ]
in length fills `shouldBe` 5
describe "Phase 63 A2: grid major/minor の個別 on/off" $ do
-- 連続 x/y scatter + ThemeMinimal (grid on) を基準に、 PLine 本数の差分で
-- major/minor の描き分けを検証 (tick/軸枠も PLine のため絶対数でなく差分計数)。
let spec63 extra = layer (scatter (inline [1.0, 2.0, 3.0, 4.0 :: Double])
(inline [2.0, 4.0, 1.0, 3.0 :: Double]))
<> theme ThemeMinimal <> extra
nLines extra = let s = spec63 extra
in length [ () | PLine{} <- renderToPrimitives emptyResolver
(computeLayout emptyResolver s) s ]
nAll = nLines mempty
nMajorOff = nLines (themeGridMajor False)
nMinorOff = nLines (themeGridMinor False)
it "themeGridMajor False で major 分だけ減る" $
(nAll - nMajorOff > 0) `shouldBe` True
it "themeGridMinor False で minor 分だけ減る" $
(nAll - nMinorOff > 0) `shouldBe` True
it "個別 off ×2 = 一括 themeGrid False (糖衣と一致)" $
nLines (themeGridMajor False <> themeGridMinor False)
`shouldBe` nLines (themeGrid False)
it "個別 > 一括: themeGrid False <> themeGridMinor True は minor のみ" $
nLines (themeGrid False <> themeGridMinor True) `shouldBe` nMajorOff
it "指定順に依らず個別が勝つ (themeGridMinor True <> themeGrid False)" $
nLines (themeGridMinor True <> themeGrid False) `shouldBe` nMajorOff
it "preset off (ThemeClassic) にも個別 on が勝つ" $
(nLines (theme ThemeClassic <> themeGridMajor True)
> nLines (theme ThemeClassic)) `shouldBe` True
it "未指定は現行既定のまま (minor = 太さ 0.5 の線が存在)" $
let s = spec63 mempty
ws = [ w | PLine _ _ (LineStyle _ w _) <- renderToPrimitives emptyResolver
(computeLayout emptyResolver s) s ]
in (0.5 `elem` ws) `shouldBe` True
describe "Phase 63 A3: legend position の theme 化 (themeLegendPos)" $ do
it "theme 焼き込みが効く" $
effectiveLegendPos (themeLegendPos LegendBottom) `shouldBe` LegendBottom
it "図レベル legendPos が theme より優先" $
effectiveLegendPos (themeLegendPos LegendBottom <> legendPos LegendRight)
`shouldBe` LegendRight
it "未指定は既定 LegendRightCenter のまま" $
effectiveLegendPos mempty `shouldBe` LegendRightCenter
it "render 経路でも themeLegendPos = 図レベル legendPos と同一出力" $
let base = layer (scatter (inline [1.0, 2.0, 3.0, 4.0 :: Double])
(inline [2.0, 4.0, 1.0, 3.0 :: Double])
<> colorBy (inlineCat (["a", "a", "b", "b"] :: [Data.Text.Text])))
mk extra = let s = base <> extra
in renderToPrimitives emptyResolver (computeLayout emptyResolver s) s
in mk (themeLegendPos LegendBottom) `shouldBe` mk (legendPos LegendBottom)
describe "Phase 63 A4: tick の長さ・向き (themeTickLength/themeTickDir)" $ do
let base64 = layer (scatter (inline [1.0, 2.0, 3.0, 4.0 :: Double])
(inline [2.0, 4.0, 1.0, 3.0 :: Double]))
layOf extra = computeLayout emptyResolver (base64 <> extra)
areaOf extra = lpPlotArea (layOf extra)
primsOf64 extra = renderToPrimitives emptyResolver (layOf extra) (base64 <> extra)
panelBottom extra = let a = areaOf extra in rY a + rH a
-- 下辺 tick = 垂直 PLine (x1 == x2)。 panel 下端からの外向き突出量の最大。
overhang extra =
let yb = panelBottom extra
in maximum (0 : [ max y1 y2 - yb
| PLine (Point x1 y1) (Point x2 y2) _ <- primsOf64 extra
, x1 == x2 ])
it "既定 = ggTickLen / TickOut" $ do
effectiveTickLength mempty `shouldBe` ggTickLen
effectiveTickDir mempty `shouldBe` TickOut
it "後勝ち合成 (Last)" $
effectiveTickLength (themeTickLength 5 <> themeTickLength 10) `shouldBe` 10
it "themeTickDir TickOut は既定と同一出力 (既存挙動不変)" $
primsOf64 (themeTickDir TickOut) `shouldBe` primsOf64 mempty
it "themeTickLength が margin 予約に効く (左端が右へ・下端が上へ)" $ do
(rX (areaOf (themeTickLength 10)) > rX (areaOf mempty)) `shouldBe` True
(panelBottom (themeTickLength 10) < panelBottom mempty) `shouldBe` True
it "TickIn は外向き 0 = tickLength 0 と同じ margin" $
areaOf (themeTickDir TickIn) `shouldBe` areaOf (themeTickLength 0)
it "render: themeTickLength で外向き tick が長くなる" $
(overhang (themeTickLength 10) > overhang mempty) `shouldBe` True
it "render: TickIn は panel 下端より下に線が出ない" $
let yb = panelBottom (themeTickDir TickIn)
ys = concat [ [y1, y2]
| PLine (Point _ y1) (Point _ y2) _
<- primsOf64 (themeTickDir TickIn) ]
in all (<= yb + 1e-6) ys `shouldBe` True
it "render: TickBoth は panel 下端を跨ぐ tick が出る" $
let yb = panelBottom (themeTickDir TickBoth)
in any (\(y1, y2) -> min y1 y2 < yb - 1e-6 && max y1 y2 > yb + 1e-6)
[ (y1, y2) | PLine (Point x1 y1) (Point x2 y2) _
<- primsOf64 (themeTickDir TickBoth)
, x1 == x2 ]
`shouldBe` True
describe "Phase 68: grid / 軸線 線幅の theme 化 (themeGridWidth/themeGridMinorWidth/themeAxisLineWidth)" $ do
-- === 解決関数の規約 (単一情報源) ===
it "未指定 = 各 role の現状値 (golden 保存)" $ do
effectiveGridWidth mempty `shouldBe` 1.0
effectiveGridMinorWidth mempty `shouldBe` 0.5 -- major 1.0 × 0.5
effectiveNonCartesianGridWidth mempty `shouldBe` 0.5 -- polar / ternary grid
effectiveAxisLineWidth mempty `shouldBe` 1.0
it "toGridWidth 指定で Cartesian major + polar/ternary grid が統一される (座標系非依存)" $ do
let ov = vsThemeOverride (themeGridWidth 2.5)
effectiveGridWidth ov `shouldBe` 2.5
effectiveNonCartesianGridWidth ov `shouldBe` 2.5
it "minor 未指定は major × 0.5 (ggplot rel(0.5)) に追従する" $
effectiveGridMinorWidth (vsThemeOverride (themeGridWidth 3.0)) `shouldBe` 1.5
it "themeGridMinorWidth は major と独立に上書きできる (major 不変)" $ do
let ov = vsThemeOverride (themeGridWidth 3.0 <> themeGridMinorWidth 0.9)
effectiveGridMinorWidth ov `shouldBe` 0.9
effectiveGridWidth ov `shouldBe` 3.0
it "themeAxisLineWidth は grid に波及しない (逆も同様)" $ do
let ov = vsThemeOverride (themeAxisLineWidth 4.0)
effectiveAxisLineWidth ov `shouldBe` 4.0
effectiveGridWidth ov `shouldBe` 1.0
effectiveNonCartesianGridWidth ov `shouldBe` 0.5
it "後勝ち合成 (Last)" $
effectiveGridWidth (vsThemeOverride (themeGridWidth 2 <> themeGridWidth 5)) `shouldBe` 5.0
-- === JSON 後方互換 (新 field 無しの既存 spec が読める・Phase 64 A11 と同契約) ===
it "JSON 後方互換: 新 field 無しの ThemeOverride は Last Nothing で decode" $ do
let dec = eitherDecode "{}" :: Either String ThemeOverride
fmap (getLast . toGridWidth) dec `shouldBe` Right Nothing
fmap (getLast . toGridMinorWidth) dec `shouldBe` Right Nothing
fmap (getLast . toAxisLineWidth) dec `shouldBe` Right Nothing
it "JSON roundtrip: themeGridWidth 2.0 が encode→decode で保存" $
fmap (getLast . toGridWidth)
(eitherDecode (encode (vsThemeOverride (themeGridWidth 2.0))) :: Either String ThemeOverride)
`shouldBe` Right (Just 2.0)
-- === Render レベル (Cartesian grid の実線幅が変わる・既定は不変) ===
let base68 = layer (scatter (inline [1.0, 2.0, 3.0, 4.0 :: Double])
(inline [2.0, 4.0, 1.0, 3.0 :: Double]))
<> themeGrid True
widthsOf extra =
[ lsWidth ls
| PLine _ _ ls <- renderToPrimitives emptyResolver
(computeLayout emptyResolver (base68 <> extra)) (base68 <> extra) ]
it "render: 既定 grid 線幅に 2.0 は出ない (現状値のみ)" $
elem 2.0 (widthsOf mempty) `shouldBe` False
it "render: themeGridWidth 2.0 で grid 線が 2.0 になる" $
elem 2.0 (widthsOf (themeGridWidth 2.0)) `shouldBe` True
it "render: themeGridWidth 未指定は既定と byte 完全一致 (golden ゼロ diff)" $
widthsOf (themeGridMinorWidth 0.5 <> themeGridWidth 1.0) `shouldBe` widthsOf mempty
describe "Phase 63 A5: plot margin (themePlotMargin)" $ do
let base65 = layer (scatter (inline [1.0, 2.0, 3.0, 4.0 :: Double])
(inline [2.0, 4.0, 1.0, 3.0 :: Double]))
layOf extra = computeLayout emptyResolver (base65 <> extra)
areaOf extra = lpPlotArea (layOf extra)
primsOf65 extra = renderToPrimitives emptyResolver (layOf extra) (base65 <> extra)
it "既定 = 各辺 ggHalfLine" $
effectivePlotMargin mempty
`shouldBe` Margin ggHalfLine ggHalfLine ggHalfLine ggHalfLine
it "既定値の明示指定 (5.5 ×4) は既定と同一出力 (置き換え意味論・既存挙動不変)" $
primsOf65 (themePlotMargin 5.5 5.5 5.5 5.5) `shouldBe` primsOf65 mempty
it "4 辺が個別に効く (t/r/b/l = 30/40/50/60)" $ do
let a0 = areaOf mempty
a1 = areaOf (themePlotMargin 30 40 50 60)
(rY a1 > rY a0) `shouldBe` True -- top
(rX a1 + rW a1 < rX a0 + rW a0) `shouldBe` True -- right
(rY a1 + rH a1 < rY a0 + rH a0) `shouldBe` True -- bottom
(rX a1 > rX a0) `shouldBe` True -- left
it "margin 0 で panel が外周いっぱいへ広がる" $ do
let a0 = areaOf mempty
a1 = areaOf (themePlotMargin 0 0 0 0)
(rW a1 > rW a0) `shouldBe` True
(rH a1 > rH a0) `shouldBe` True
it "render: title/軸タイトルが margin に追従 (予約と描画の整合)" $
let textYs extra = [ y | PText (Point _ y) t _
<- renderToPrimitives emptyResolver
(computeLayout emptyResolver (base65 <> title "T" <> extra))
(base65 <> title "T" <> extra)
, t == "T" ]
in case (textYs mempty, textYs (themePlotMargin 30 5.5 5.5 5.5)) of
([y0], [y1]) -> (y1 - y0) `shouldBe` (30 - 5.5)
_ -> expectationFailure "title PText が 1 個でない"
describe "Phase 63 A6: subplot の相対サイズ (subplotWidths/Heights)" $ do
let p1 = layer (scatter (inline [1.0, 2.0 :: Double]) (inline [1.0, 2.0 :: Double]))
p2 = layer (scatter (inline [1.0, 2.0 :: Double]) (inline [2.0, 1.0 :: Double]))
prims66 spec = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
-- panel 枠 = stroke 付き PRect (background/panel 塗りは stroke 無し)。
frames spec = [ r | PRect r _ (Just _) <- prims66 spec ]
it "未指定 = subplotWidths [1,1] と同一出力 (既定挙動不変)" $
prims66 ((p1 <-> p2) <> subplotWidths [1, 1]) `shouldBe` prims66 (p1 <-> p2)
it "subplotWidths [3,1] で枠幅が 3:1" $
case frames ((p1 <-> p2) <> subplotWidths [3, 1]) of
[ra, rb] -> (abs (rW ra / rW rb - 3) < 1e-6) `shouldBe` True
fs -> expectationFailure ("枠が 2 個でない: " <> show (length fs))
it "不足分は 1 埋め ([3] = [3,1] と同一出力)" $
prims66 ((p1 <-> p2) <> subplotWidths [3])
`shouldBe` prims66 ((p1 <-> p2) <> subplotWidths [3, 1])
it "subplotHeights [2,1] で枠高が 2:1 (縦並び)" $
case frames ((p1 <:> p2) <> subplotHeights [2, 1]) of
[ra, rb] -> (abs (rH ra / rH rb - 2) < 1e-6) `shouldBe` True
fs -> expectationFailure ("枠が 2 個でない: " <> show (length fs))
describe "Phase 63 A7: subplot panel タグ (subplotTags)" $ do
let p1 = layer (scatter (inline [1.0, 2.0 :: Double]) (inline [1.0, 2.0 :: Double]))
p2 = layer (scatter (inline [1.0, 2.0 :: Double]) (inline [2.0, 1.0 :: Double]))
prims67 spec = renderToPrimitives emptyResolver (computeLayout emptyResolver spec) spec
texts spec = [ t | PText _ t _ <- prims67 spec ]
frames spec = [ r | PRect r _ (Just _) <- prims67 spec ]
it "未指定 = タグ無し (既定挙動不変)" $
("A" `elem` texts (p1 <-> p2)) `shouldBe` False
it "TagUpper で panel 列挙順に A/B" $ do
("A" `elem` texts ((p1 <-> p2) <> subplotTags TagUpper)) `shouldBe` True
("B" `elem` texts ((p1 <-> p2) <> subplotTags TagUpper)) `shouldBe` True
it "TagLower で a/b" $ do
("a" `elem` texts ((p1 <-> p2) <> subplotTags TagLower)) `shouldBe` True
("b" `elem` texts ((p1 <-> p2) <> subplotTags TagLower)) `shouldBe` True
it "TagNumeric の増分 = 1/2 (tick ラベルと区別して差分で見る)" $
(texts ((p1 <-> p2) <> subplotTags TagNumeric) Data.List.\\ texts (p1 <-> p2))
`shouldBe` ["1", "2"]
it "panel 個別の tag が優先 (個別 > 一括)" $ do
let tagged = ((p1 <> tag "X") <-> p2) <> subplotTags TagUpper
("X" `elem` texts tagged) `shouldBe` True
("B" `elem` texts tagged) `shouldBe` True
("A" `elem` texts tagged) `shouldBe` False
it "tag の margin 予約が panel 枠に効く (枠上端が下がる)" $
case (frames (p1 <-> p2), frames ((p1 <-> p2) <> subplotTags TagUpper)) of
([ra, _], [rb, _]) -> (rY rb > rY ra) `shouldBe` True
_ -> expectationFailure "枠が 2 個でない"
it "JSON roundtrip (TagStyle = nullary 名)" $
eitherDecode (encode ((p1 <-> p2) <> subplotTags TagLower))
`shouldBe` Right ((p1 <-> p2) <> subplotTags TagLower)
describe "Phase 63 A8: cowplot 風 preset (themeCowplot/themeMinimalGrid/themeMap)" $ do
let p1 = layer (scatter (inline [1.0, 2.0 :: Double]) (inline [1.0, 2.0 :: Double]))
palOf spec = resolveTheme
(maybe ThemeDefault id (getLast (vsTheme spec))) (vsThemeOverride spec)
it "themeCowplot = ThemeClassic 基調 + 黒軸線 + tick 3.5 + margin 7" $ do
getLast (vsTheme themeCowplot) `shouldBe` Just ThemeClassic
tpShowGridMajor (palOf themeCowplot) `shouldBe` False
tpShowAxisLine (palOf themeCowplot) `shouldBe` True
tpAxis (palOf themeCowplot) `shouldBe` "#000000"
tpTitleColor (palOf themeCowplot) `shouldBe` "#000000"
effectiveTickLength themeCowplot `shouldBe` 3.5
effectivePlotMargin themeCowplot `shouldBe` Margin 7 7 7 7
it "themeMinimalGrid = major grid (grey85) のみ・軸線/枠/tick なし" $ do
tpShowGridMajor (palOf themeMinimalGrid) `shouldBe` True
tpShowGridMinor (palOf themeMinimalGrid) `shouldBe` False
tpGrid (palOf themeMinimalGrid) `shouldBe` "#d9d9d9"
tpShowBorder (palOf themeMinimalGrid) `shouldBe` False
tpShowAxisLine (palOf themeMinimalGrid) `shouldBe` False
effectiveTickLength themeMinimalGrid `shouldBe` 0
it "themeMap = ThemeVoid 基調 (軸/grid/枠なし) + margin 7" $ do
getLast (vsTheme themeMap) `shouldBe` Just ThemeVoid
tpShowGridMajor (palOf themeMap) `shouldBe` False
tpShowAxisLine (palOf themeMap) `shouldBe` False
tpShowBorder (palOf themeMap) `shouldBe` False
effectiveTickLength themeMap `shouldBe` 0
effectivePlotMargin themeMap `shouldBe` Margin 7 7 7 7
it "後置 setter が preset を上書き (preset は普通の VisualSpec 値)" $ do
effectiveTickLength (themeCowplot <> themeTickLength 5) `shouldBe` 5
-- ★ A14: preset は tick 長を明示しない (base 派生に任せる) ため、 先置きの
-- 明示 setter は preset を通っても生き残る (A8 当時は preset 明示 3.5 が勝った)。
effectiveTickLength (themeTickLength 5 <> themeCowplot) `shouldBe` 5
-- base font size は preset が明示するので後置 preset が勝つ (Last)。
effectiveBaseFontSize (themeBaseFontSize 12 <> themeCowplot) `shouldBe` 14
it "JSON roundtrip (既存 field のみ = 新規 field 追加なし)" $
eitherDecode (encode (p1 <> themeCowplot))
`shouldBe` Right (p1 <> themeCowplot)
describe "Phase 63 A12: base font size (themeBaseFontSize)" $ do
let base68 = layer (scatter (inline [1.0, 2.0, 3.0, 4.0 :: Double])
(inline [2.0, 4.0, 1.0, 3.0 :: Double]))
<> title "T" <> xLabel "x" <> yLabel "y"
layOf extra = computeLayout emptyResolver (base68 <> extra)
areaOf extra = lpPlotArea (layOf extra)
primsOf68 extra = renderToPrimitives emptyResolver (layOf extra) (base68 <> extra)
-- 描画テキストの font size (本文 t で slot を特定)
sizeOf extra t = [ tsSize ts | PText _ t' ts <- primsOf68 extra, t' == t ]
it "既定 = 11" $
effectiveBaseFontSize mempty `shouldBe` 11
it "後勝ち合成 (Last)" $
effectiveBaseFontSize (themeBaseFontSize 14 <> themeBaseFontSize 12) `shouldBe` 12
it "既定値の明示指定 (11) は既定と同一出力 (golden 不変 gate の単体版)" $
primsOf68 (themeBaseFontSize 11) `shouldBe` primsOf68 mempty
it "render: base 14 で相対倍率どおり派生 (title ×1.2 / axis.title ×1 / axis.text ×0.8)" $ do
sizeOf (themeBaseFontSize 14) "T" `shouldBe` [14 * 1.2]
sizeOf (themeBaseFontSize 14) "x" `shouldBe` [14.0]
sizeOf (themeBaseFontSize 14) "1" `shouldBe` [14 * 0.8, 14 * 0.8] -- x/y 両軸の tick "1"
it "個別 theme*Font (fsSize) > base 派生" $
sizeOf (themeBaseFontSize 14 <> themeTickFont (fontSize 9)) "1" `shouldBe` [9, 9]
it "layout: base 拡大が margin 予約に効く (左端が右へ・下端が上へ)" $ do
(rX (areaOf (themeBaseFontSize 22)) > rX (areaOf mempty)) `shouldBe` True
let pb extra = let a = areaOf extra in rY a + rH a
(pb (themeBaseFontSize 22) < pb mempty) `shouldBe` True
it "layout: theme*Font の fsSize も予約に効く (旧 setter-only 解決の fix)" $
(rY (areaOf (themeTitleFont (fontSize 30))) > rY (areaOf mempty)) `shouldBe` True
it "JSON roundtrip (toBaseFontSize field)" $
eitherDecode (encode (base68 <> themeBaseFontSize 14))
`shouldBe` Right (base68 <> themeBaseFontSize 14)
describe "Phase 63 A13: spacing の half_line = base/2 派生" $ do
let base69 = layer (scatter (inline [1.0, 2.0, 3.0, 4.0 :: Double])
(inline [2.0, 4.0, 1.0, 3.0 :: Double]))
areaOf extra = lpPlotArea (computeLayout emptyResolver (base69 <> extra))
it "既定 11: 全派生値 = 従来定数と bit 同値 (golden 不変 gate の単体版)" $ do
effectiveHalfLine mempty `shouldBe` ggHalfLine
effectiveTickLength mempty `shouldBe` ggTickLen
effectiveAxTextMar mempty `shouldBe` ggAxTextMar
effectiveAxTitleMar mempty `shouldBe` ggAxTitleMar
effectiveLegendBaseSize mempty `shouldBe` legendBaseSize
effectiveLegendKeyW mempty `shouldBe` legendKeyW
effectiveLegendKeyPitch mempty `shouldBe` legendKeyPitch
it "base 22 で half_line 系が倍 (tick 5.5 / margin 11 / axText 4.4)" $ do
effectiveHalfLine (themeBaseFontSize 22) `shouldBe` 11
effectiveTickLength (themeBaseFontSize 22) `shouldBe` 5.5
effectivePlotMargin (themeBaseFontSize 22) `shouldBe` Margin 11 11 11 11
effectiveAxTextMar (themeBaseFontSize 22) `shouldBe` 0.8 * 5.5
it "個別 theme setter が base 派生既定より優先" $ do
effectiveTickLength (themeBaseFontSize 22 <> themeTickLength 3) `shouldBe` 3
effectivePlotMargin (themeBaseFontSize 22 <> themePlotMargin 7 7 7 7)
`shouldBe` Margin 7 7 7 7
it "layout: base 拡大で spacing 予約も拡がる (font 増分と独立に tick/margin 分)" $ do
-- font 由来分を themeTickFont 等で固定し、 spacing 増分だけを観測する
let fixFonts = themeTitleFont (fontSize 13.2) <> themeAxisLabelFont (fontSize 11)
<> themeTickFont (fontSize 8.8)
(rX (areaOf (fixFonts <> themeBaseFontSize 22)) > rX (areaOf fixFonts))
`shouldBe` True
describe "Phase 63 A14: themeCowplotSized (base_size 引数) + labs の base 派生" $ do
let p1 = layer (scatter (inline [1.0, 2.0 :: Double]) (inline [1.0, 2.0 :: Double]))
tp = Data.Text.pack
slotSizeOf sl spec = effectiveFontSize mempty (sl (vsThemeOverride spec)) 0
base70 = p1 <> title (tp "T") <> subtitle (tp "sub")
<> caption (tp "cap") <> tag (tp "G")
primsOf70 extra = renderToPrimitives emptyResolver
(computeLayout emptyResolver (base70 <> extra)) (base70 <> extra)
sizeOf70 extra t = [ tsSize ts | PText _ t' ts <- primsOf70 extra, t' == tp t ]
it "themeCowplot = themeCowplotSized 14 (minimal-grid/map も同形)" $ do
themeCowplot `shouldBe` themeCowplotSized 14
themeMinimalGrid `shouldBe` themeMinimalGridSized 14
themeMap `shouldBe` themeMapSized 14
it "themeCowplotSized N: base=N 焼き込みで tick N/4 / margin N/2 が自動連動 (A13)" $ do
effectiveBaseFontSize (themeCowplotSized 12) `shouldBe` 12
effectiveTickLength (themeCowplotSized 12) `shouldBe` 3
effectivePlotMargin (themeCowplotSized 12) `shouldBe` Margin 6 6 6 6
effectiveTickLength (themeCowplotSized 14) `shouldBe` 3.5
effectivePlotMargin (themeCowplotSized 14) `shouldBe` Margin 7 7 7 7
-- minimal-grid/map は tick 0 を明示 (base 派生させない)
effectiveTickLength (themeMinimalGridSized 12) `shouldBe` 0
effectiveTickLength (themeMapSized 12) `shouldBe` 0
it "cowplot 倍率 font (title ×16/14 bold / axis.title ×1 / text ×12/14)" $ do
slotSizeOf toTitleFont (themeCowplotSized 12) `shouldBe` 12 * 16 / 14
slotSizeOf toAxisLabelFont (themeCowplotSized 12) `shouldBe` 12
slotSizeOf toTickFont (themeCowplotSized 12) `shouldBe` 12 * 12 / 14
slotSizeOf toLegendFont (themeCowplotSized 12) `shouldBe` 12 * 12 / 14
(getLast (toTitleFont (vsThemeOverride (themeCowplotSized 12)))
>>= getLast . fsWeight) `shouldBe` Just (tp "bold")
it "labs size = base 派生 (subtitle ×1 / caption ×0.8 / tag ×1.2 = ggplot 倍率)" $ do
effectiveSubtitleSize mempty `shouldBe` 11
effectiveCaptionSize mempty `shouldBe` 0.8 * 11
effectiveTagSize mempty `shouldBe` 1.2 * 11
effectiveSubtitleSize (themeBaseFontSize 14) `shouldBe` 14
effectiveCaptionSize (themeBaseFontSize 14) `shouldBe` 0.8 * 14
effectiveTagSize (themeBaseFontSize 14) `shouldBe` 1.2 * 14
it "render: subtitle/caption/tag の描画サイズも base 派生 (予約と単一情報源)" $ do
sizeOf70 (themeBaseFontSize 14) "sub" `shouldBe` [14.0]
sizeOf70 (themeBaseFontSize 14) "cap" `shouldBe` [0.8 * 14]
sizeOf70 (themeBaseFontSize 14) "G" `shouldBe` [1.2 * 14]
describe "Phase 63 A15: 軸タイトル = 軸 text 直下 + axis.title margin (最外端 pin 廃止)" $ do
let p71 = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double]))
<> xLabel "xt" <> yLabel "yt"
layOf71 extra = computeLayout emptyResolver (p71 <> extra)
primsOf71 extra = renderToPrimitives emptyResolver (layOf71 extra) (p71 <> extra)
textPtOf extra t = head [ p | PText p t' _ <- primsOf71 extra, t' == t ]
it "offset = tick 突出 + axis.text margin + tick ラベル帯 + axis.title margin (bM/lM 予約と同一 stack)" $ do
let lay = layOf71 mempty
-- x 側の tick ラベル帯 = tick font size (非回転 numeric)、 y 側 = 最長ラベル幅 (0.6em/char)
lpXTitleOff lay `shouldBe`
effectiveTickLength mempty + effectiveAxTextMar mempty + 0.8 * 11 + effectiveAxTitleMar mempty
lpYTitleOff lay `shouldBe`
effectiveTickLength mempty + effectiveAxTextMar mempty + 0.6 * (0.8 * 11) + effectiveAxTitleMar mempty
it "render: 軸タイトルは panel 端 + offset 基準 (boxBottom/boxLeft 最外端 pin 廃止)" $ do
let lay = layOf71 mempty
a = lpPlotArea lay
Point _ xy = textPtOf mempty "xt"
Point yx _ = textPtOf mempty "yt"
xy `shouldBe` rY a + rH a + lpXTitleOff lay + 0.8 * 11
yx `shouldBe` rX a - lpYTitleOff lay - 0.2 * 11
it "LegendBottom でも軸タイトルは軸 text 直下 = 凡例より内側 (J5 順序 fix)" $ do
let legended = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double])
<> colorBy (inlineCat ["a", "b", "c" :: Data.Text.Text]))
<> xLabel "xt" <> themeLegendPos LegendBottom
lay = computeLayout emptyResolver legended
a = lpPlotArea lay
xy = head [ y | PText (Point _ y) t _ <- renderToPrimitives emptyResolver lay legended
, t == "xt" ]
-- タイトル glyph 下端 (baseline + descent) が凡例ブロック上端 (★A17: lpLegendYOff) より内側
(xy + 0.2 * 11 <= rY a + rH a + lpLegendYOff lay) `shouldBe` True
-- panel 相対位置は凡例の有無で不変 (旧 pin は legendH ぶん外へ出ていた = J5)
abs (xy - (rY a + rH a) - (lpXTitleOff lay + 0.8 * 11)) `shouldSatisfy` (< 1e-9)
it "caption があっても軸タイトルは軸 text 直下 (caption はさらに外側)" $ do
let withCap = p71 <> caption "cap"
layC = computeLayout emptyResolver withCap
aC = lpPlotArea layC
xyC = head [ y | PText (Point _ y) t _ <- renderToPrimitives emptyResolver layC withCap
, t == "xt" ]
abs (xyC - (rY aC + rH aC) - (lpXTitleOff layC + 0.8 * 11)) `shouldSatisfy` (< 1e-9)
describe "Phase 63 A16: theme 回転角の layout 反映 (resolveAxisAngle 単一情報源)" $ do
let p72 = layer (bar (inlineCat ["alpha", "bravo", "charlie" :: Data.Text.Text])
(inline [1, 2, 3 :: Double]))
<> xLabel "xt"
areaOf72 extra = lpPlotArea (computeLayout emptyResolver (p72 <> extra))
pb72 extra = let a = areaOf72 extra in rY a + rH a -- panel 下端
it "themeAxisTextAngleX で回転マージンが予約される (旧 axisRotateOf は theme 無視 = J4)" $
(pb72 (themeAxisTextAngleX 45) < pb72 mempty) `shouldBe` True
it "共通 themeAxisTextAngle も x 側予約に効く" $
(pb72 (themeAxisTextAngle 45) < pb72 mempty) `shouldBe` True
it "theme 回転と per-axis 回転で予約が同値 (描画 resolveAxisAngle と同じ解決順)" $
areaOf72 (themeAxisTextAngleX 45) `shouldBe` areaOf72 (xAxis (axisRotate 45))
it "per-axis 明示が theme より優先 (解決順の単一情報源)" $
areaOf72 (xAxis (axisRotate 90) <> themeAxisTextAngleX 30)
`shouldBe` areaOf72 (xAxis (axisRotate 90))
describe "Phase 63 A17: bottom 凡例の実位置 + wrap (予約と描画の単一情報源)" $ do
let cats73 = ["aa", "bb", "cc"] :: [Data.Text.Text]
p73 = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double])
<> colorBy (inlineCat cats73))
<> xLabel "xt" <> themeLegendPos LegendBottom
lay73 = computeLayout emptyResolver p73
pb73 extra = let a = lpPlotArea (computeLayout emptyResolver (p73 <> extra))
in rY a + rH a
it "lpLegendYOff = 軸 stack (bM 予約と同一) + 2×half_line (= legend.box.spacing)" $
lpLegendYOff lay73 `shouldBe`
effectiveTickLength mempty + effectiveAxTextMar mempty + 0.8 * 11
+ effectiveAxTitleMar mempty + 11 + 2 * effectiveHalfLine mempty
it "render: 凡例は panel 下端 + lpLegendYOff 起点 = 軸タイトルの外側 (J5 順序 fix)" $ do
let a = lpPlotArea lay73
prims = renderToPrimitives emptyResolver lay73 p73
titleY = head [ y | PText (Point _ y) t _ <- prims, t == "xt" ]
legYs = [ y | PText (Point _ y) t _ <- prims, t `elem` cats73 ]
length legYs `shouldBe` 3
-- chip text baseline = block 上端 (+7 anchor) + 2 (renderLegendBottom と同式)
all (\y -> abs (y - (rY a + rH a + lpLegendYOff lay73 + 9)) < 1e-9) legYs
`shouldBe` True
-- 凡例 text 上端がタイトル glyph 下端より外側 (ticks→labels→title→legend)
all (\y -> y - 0.8 * (0.8 * 11) >= titleY + 0.2 * 11) legYs `shouldBe` True
it "auto-wrap: panel 幅に収まらないラベル群は複数行 (行数 = ceil(n/lpLegendNCol))" $ do
let longCats = [ Data.Text.pack ("categorylabel-" <> show i) | i <- [1 .. 8 :: Int] ]
pW = layer (scatter (inline [1.0 .. 8.0 :: Double])
(inline [1.0 .. 8.0 :: Double])
<> colorBy (inlineCat longCats))
<> themeLegendPos LegendBottom
layW = computeLayout emptyResolver pW
ys = Data.List.nub [ y | PText (Point _ y) t _
<- renderToPrimitives emptyResolver layW pW
, t `elem` longCats ]
(lpLegendNCol layW < 8) `shouldBe` True
length ys `shouldBe` (8 + lpLegendNCol layW - 1) `div` lpLegendNCol layW
it "明示 legendNrow は auto-wrap より優先 (nc = ceil(n/nrow) の従来コース)" $
lpLegendNCol (computeLayout emptyResolver (p73 <> legendNrow 3)) `shouldBe` 1
it "legendH 予約が行数連動 (nrow=2 は nrow=1 より panel 下端が上がる)" $
(pb73 (legendNrow 2) < pb73 (legendNrow 1)) `shouldBe` True
describe "Phase 63 A18: plot 背景透過の口 (themePlotBg)" $ do
-- 全面背景 = viewport ぴったりの PRect (fill 不透過・枠なし)。 これの有無で
-- 「塗る/塗らない」 を検証 (panel 塗りは plotArea サイズなので誤検出しない)。
let base18 = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double]))
nBg extra = let s = base18 <> extra
l = computeLayout emptyResolver s
vp = lpViewport l
in length [ () | PRect (Rect 0 0 w h) (FillStyle _ 1.0) Nothing
<- renderToPrimitives emptyResolver l s
, w == fromIntegral (vsW vp)
, h == fromIntegral (vsH vp) ]
it "既定は全面背景 rect が 1 枚 (従来挙動不変)" $
nBg mempty `shouldBe` 1
it "themePlotBg False で全面背景 rect が消える (= 透過)" $
nBg (themePlotBg False) `shouldBe` 0
it "cowplot 3 preset は背景透過 (cowplot rect fill NA 相当)" $
(nBg themeCowplot, nBg themeMinimalGrid, nBg themeMap) `shouldBe` (0, 0, 0)
it "preset 後の themePlotBg True で再点灯 (Last 合成)" $
nBg (themeCowplot <> themePlotBg True) `shouldBe` 1
it "specThemePalette: 未指定は tpShowBackground = True / override が勝つ" $
( tpShowBackground (specThemePalette mempty)
, tpShowBackground (specThemePalette (themePlotBg False)) )
`shouldBe` (True, False)
describe "Phase 63 A19: ThemeVoid 完全 void (axis.text / axis.title の blank 口)" $ do
let base19 = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double]))
<> title "T" <> xLabel "xt" <> yLabel "yt"
layOf extra = computeLayout emptyResolver (base19 <> extra)
textsOf extra = [ t | PText _ t _
<- renderToPrimitives emptyResolver (layOf extra) (base19 <> extra) ]
-- 軸 text の代表 = tick ラベル "1" (x/y 両軸)、 軸タイトル = "xt"/"yt"
hasTickLabel extra = "1" `elem` textsOf extra
hasAxisTitle extra = "xt" `elem` textsOf extra || "yt" `elem` textsOf extra
panelBottom extra = let a = lpPlotArea (layOf extra) in rY a + rH a
it "実効値: 既定 True / ThemeVoid のみ既定 False" $ do
( effectiveShowAxisText mempty, effectiveShowAxisTitle mempty )
`shouldBe` (True, True)
( effectiveShowAxisText (theme ThemeVoid)
, effectiveShowAxisTitle (theme ThemeVoid) ) `shouldBe` (False, False)
it "ThemeVoid は tick 長も既定 0 (ggplot theme_void の axis.ticks.length = 0)" $
effectiveTickLength (theme ThemeVoid) `shouldBe` 0
it "ThemeVoid: tick ラベル文字・軸タイトルが消え、 タイトル系は残る" $ do
hasTickLabel (theme ThemeVoid) `shouldBe` False
hasAxisTitle (theme ThemeVoid) `shouldBe` False
("T" `elem` textsOf (theme ThemeVoid)) `shouldBe` True
it "themeAxisText False: 文字のみ消え tick 線は残る (既定 theme)" $ do
hasTickLabel (themeAxisText False) `shouldBe` False
hasTickLabel mempty `shouldBe` True
-- tick 線 (PLine) の本数は不変 = 文字だけが落ちる
let nLines extra = length [ () | PLine _ _ _
<- renderToPrimitives emptyResolver (layOf extra)
(base19 <> extra) ]
nLines (themeAxisText False) `shouldBe` nLines mempty
it "themeAxisTitle False: 軸タイトルのみ消える" $ do
hasAxisTitle (themeAxisTitle False) `shouldBe` False
hasTickLabel (themeAxisTitle False) `shouldBe` True
it "margin 予約が連動 (非表示で panel 下端が下がる = 予約解放)" $ do
(panelBottom (themeAxisText False) > panelBottom mempty) `shouldBe` True
(panelBottom (themeAxisTitle False) > panelBottom mempty) `shouldBe` True
it "ThemeVoid 後の themeAxisText True で再点灯 (Last 合成・override > preset)" $
hasTickLabel (theme ThemeVoid <> themeAxisText True) `shouldBe` True
it "themeMap (合成 preset) も axis.text / axis.title が blank" $ do
hasTickLabel themeMap `shouldBe` False
hasAxisTitle themeMap `shouldBe` False
it "JSON roundtrip (toShowAxisText / toShowAxisTitle field)" $
eitherDecode (encode (base19 <> themeAxisText False <> themeAxisTitle False))
`shouldBe` Right (base19 <> themeAxisText False <> themeAxisTitle False)
describe "Phase 63 A19.5: 凡例キー帯 (bgRect) 撤去 + legend.key.size の theme 口" $ do
let base195 = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double])
<> colorBy (inlineCat (["a", "b", "a"] :: [Data.Text.Text])))
primsOf extra = renderToPrimitives emptyResolver
(computeLayout emptyResolver (base195 <> extra))
(base195 <> extra)
it "凡例キー列の連続帯が出ない (ThemeVoid: 塗り PRect = 全面背景 1 枚のみ)" $
-- 旧 bgRect は tpPanelBg 不透過帯を無条件に塗っていた (A18 の透過化で顕在化)
length [ () | PRect _ (FillStyle _ o) _ <- primsOf (theme ThemeVoid), o > 0 ]
`shouldBe` 1
it "ThemeGrey は legend.key (grey95) がキー数ぶんのみ = 連続帯との二重塗り解消" $
length [ () | PRect _ (FillStyle c o) _ <- primsOf (theme ThemeGrey)
, c == "#f2f2f2", o == 1.0 ]
`shouldBe` 2
it "effectiveLegendKeyW: 既定は従来値 / cowplot preset = 1.1×font_size / 上書き優先" $ do
effectiveLegendKeyW mempty `shouldBe` legendKeyW
effectiveLegendKeyW themeCowplot `shouldBe` 1.1 * 14
effectiveLegendKeyW (themeCowplotSized 12) `shouldBe` 1.1 * 12
effectiveLegendKeyW themeMap `shouldBe` 1.1 * 14
effectiveLegendKeyW (themeCowplot <> themeLegendKeySize 20) `shouldBe` 20
it "凡例行 pitch = キー辺 (cowplot は 22.06pt → 15.4pt に詰まる = gold 32px@150dpi)" $ do
effectiveLegendKeyPitch themeCowplot `shouldBe` 1.1 * 14
-- 描画実測: 凡例キー点 (panel 右端より外) の縦間隔が pitch と一致
let pitchOf extra =
let l = computeLayout emptyResolver (base195 <> extra)
a = lpPlotArea l
ys = Data.List.sort
[ y | PCircle (Point x y) _ _ _ _ <- primsOf extra
, x > rX a + rW a ]
in case ys of
(y1 : y2 : _) -> y2 - y1
_ -> 0
-- 描画座標は offset 加算の丸めが乗るため ULP 許容で比較
abs (pitchOf themeCowplot - 1.1 * 14) < 1e-9 `shouldBe` True
abs (pitchOf mempty - legendKeyPitch) < 1e-9 `shouldBe` True
it "JSON roundtrip (toLegendKeySize field)" $
eitherDecode (encode (base195 <> themeLegendKeySize 15.4))
`shouldBe` Right (base195 <> themeLegendKeySize 15.4)
describe "Phase 63 A20: bottom/top 凡例の keyBg も tpLegendKeyBg へ一本化" $ do
-- legendSwatch (bottom/top 経路) の Phase 34 grey95 ハードコードが
-- A19.5 の一本化から漏れていた取り残し (実測: cowplot bottom 凡例に
-- #f2f2f2 1695px、 右凡例は 0px)
let base20 = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double])
<> colorBy (inlineCat (["a", "b", "a"] :: [Data.Text.Text])))
greyKeys extra =
length [ () | PRect _ (FillStyle c o) _
<- renderToPrimitives emptyResolver
(computeLayout emptyResolver (base20 <> extra))
(base20 <> extra)
, c == "#f2f2f2", o == 1.0 ]
it "cowplot (tpLegendKeyBg = \"\") の bottom 凡例に grey95 キー箱が出ない" $
greyKeys (themeCowplot <> themeLegendPos LegendBottom) `shouldBe` 0
it "既定 theme (tpLegendKeyBg = \"\") の bottom 凡例も出ない = 右凡例と整合" $
greyKeys (themeLegendPos LegendBottom) `shouldBe` 0
it "ThemeGrey の bottom 凡例はキー数ぶんの grey95 (theme 口は生きる)" $
greyKeys (theme ThemeGrey <> themeLegendPos LegendBottom) `shouldBe` 2
describe "Phase 63 A20.5: themeFontFamily (全 text slot 共通の family fallback)" $ do
let base205 = layer (scatter (inline [1.0, 2.0, 3.0 :: Double])
(inline [2.0, 4.0, 1.0 :: Double])
<> colorBy (inlineCat (["a", "b", "a"] :: [Data.Text.Text])))
<> title "t" <> xLabel "x" <> yLabel "y"
famsOf extra =
nubKeepT [ tsFamily ts
| PText _ _ ts <- renderToPrimitives emptyResolver
(computeLayout emptyResolver (base205 <> extra))
(base205 <> extra) ]
nubKeepT = foldr (\x acc -> if x `elem` acc then acc else x : acc) []
it "themeFontFamily が全 text slot (title/axis/tick/legend) へ波及する" $
famsOf (themeFontFamily "DejaVu Sans") `shouldBe` ["DejaVu Sans"]
it "slot 別 FontSpec の family が themeFontFamily より優先" $ do
let fams = famsOf (themeFontFamily "A" <> themeTickFont (fontFamily "B"))
("A" `elem` fams, "B" `elem` fams) `shouldBe` (True, True)
it "preset の fontSize 焼き込みを潰さない (cowplotSized 12 の text size 不変)" $
let sizesOf extra =
[ (tsSize ts, tsFamily ts)
| PText _ _ ts <- renderToPrimitives emptyResolver
(computeLayout emptyResolver (base205 <> extra))
(base205 <> extra) ]
withFam = sizesOf (themeCowplotSized 12 <> themeFontFamily "X")
withoutFam = sizesOf (themeCowplotSized 12)
in map fst withFam `shouldBe` map fst withoutFam
it "JSON roundtrip (toFontFamily field)" $
eitherDecode (encode (base205 <> themeFontFamily "DejaVu Sans"))
`shouldBe` Right (base205 <> themeFontFamily "DejaVu Sans")
describe "Phase 65: boxplot outlier の domain 内包 (panel 外打点 fix)" $ do
let vals65 = [10, 11, 12, 13, 14, 15, 16, 40 :: Double] -- 40 = 1.5×IQR フェンス外
sp65 = layer (boxplot (inline vals65)
<> groupBy (inlineCat (replicate 8 ("g" :: Data.Text.Text))))
lay65 = computeLayout emptyResolver sp65
a65 = lpPlotArea lay65
circleYs = [ y | PCircle (Point _ y) _ _ _ _
<- renderToPrimitives emptyResolver lay65 sp65 ]
it "outlier ドット (PCircle) が panel y 範囲内に収まる" $ do
length circleYs `shouldBe` 1
all (\y -> y >= rY a65 && y <= rY a65 + rH a65) circleYs `shouldBe` True
describe "Math.Special: logGamma" $ do
it "logGamma 1 = 0 (Γ1=1)" $ abs (logGamma 1) < 1e-10 `shouldBe` True
it "logGamma 2 = 0 (Γ2=1)" $ abs (logGamma 2) < 1e-10 `shouldBe` True
it "logGamma 3 = ln 2" $ abs (logGamma 3 - log 2) < 1e-9 `shouldBe` True
it "logGamma 5 = ln 24" $ abs (logGamma 5 - log 24) < 1e-9 `shouldBe` True
it "logGamma 0.5 = ln √π" $ abs (logGamma 0.5 - log (sqrt pi)) < 1e-8 `shouldBe` True
describe "Math.Special: regIncompleteBeta" $ do
it "I_x(1,1) = x (一様 CDF)" $
all (\x -> abs (regIncompleteBeta 1 1 x - x) < 1e-9) [0.1,0.3,0.5,0.7,0.9]
`shouldBe` True
it "I_0.5(2,2) = 0.5 (対称)" $ abs (regIncompleteBeta 2 2 0.5 - 0.5) < 1e-9 `shouldBe` True
it "端点 I_0 = 0 / I_1 = 1" $
(regIncompleteBeta 3 5 0 == 0 && regIncompleteBeta 3 5 1 == 1) `shouldBe` True
it "対称律 I_0.5(a,b) = 1 - I_0.5(b,a)" $
abs (regIncompleteBeta 2 5 0.5 - (1 - regIncompleteBeta 5 2 0.5)) < 1e-10 `shouldBe` True
it "単調増加 (x↑ で I↑)" $
let xs = [0.05,0.1..0.95] in
and (zipWith (<) (map (regIncompleteBeta 3 4) xs) (map (regIncompleteBeta 3 4) (tail xs)))
`shouldBe` True
describe "Math.Special: betaQuantile" $ do
it "betaQuantile 0.5 1 1 = 0.5" $ abs (betaQuantile 0.5 1 1 - 0.5) < 1e-9 `shouldBe` True
it "betaQuantile 0.5 3 3 = 0.5 (対称)" $ abs (betaQuantile 0.5 3 3 - 0.5) < 1e-9 `shouldBe` True
it "逆関数往復 I(betaQuantile q) ≈ q" $
all (\(q,a,b) -> abs (regIncompleteBeta a b (betaQuantile q a b) - q) < 1e-9)
[ (0.025,2,9), (0.5,5,5), (0.975,2,9), (0.1,1,1), (0.9,7,3) ]
`shouldBe` True
it "Benard 中央順位近似 (median ≈ (i-0.3)/(n+0.4))" $
let n = 10 :: Int
ok i = abs (betaQuantile 0.5 (fromIntegral i) (fromIntegral (n-i+1))
- (fromIntegral i - 0.3) / (fromIntegral n + 0.4)) < 0.01
in all ok [1 .. n] `shouldBe` True
where
isMissing (PlotError MissingAesthetic{} _) = True
isMissing _ = False
isNotFound (PlotError ColumnNotFound{} _) = True
isNotFound _ = False
isTypeMismatch (PlotError ColumnTypeMismatch{} _) = True
isTypeMismatch _ = False
isHoverWarn (PlotWarning (BackendUnsupported _ FeatHover) _) = True
isHoverWarn _ = False
-- ===========================================================================
-- Phase 7 A7: gallery primitive count 回帰 test の helper (module level)
-- ===========================================================================
-- | design/gallery/specs/**/*.json を全て render し、 case ごとの Primitive
-- constructor 別本数を 1 行にまとめた文字列を返す (golden 比較用)。
-- ⚠ repo root を cwd として実行する前提 (cabal test を repo root から)。
galleryCountsString :: FilePath -> IO String
galleryCountsString galleryDir = do
let specsDir = galleryDir ++ "/specs"
prefix = specsDir ++ "/"
files <- listJsonRec specsDir
rows <- mapM (countRow prefix) (sort files)
pure (unlines rows)
where
countRow prefix f = do
bs <- BL.readFile f
let rel = drop (length prefix) f
case eitherDecode bs of
Left err -> pure (rel ++ ": DECODE-ERROR " ++ err)
Right spec -> do
let lay = computeLayout emptyResolver spec
prims = renderToPrimitives emptyResolver lay spec
counts = Map.toAscList
(Map.fromListWith (+) [(ctorName p, 1 :: Int) | p <- prims])
pure (rel ++ ": " ++ unwords [c ++ "=" ++ show n | (c, n) <- counts])
-- | cwd から design/gallery を探す (cabal test の cwd が repo root か package
-- dir か実行環境で異なるため、 数段上まで候補を辿る)。
-- fixture 非同梱の環境 (公開ツリー等) では 'Nothing' (test 側で pendingWith skip)。
findGalleryDir :: IO (Maybe FilePath)
findGalleryDir = go [ up n ++ "design/gallery" | n <- [0 .. 4 :: Int] ]
where
up n = concat (replicate n "../")
go [] = pure Nothing
go (d:ds) = do
e <- doesDirectoryExist d
if e then pure (Just d) else go ds
-- | design/gallery/specs 配下を再帰列挙し .json のみ返す。
listJsonRec :: FilePath -> IO [FilePath]
listJsonRec dir = do
entries <- listDirectory dir
fmap concat (mapM step entries)
where
step e = do
let full = dir </> e
isDir <- doesDirectoryExist full
if isDir then listJsonRec full
else pure [full | takeExtension full == ".json"]
-- | Primitive の constructor 名 (count 集計キー)。
ctorName :: Primitive -> String
ctorName p = case p of
PLine{} -> "PLine"
PRect{} -> "PRect"
PCircle{} -> "PCircle"
PPath{} -> "PPath"
PText{} -> "PText"
PClipPush{} -> "PClipPush"
PClipPath{} -> "PClipPath"
PClipPop -> "PClipPop"
PTransformPush{} -> "PTransformPush"
PTransformPop -> "PTransformPop"