// // DTATests.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // import Foundation import Testing import ZQData /// Native .dta reader/writer tests: haven-written fixtures (formats 117 /// and 118) must load identically to the CSV, and a write/read roundtrip /// must be lossless. All loads go through the public ZQDataStore path. @Suite("Stata .dta reader/writer") struct DTATests { let store: ZQDataStore let csvFrame: ZQDataFrame let fixturesDirectory: URL init() async throws { let fixtures = try Fixtures() self.store = try ZQDataStore() self.csvFrame = try await store.load(contentsOf: fixtures.datasetURL) self.fixturesDirectory = fixtures.datasetURL.deletingLastPathComponent() } private func expectMatchesCSV(_ frame: ZQDataFrame) throws { #expect(frame.rowCount == 60) #expect(frame.columnNames == [ "revenue", "price", "region", "firm_id", "purchase", "orders", "z1", "z2", "firm_name", ]) // Numeric columns: bit-identical to the CSV load (both sides parse // the same decimal text into the nearest double). for name in csvFrame.columnNames { let expected = try csvFrame.requireNumeric(name) let actual = try frame.requireNumeric(name) #expect(actual.missing == expected.missing, "missing mask of \(name)") for i in 0..<60 where !expected.missing[i] { #expect(actual.values[i] == expected.values[i], "\(name)[\(i)]") } } // String column: haven wrote firm_, with row 5 blank (missing). guard case .string(let names)? = frame.column(named: "firm_name")?.data else { Issue.record("firm_name is not a string column") return } #expect(names[0] == "firm_1") #expect(names[4] == nil) // "" is Stata's string missing #expect(names[59] == "firm_12") } @Test("reads haven-written format 118") func readsFormat118() async throws { let url = fixturesDirectory.appendingPathComponent("regression_v118.dta") try expectMatchesCSV(try await store.load(contentsOf: url)) } @Test("reads haven-written format 117") func readsFormat117() async throws { let url = fixturesDirectory.appendingPathComponent("regression_v117.dta") try expectMatchesCSV(try await store.load(contentsOf: url)) } @Test("write/read roundtrip is lossless") func roundtrip() async throws { let url = fixturesDirectory.appendingPathComponent("regression_v118.dta") let original = try await store.load(contentsOf: url) let out = FileManager.default.temporaryDirectory .appendingPathComponent("metrika_roundtrip_\(UUID().uuidString).dta") defer { try? FileManager.default.removeItem(at: out) } try await store.save(original, to: out) let reloaded = try await store.load(contentsOf: out) #expect(reloaded == original) } @Test("rejects pre-117 files with a clear message") func rejectsOldFormats() async throws { // Format 115 files start with the raw version byte 0x73. let url = try temporaryFile(Data([0x73, 0x02, 0x01, 0x00])) defer { try? FileManager.default.removeItem(at: url) } do { _ = try await store.load(contentsOf: url) Issue.record("expected a format error") } catch { #expect("\(error)".contains("older than 117")) } } @Test("rejects non-dta bytes") func rejectsGarbage() async throws { let url = try temporaryFile(Data("not a dta file at all".utf8)) defer { try? FileManager.default.removeItem(at: url) } await #expect(throws: (any Error).self) { _ = try await store.load(contentsOf: url) } } private func temporaryFile(_ data: Data) throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("metrika_dta_\(UUID().uuidString).dta") try data.write(to: url) return url } }