Skip to content

Commit 4bebda3

Browse files
test: port export.test.ts + privacy.test.ts → Idris2 (4/4 files; estate port 6/11 complete) (#56)
Completes the partial port from #55 — ubicity now has all 4 TS test files mirrored in Idris2 (18 tests total, 0 failed locally). ## What's new * **ExportTest.idr** — 5/5 PASS (CSV / GeoJSON struct / DOT graph / Markdown / CSV escaping). All pure string formatting; no SUT bindings required. The Markdown test substitutes the ISO timestamp for the TS `toLocaleDateString()` because the TS test never asserts on the date format itself. * **PrivacyTest.idr** — 5/6 PASS (1 deferred for crypto) - Location fuzzing (Double rounding) - PII removal — substring find/replace rather than regex; preserves the TS invariants (verifies the email/phone tokens are gone + replacements present) - Privacy-level enforcement (`Private | Anonymous | Public` sum type) - Data minimization (record projection) - Shareable-dataset filter - **Deferred**: SHA-256 learner-ID anonymization — same constraint as the two CoreTest deferred cases. Idris2 base lacks crypto. * **Main.idr** — aggregates all 4 suites * **ubicity-tests.ipkg** — registers ExportTest + PrivacyTest ## Local run (Idris2 0.8.0, Chez codegen) \`\`\` === CoreTest === 3 passed, 0 failed === MapperTest === 5 passed, 0 failed === ExportTest === 5 passed, 0 failed === PrivacyTest === 5 passed, 0 failed === Total: 18 passed, 0 failed === \`\`\` ## Estate-rollout impact ubicity moves from "partial — 1 of 4 TS files" → "ports complete — all 4 TS files mirrored, 3 sub-cases deferred for crypto". Each deferred case is documented inline in its module with the precise constraint (crypto / JSON parse). ## Process note Originally drafted on top of `feat/port-core-tests-to-idris2` (PR #55) before that merged. After #55 squash-landed I cherry-picked just the new commit onto a fresh branch off main — avoids the GitHub stacked-PR orphan trap documented in [the auto-memory](https://github.com/hyperpolymath/repos-monorepo/blob/main/panic-free-tests-and-benches/clade-registry/ESTATE-ROLLOUT.adoc). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d1c150a commit 4bebda3

4 files changed

Lines changed: 510 additions & 4 deletions

File tree

tests/idris2/ExportTest.idr

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
-- SPDX-License-Identifier: PMPL-1.0-or-later
2+
-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
--
4+
-- Port of tests/export.test.ts to Idris2, estate-rollout port 6/11.
5+
-- 5 of 5 tests ported. All exports are pure string-formatting (CSV,
6+
-- GeoJSON struct, DOT, Markdown, CSV escaping). The TS suite builds
7+
-- its inline data fresh per test, so no SUT bindings are required.
8+
--
9+
-- Note: the TS Markdown test uses `new Date().toLocaleDateString()`
10+
-- but only asserts on Type/Domains/description (not the date), so we
11+
-- substitute a deterministic ISO date string with no test-coverage loss.
12+
13+
module ExportTest
14+
15+
import Test.Spec
16+
import Data.String
17+
import Data.List
18+
19+
%default covering
20+
21+
-- == Lightweight experience record ==
22+
-- Single shape covering all 5 export shapes. Fields unused per test
23+
-- default to "" / 0.0 / [].
24+
25+
record EExp where
26+
constructor MkEExp
27+
eId : String
28+
eTimestamp : String
29+
eLearnerId : String
30+
eLocName : String
31+
eLat : Double
32+
eLon : Double
33+
eType : String
34+
eDomains : List String
35+
eDescription : String
36+
37+
-- == CSV ==
38+
39+
csvHeaders : String
40+
csvHeaders = "id,timestamp,learner_id,location,type,domains,description"
41+
42+
csvRow : EExp -> String
43+
csvRow e =
44+
e.eId ++ "," ++
45+
e.eTimestamp ++ "," ++
46+
e.eLearnerId ++ "," ++
47+
e.eLocName ++ "," ++
48+
e.eType ++ "," ++
49+
joinWith ";" e.eDomains ++ "," ++
50+
e.eDescription
51+
52+
where
53+
joinWith : String -> List String -> String
54+
joinWith _ [] = ""
55+
joinWith _ [x] = x
56+
joinWith sep (x :: xs) = x ++ sep ++ joinWith sep xs
57+
58+
csvOf : List EExp -> String
59+
csvOf xs = csvHeaders ++ "\n" ++ rowsJoined
60+
where
61+
rowsJoined : String
62+
rowsJoined = case map csvRow xs of
63+
[] => ""
64+
(r :: rs) => foldl (\acc, r2 => acc ++ "\n" ++ r2) r rs
65+
66+
-- == GeoJSON (struct-only, no JSON serialization) ==
67+
-- The TS test builds an object literal and asserts on its shape, NOT
68+
-- on serialized text. Mirror that with an Idris2 record + assertions.
69+
70+
record GeoPoint where
71+
constructor MkGeoPoint
72+
pId : String
73+
pName : String
74+
pType : String
75+
pLat : Double
76+
pLon : Double
77+
78+
geojsonFeatures : List EExp -> List GeoPoint
79+
geojsonFeatures =
80+
map (\e => MkGeoPoint e.eId e.eLocName e.eType e.eLat e.eLon)
81+
82+
-- == DOT graph ==
83+
84+
dotOf : List (String, List String) -> String
85+
dotOf connections =
86+
let header = "graph LearningNetwork {\n"
87+
edges = concatMap edgesFrom connections
88+
footer = "}"
89+
in header ++ edges ++ footer
90+
where
91+
edgesFrom : (String, List String) -> String
92+
edgesFrom (from, tos) =
93+
foldl (\acc, to => acc ++ " \"" ++ from ++ "\" -- \"" ++ to ++ "\";\n") "" tos
94+
95+
-- == Markdown ==
96+
-- TS uses toLocaleDateString() but never asserts on it. We pass through
97+
-- the ISO timestamp directly — covers the same invariants.
98+
99+
mdEntry : EExp -> String
100+
mdEntry e =
101+
"## " ++ e.eTimestamp ++ "\n\n" ++
102+
"**Type**: " ++ e.eType ++ "\n\n" ++
103+
"**Domains**: " ++ joinComma e.eDomains ++ "\n\n" ++
104+
e.eDescription ++ "\n\n" ++
105+
"---\n\n"
106+
where
107+
joinComma : List String -> String
108+
joinComma [] = ""
109+
joinComma [x] = x
110+
joinComma (x :: xs) = x ++ ", " ++ joinComma xs
111+
112+
mdOf : List EExp -> String
113+
mdOf xs = "# Learner Journey\n\n" ++ concatMap mdEntry xs
114+
115+
-- == CSV escaping ==
116+
117+
-- Replace every `"` with `""`, then wrap whole string in `"`.
118+
csvEscape : String -> String
119+
csvEscape s =
120+
let doubled = pack (escapeChars (unpack s))
121+
in "\"" ++ doubled ++ "\""
122+
where
123+
escapeChars : List Char -> List Char
124+
escapeChars [] = []
125+
escapeChars (c :: cs) =
126+
if c == '"'
127+
then '"' :: '"' :: escapeChars cs
128+
else c :: escapeChars cs
129+
130+
-- == Test fixtures ==
131+
132+
csvSample : EExp
133+
csvSample = MkEExp
134+
"exp-001"
135+
"2025-01-01T10:00:00Z"
136+
"alice"
137+
"Makerspace A"
138+
0.0
139+
0.0
140+
"workshop"
141+
["electronics"]
142+
"Built LED circuit"
143+
144+
geoSample : EExp
145+
geoSample = MkEExp
146+
"exp-001"
147+
""
148+
""
149+
"Makerspace A"
150+
37.77
151+
(-122.42)
152+
"workshop"
153+
[]
154+
""
155+
156+
dotSample : List (String, List String)
157+
dotSample =
158+
[ ("electronics", ["art", "robotics"])
159+
, ("art", ["sculpture"])
160+
]
161+
162+
mdJourney : List EExp
163+
mdJourney =
164+
[ MkEExp "1" "2025-01-01T10:00:00Z" "" "" 0.0 0.0
165+
"workshop" ["electronics"] "First workshop"
166+
, MkEExp "2" "2025-01-05T14:00:00Z" "" "" 0.0 0.0
167+
"project" ["electronics", "art"] "Built sculpture"
168+
]
169+
170+
-- == Tests ==
171+
172+
public export
173+
allSuites : List TestCase
174+
allSuites =
175+
[ test "Export - CSV format generation" $ do
176+
let csv = csvOf [csvSample]
177+
allPass
178+
[ assertTrue "csv contains header" (isInfixOf "id,timestamp,learner_id" csv)
179+
, assertTrue "csv contains row data" (isInfixOf "exp-001,2025-01-01T10:00:00Z" csv)
180+
, assertTrue "csv contains domain" (isInfixOf "electronics" csv)
181+
]
182+
183+
, test "Export - GeoJSON struct generation" $ do
184+
let features = geojsonFeatures [geoSample]
185+
let first = case features of
186+
(h :: _) => h
187+
[] => MkGeoPoint "" "" "" 0.0 0.0
188+
allPass
189+
[ assertTrue "1 feature" (length features == 1)
190+
, assertTrue "feature id matches" (first.pId == "exp-001")
191+
, assertTrue "feature name matches" (first.pName == "Makerspace A")
192+
, assertTrue "feature lat preserved" (first.pLat == 37.77)
193+
, assertTrue "feature lon preserved" (first.pLon == -122.42)
194+
]
195+
196+
, test "Export - DOT graph format generation" $ do
197+
let dot = dotOf dotSample
198+
allPass
199+
[ assertTrue "header present" (isInfixOf "graph LearningNetwork" dot)
200+
, assertTrue "electronics--art edge" (isInfixOf "\"electronics\" -- \"art\"" dot)
201+
, assertTrue "electronics--robotics edge" (isInfixOf "\"electronics\" -- \"robotics\"" dot)
202+
, assertTrue "art--sculpture edge" (isInfixOf "\"art\" -- \"sculpture\"" dot)
203+
]
204+
205+
, test "Export - Markdown format generation" $ do
206+
let md = mdOf mdJourney
207+
allPass
208+
[ assertTrue "title present" (isInfixOf "# Learner Journey" md)
209+
, assertTrue "Type: workshop" (isInfixOf "**Type**: workshop" md)
210+
, assertTrue "Domains: electronics, art" (isInfixOf "**Domains**: electronics, art" md)
211+
, assertTrue "description present" (isInfixOf "Built sculpture" md)
212+
]
213+
214+
, test "Export - CSV escaping special characters" $ do
215+
let text = "Description with \"quotes\" and, commas"
216+
let escaped = csvEscape text
217+
let expected = "\"Description with \"\"quotes\"\" and, commas\""
218+
allPass
219+
[ assertEq escaped expected
220+
, assertTrue "starts with quote" (isPrefixOf "\"" escaped)
221+
, assertTrue "ends with quote" (isSuffixOf "\"" escaped)
222+
]
223+
]

tests/idris2/Main.idr

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ module Main
66
import Test.Spec
77
import CoreTest
88
import MapperTest
9+
import ExportTest
10+
import PrivacyTest
911
import System
1012

1113
%default covering
@@ -14,8 +16,10 @@ main : IO ()
1416
main = do
1517
(p1, f1) <- runTestSuite "CoreTest" CoreTest.allSuites
1618
(p2, f2) <- runTestSuite "MapperTest" MapperTest.allSuites
17-
let totalPassed = p1 + p2
18-
let totalFailed = f1 + f2
19+
(p3, f3) <- runTestSuite "ExportTest" ExportTest.allSuites
20+
(p4, f4) <- runTestSuite "PrivacyTest" PrivacyTest.allSuites
21+
let totalPassed = p1 + p2 + p3 + p4
22+
let totalFailed = f1 + f2 + f3 + f4
1923
putStrLn ""
2024
putStrLn $ "=== Total: " ++ show totalPassed ++ " passed, " ++ show totalFailed ++ " failed ==="
2125
if totalFailed > 0

0 commit comments

Comments
 (0)