phino-0.0.114: test/LaTeXSpec.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
-- SPDX-FileCopyrightText: Copyright (c) 2025 Objectionary.com
-- SPDX-License-Identifier: MIT
{- | Tests for the LaTeX module that provides conversion of phi-calculus
expressions and rules to LaTeX format for academic documents.
-}
module LaTeXSpec where
import AST (Attribute (AtLabel, AtPhi, AtRho), Binding (BiTau, BiVoid), Bytes (BtOne), Expression (ExFormation, ExMeta, ExPhiAgain, ExPhiMeet, ExRoot))
import Control.Monad (forM_)
import Data.List (intercalate)
import Data.Text qualified as T
import LaTeX
( LatexContext (..)
, conditionToLatex
, defaultLatexContext
, explainContextualizeRules
, explainDataizeRules
, explainMorphRules
, explainRules
, expressionToLaTeX
, meetInExpression
, meetInExpressions
, rewrittensToLatex
)
import Lining (LineFormat (MULTILINE))
import Parser (parseExpressionThrows)
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe, shouldContain)
import Yaml qualified as Y
spec :: Spec
spec = do
describe "meet expression in expression" $
forM_
[ ("Q.x.y", "Q.x.y", "[[ x -> Q.x.y ]]", ["Q.x.y"])
, ("Q.x.y twice", "Q.x.y", "[[ x -> Q.x.y, y -> Q.x.y.z ]]", ["Q.x.y", "Q.x.y"])
, ("Q.x.y.z.a and Q.x.y", "Q.x.y.z.a", "[[ x -> Q.x.y, y -> Q.x.y.z ]]", ["Q.x.y.z", "Q.x.y", "Q.x.y"])
, ("Ignore data objects", "[[ x -> \"foo\" ]]", "Q.x( y -> \"foo\" )", [])
, ("Not found [[ t -> 42 ]]", "⟦ ex ↦ ⟦ x ↦ ⟦ t ↦ 42 ⟧.t ⟧.x ⟧", "⟦ ex ↦ ⟦ x ↦ 42 ⟧.x ⟧", [])
, ("Missed [[ t -> 42 ]]", "⟦ ex ↦ ⟦ x ↦ ⟦ t ↦ 42 ⟧.t ⟧.x ⟧", "⟦ ex ↦ 42 ⟧", [])
]
( \(desc, first, second, exprs) -> it desc $ do
ptn <- parseExpressionThrows first
tgt <- parseExpressionThrows second
res <- traverse parseExpressionThrows exprs
meetInExpression ptn 4 tgt `shouldBe` res
)
describe "meets several sub-expressions in a single step" $
-- A step routinely carries several independent recurring sub-expressions.
-- The first step here holds two distinct recurring formations
-- ([[ p -> Q.a ]] and [[ q -> Q.b ]]); both must be factored, so the first
-- rendered step ends up with two \phinoMeet{}s, not just the single most
-- frequent one (see #976).
it "factors every recurring sub-expression, not only one" $ do
let step :: String -> String
step lastAttr = "[[ r -> [[ p -> Q.a ]], s -> [[ q -> Q.b ]], tag -> Q." <> lastAttr <> " ]]"
exprs <- traverse parseExpressionThrows [step "one", step "two", step "three"]
let ctx = defaultLatexContext{_compress = True, _meetLength = 6, _meetPopularity = 1}
case meetInExpressions exprs ctx of
(firstStep : _) -> T.count "ExPhiMeet" (T.pack (show firstStep)) `shouldBe` 2
[] -> expectationFailure "meetInExpressions returned no expressions"
describe "indents wrapped continuation steps in a --sequence (#981)" $
it "nests a wrapped step's members below its two-space \\leadsto line and aligns the closing bracket with it, rather than laying the step out from column 0" $ do
start <- parseExpressionThrows "[[ x -> Q.y ]]"
wrapped <- parseExpressionThrows "[[ a -> Q.b, c -> Q.d ]]"
let ctx = defaultLatexContext{_line = MULTILINE, _margin = 20}
latex <- rewrittensToLatex ([(start, Just "first"), (wrapped, Just "second")], False) ctx
latex
`shouldContain` intercalate
"\n"
[ " \\leadsto [["
, " |a| -> Q . |b|,"
, " |c| -> Q . |d|"
, " ]] \\leadsto_{\\nameref{r:second}}"
]
describe "renders the 'formation' condition" $
forM_
[ ("formation", Y.IsFormation (ExMeta "n"), "{ \\phinoIsFormation{ n } }")
, ("not formation", Y.Not (Y.IsFormation (ExMeta "n")), "{ \\phinoNotFormation{ n } }")
, ("empty (And [])", Y.And [], "{ }")
, ("empty (Or [])", Y.Or [], "{ }")
, ("normal form", Y.NF (ExMeta "n"), "{ \\isnormal{ n } }")
, ("matches", Y.Matches "abc" (ExMeta "n"), "{ matches\\lparen abc, n \\rparen }")
, ("part-of", Y.PartOf (ExMeta "n") (BiVoid AtRho), "{ part-of\\lparen n, \\phiTerminal{\\rho} -> ? \\rparen }")
, ("compare equal", Y.Eq (Y.CmpAttr AtRho) (Y.CmpAttr AtPhi), "{ \\phiTerminal{\\rho} = @ }")
, ("compare greater", Y.Gt (Y.CmpNum (Y.Literal 3)) (Y.CmpNum (Y.Literal 4)), "{ 3 > 4 }")
, ("not normal form", Y.Not (Y.NF (ExMeta "n")), "{ not\\lparen \\isnormal{ n } \\rparen }")
, ("disjoint", Y.Disjoint [AtRho] [BiVoid AtRho], "{ [ \\phiTerminal{\\rho} ] \\cap \\phiTerminal{\\rho} -> ? = \\emptyset }")
, ("absolute", Y.Absolute (ExMeta "n"), "{ \\phinoAbsolute{ n } }")
]
(\(desc, cond, expected) -> it desc (conditionToLatex (Just cond) `shouldBe` expected))
describe "expressionToLaTeX" $ do
forM_
[
( "renders '\\phiquation*' (unnumbered) when '_nonumber' is set"
, \ctx -> ctx{_nonumber = True}
, "\\begin{phiquation*}\n[[ |x| -> Q . |y| ]]{.}\n\\end{phiquation*}"
)
,
( "renders a '\\label{}' when '_label' is set"
, \ctx -> ctx{_label = Just "eq:one"}
, "\\begin{phiquation}\n\\label{eq:one}\n[[ |x| -> Q . |y| ]]{.}\n\\end{phiquation}"
)
,
( "renders a '\\phiExpression{}' prefix when '_expression' is set"
, \ctx -> ctx{_expression = Just "e"}
, "\\begin{phiquation}\n\\phiExpression{e} [[ |x| -> Q . |y| ]]{.}\n\\end{phiquation}"
)
]
( \(desc, adjustContext, expected) -> it desc $ do
expr <- parseExpressionThrows "[[ x -> Q.y ]]"
expressionToLaTeX expr (adjustContext defaultLatexContext) `shouldBe` expected
)
it "escapes '@' and '^' in an attribute label, same as '$' and '_'" $ do
let weird = ExFormation [BiTau (AtLabel "a@b^c") ExRoot, BiVoid AtRho]
expressionToLaTeX weird defaultLatexContext
`shouldBe` "\\begin{phiquation}\n[[ |a\\char64{}b\\char94{}c| -> Q ]]{.}\n\\end{phiquation}"
forM_
[
( "renders a \\phinoMeet{} marker with its prefix"
, ExPhiMeet (Just "pfx") 2
, "\\begin{phiquation}\n\\phinoMeet{pfx:2}{ Q . |y| }{.}\n\\end{phiquation}"
)
,
( "renders a \\phinoAgain{} marker without a prefix"
, ExPhiAgain Nothing 3
, "\\begin{phiquation}\n\\phinoAgain{3}{.}\n\\end{phiquation}"
)
]
( \(desc, wrapInner, expected) -> it desc $ do
inner <- parseExpressionThrows "Q.y"
expressionToLaTeX (wrapInner inner) defaultLatexContext `shouldBe` expected
)
describe "rewrittensToLatex" $ do
it "renders the ellipsis ending when the chain exceeded its bound" $ do
step1 <- parseExpressionThrows "[[ x -> Q.y ]]"
latex <- rewrittensToLatex ([(step1, Nothing)], True) defaultLatexContext
latex `shouldBe` "\\begin{phiquation}\n[[ |x| -> Q . |y| ]] \\leadsto\n \\leadsto \\dots\n\\end{phiquation}"
it "prefixes each step with a '% === Step' header when '_headers' is set" $ do
step1 <- parseExpressionThrows "[[ x -> Q.y ]]"
step2 <- parseExpressionThrows "[[ x -> Q.z ]]"
latex <- rewrittensToLatex ([(step1, Nothing), (step2, Just "myrule")], False) defaultLatexContext{_headers = True}
latex
`shouldBe` intercalate
"\n"
[ "\\begin{phiquation}"
, "% === Step #1"
, "[[ |x| -> Q . |y| ]]"
, "% === Step #2, Rule '?', 11t -> 11t"
, " \\leadsto [[ |x| -> Q . |z| ]] \\leadsto_{\\nameref{r:myrule}}{.}"
, "\\end{phiquation}"
]
it "locates the focused sub-expression at every step" $ do
step1 <- parseExpressionThrows "[[ x -> Q.aaa.bbb.ccc.ddd ]]"
step2 <- parseExpressionThrows "[[ x -> Q.aaa.bbb.ccc.ddd.eee ]]"
focus <- parseExpressionThrows "Q.x"
latex <- rewrittensToLatex ([(step1, Nothing), (step2, Just "r")], False) defaultLatexContext{_focus = focus}
latex
`shouldBe` intercalate
"\n"
[ "\\begin{phiquation}"
, "Q . |aaa| . |bbb| . |ccc| . |ddd|"
, " \\leadsto Q . |aaa| . |bbb| . |ccc| . |ddd| . |eee| \\leadsto_{\\nameref{r:r}}{.}"
, "\\end{phiquation}"
]
it "compresses and canonizes a --sequence of full expressions above the default meet threshold" $ do
step1 <- parseExpressionThrows "[[ x -> Q.a.b.c.d ]]"
step2 <- parseExpressionThrows "[[ y -> Q.a.b.c.d ]]"
step3 <- parseExpressionThrows "[[ z -> Q.a.b.c.d ]]"
latex <-
rewrittensToLatex
([(step1, Nothing), (step2, Just "r1"), (step3, Just "r2")], False)
defaultLatexContext{_compress = True, _canonize = True}
latex
`shouldBe` intercalate
"\n"
[ "\\begin{phiquation}"
, "[[ |x| -> \\phinoMeet{1}{ Q . |a| . |b| . |c| . |d| } ]]"
, " \\leadsto [[ |y| -> \\phinoAgain{1} ]] \\leadsto_{\\nameref{r:r1}}"
, " \\leadsto [[ |z| -> \\phinoAgain{1} ]] \\leadsto_{\\nameref{r:r2}}{.}"
, "\\end{phiquation}"
]
it "compresses and canonizes a --sequence of focused sub-expressions above the default meet threshold" $ do
focus <- parseExpressionThrows "Q.x"
step1 <- parseExpressionThrows "[[ x -> [[ w -> Q.a.b.c.d ]] ]]"
step2 <- parseExpressionThrows "[[ x -> [[ w -> Q.a.b.c.d ]] ]]"
step3 <- parseExpressionThrows "[[ x -> [[ w -> Q.a.b.c.d ]] ]]"
latex <-
rewrittensToLatex
([(step1, Nothing), (step2, Just "r1"), (step3, Just "r2")], False)
defaultLatexContext{_focus = focus, _compress = True, _canonize = True}
latex
`shouldBe` intercalate
"\n"
[ "\\begin{phiquation}"
, "\\phinoMeet{1}{ [[ |w| -> Q . |a| . |b| . |c| . |d| ]] }"
, " \\leadsto \\phinoAgain{1} \\leadsto_{\\nameref{r:r1}}"
, " \\leadsto \\phinoAgain{1} \\leadsto_{\\nameref{r:r2}}{.}"
, "\\end{phiquation}"
]
describe "meetInExpressions" $ do
it "returns an empty list for an empty sequence" $
meetInExpressions [] defaultLatexContext `shouldBe` []
it "finds a meet using the default meet length and popularity, without overriding them" $ do
step1 <- parseExpressionThrows "[[ x -> Q.a.b.c.d ]]"
step2 <- parseExpressionThrows "[[ y -> Q.a.b.c.d ]]"
step3 <- parseExpressionThrows "[[ z -> Q.a.b.c.d ]]"
let compressed = meetInExpressions [step1, step2, step3] defaultLatexContext{_compress = True}
length compressed `shouldBe` 3
case compressed of
(firstStep : _) -> T.count "ExPhiMeet" (T.pack (show firstStep)) `shouldBe` 1
[] -> expectationFailure "meetInExpressions returned no expressions"
describe "explainRules" $
forM_
[
( "renders a rule's label, combined 'when'/'having' condition and 'where' extras"
, Y.Rule
{ name = "myrule"
, label = Just "disp"
, description = Nothing
, pattern = ExMeta "n"
, result = ExMeta "n"
, when = Just (Y.NF (ExMeta "n"))
, having = Just (Y.IsFormation (ExMeta "n"))
, where_ =
Just
[ Y.Extra
{ meta = Y.ArgAttribute AtRho
, function = "foo"
, args = [Y.ArgExpression (ExMeta "n"), Y.ArgBinding (BiVoid AtRho), Y.ArgBytes (BtOne "01-02")]
}
, Y.Extra
{ meta = Y.ArgAttribute AtPhi
, function = "bar"
, args = [Y.ArgExpression (ExMeta "n")]
}
]
}
,
[ "\\phinoNormalizationRule[disp]{myrule}"
, "{ n }"
, "{ n }"
, "{ \\isnormal{ n } \\;\\text{and}\\; \\phinoIsFormation{ n } }"
, "{ \\phiTerminal{\\rho} \\coloneqq \\foo{ n, \\phiTerminal{\\rho} -> ?, 01-02- } and @ \\coloneqq \\bar{ n } }"
]
)
,
( "omits the label and the 'if'/'where' clauses when they are absent"
, Y.Rule
{ name = "myrule2"
, label = Nothing
, description = Nothing
, pattern = ExMeta "n"
, result = ExMeta "n"
, when = Nothing
, having = Nothing
, where_ = Nothing
}
,
[ "\\phinoNormalizationRule{myrule2}"
, "{ n }"
, "{ n }"
, "{ }"
, "{ }"
]
)
,
( "keeps just the 'having' condition when 'when' is absent"
, Y.Rule
{ name = "myrule3"
, label = Nothing
, description = Nothing
, pattern = ExMeta "n"
, result = ExMeta "n"
, when = Nothing
, having = Just (Y.IsFormation (ExMeta "n"))
, where_ = Nothing
}
,
[ "\\phinoNormalizationRule{myrule3}"
, "{ n }"
, "{ n }"
, "{ \\phinoIsFormation{ n } }"
, "{ }"
]
)
]
(\(desc, rule, expectedLines) -> it desc (explainRules [rule] `shouldBe` intercalate "\n " expectedLines))
describe "explainMorphRules" $
it "threads the state through every premise operation (morph, normalize, evaluate, contextualize, dataize)" $ do
let rule =
Y.MorphRule
{ name = "morph1"
, label = Just "mlbl"
, match = ExMeta "n"
, ematch = ExMeta "e"
, nresult = ExMeta "n1"
, when = Just (Y.NF (ExMeta "n"))
, premises =
[ Y.Premise{result = "n1", operation = Y.OpMorph (ExMeta "n")}
, Y.Premise{result = "n2", operation = Y.OpNormalize (ExMeta "n1")}
, Y.Premise{result = "n3", operation = Y.OpEvaluate (ExMeta "n2") (ExMeta "e")}
, Y.Premise{result = "n4", operation = Y.OpContextualize (ExMeta "n3") (ExMeta "e")}
, Y.Premise{result = "n5", operation = Y.OpDataize (ExMeta "n4")}
]
}
explainMorphRules [rule]
`shouldBe` intercalate
"\n"
[ "\\begin{phinoMorphingInference}"
, " \\phinoName{morph1}"
, " \\phinoLabel{mlbl}"
, " \\phinoCondition{ \\isnormal{ n } }"
, " \\phinoPremise{ \\phinoMorph{ n }{ e }{ s_1 }{ n_1 }{ s_2 } }"
, " \\phinoPremise{ \\phinoNormalize{ n_1 }{ n_2 } }"
, " \\phinoPremise{ \\phinoEvaluate{ n_2 }{ e }{ s_2 }{ n_3 }{ s_3 } }"
, " \\phinoPremise{ \\phinoContextualize{ n_3 }{ e }{ n_4 } }"
, " \\phinoPremise{ \\phinoDataize{ n_4 }{ e }{ s_3 }{ \\delta_5 }{ s_4 } }"
, " \\phinoConclusion{ \\phinoMorph{ n }{ e }{ s_1 }{ n_1 }{ s_4 } }"
, "\\end{phinoMorphingInference}"
]
describe "explainDataizeRules" $
it "renders a bare 's' in the conclusion and omits an empty 'when' condition" $ do
let rule =
Y.DataizeRule
{ name = "dataize1"
, label = Nothing
, match = ExMeta "n"
, ematch = ExMeta "e"
, dresult = BtOne "05"
, when = Just (Y.And [])
, premises = []
}
explainDataizeRules [rule]
`shouldBe` intercalate
"\n"
[ "\\begin{phinoDataizationInference}"
, " \\phinoName{dataize1}"
, " \\phinoConclusion{ \\phinoDataize{ n }{ e }{ s }{ |05-| }{ s } }"
, "\\end{phinoDataizationInference}"
]
describe "explainContextualizeRules" $
it "threads a morph premise through the rule's own 'e' universe" $ do
let rule =
Y.ContextualizeRule
{ name = "ctx1"
, label = Nothing
, match = ExMeta "n"
, cmatch = ExMeta "c"
, cresult = ExMeta "n1"
, premises = [Y.Premise{result = "n1", operation = Y.OpMorph (ExMeta "n")}]
}
explainContextualizeRules [rule]
`shouldBe` intercalate
"\n"
[ "\\begin{phinoContextualizationInference}"
, " \\phinoName{ctx1}"
, " \\phinoPremise{ \\phinoMorph{ n }{ e }{ s_1 }{ n_1 }{ s_2 } }"
, " \\phinoConclusion{ \\phinoContextualize{ n }{ e }{ n_1 } }"
, "\\end{phinoContextualizationInference}"
]