diff --git a/pycdfpp/__init__.py b/pycdfpp/__init__.py index ffe21602..6e52bc9f 100644 --- a/pycdfpp/__init__.py +++ b/pycdfpp/__init__.py @@ -823,6 +823,30 @@ def _(attribute: Attribute) -> dict: } +@to_dict_skeleton.register(VariableAttribute) +def _(attribute: VariableAttribute) -> dict: + """ + to_dict_skeleton builds a dictionary skeleton of the VariableAttribute object for use with json.dumps or similar functions. + + A variable attribute holds a single entry, so its skeleton uses the same + shape as a global Attribute with a single-element values/types list. + + Parameters + ---------- + attribute: VariableAttribute + input VariableAttribute object + + Returns + ------- + dict + dictionary skeleton of the VariableAttribute + """ + return { + "values": [_stringify_time_values(attribute.value, attribute.type())], + "types": [str(attribute.type())], + } + + @to_dict_skeleton.register(Variable) def _(variable: Variable) -> dict: """ diff --git a/tests/meson.build b/tests/meson.build index 5b1e6cde..5f025440 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -79,4 +79,8 @@ if node.found() args: [files('wacdfpp_diff/test.mjs')], timeout: 60, ) + test('wacdfpp_yaml', node, + args: [files('wacdfpp_yaml/test.mjs')], + timeout: 60, + ) endif diff --git a/tests/python_skeletons/test.py b/tests/python_skeletons/test.py index ede1bdca..97644601 100755 --- a/tests/python_skeletons/test.py +++ b/tests/python_skeletons/test.py @@ -26,6 +26,16 @@ def test_can_extrac_skeleton(self): self.assertIsNotNone(skel) self.assertEqual(type(skel["attributes"]["tt2000_special_values"]["values"][0][0]), str) + def test_variable_attributes_are_serialized(self): + cdf = make_cdf() + skel = pycdfpp.to_dict_skeleton(cdf) + attrs = skel["variables"]["test_variable"]["attributes"] + self.assertIsNotNone(attrs["attr1"]) + self.assertEqual(attrs["attr1"]["values"], [[1, 2, 3]]) + self.assertEqual(len(attrs["attr1"]["types"]), 1) + # time-valued variable attributes must be stringified like global ones + self.assertEqual(type(attrs["attr2"]["values"][0][0]), str) + if __name__ == '__main__': unittest.main() diff --git a/tests/wacdfpp_yaml/test.mjs b/tests/wacdfpp_yaml/test.mjs new file mode 100644 index 00000000..2b29085e --- /dev/null +++ b/tests/wacdfpp_yaml/test.mjs @@ -0,0 +1,93 @@ +// Pure Node test for wacdfpp/cdf-yaml.js — no WASM required. +// node test.mjs +import { toYaml, buildSkeleton, CDF_TYPE_NAMES } from "../../wacdfpp/cdf-yaml.js"; + +let failures = 0; +function check(name, ok) { + if (ok) console.log(`ok ${name}`); + else { console.error(`FAIL ${name}`); failures += 1; } +} +const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +// --- toYaml: scalars ---------------------------------------------------- +check("string plain", toYaml({ a: "hello" }) === "a: hello\n"); +check("string with space stays plain", toYaml({ a: "hello world" }) === "a: hello world\n"); +check("number-like string quoted", toYaml({ a: "123" }) === 'a: "123"\n'); +check("colon string quoted", toYaml({ a: "x: y" }) === 'a: "x: y"\n'); +check("comma string quoted", toYaml({ a: "a,b" }) === 'a: "a,b"\n'); +check("empty string quoted", toYaml({ a: "" }) === 'a: ""\n'); +check("newline string escaped", toYaml({ a: "x\ny" }) === 'a: "x\\ny"\n'); +check("reserved word quoted", toYaml({ a: "true" }) === 'a: "true"\n'); +check("bool", toYaml({ a: true }) === "a: true\n"); +check("null", toYaml({ a: null }) === "a: null\n"); +check("integer", toYaml({ a: 42 }) === "a: 42\n"); +check("nan -> .nan", toYaml({ a: Number.NaN }) === "a: .nan\n"); +check("inf -> .inf", toYaml({ a: Infinity }) === "a: .inf\n"); +check("-inf -> -.inf", toYaml({ a: -Infinity }) === "a: -.inf\n"); + +// --- toYaml: arrays ----------------------------------------------------- +check("scalar array -> flow", toYaml({ a: [1, 2, 3] }) === "a: [1, 2, 3]\n"); +check("single-scalar array -> flow", toYaml({ a: ["mV"] }) === "a: [mV]\n"); +check("empty array -> []", toYaml({ a: [] }) === "a: []\n"); +check("typed array -> flow", toYaml({ a: new Float64Array([1, 2]) }) === "a: [1, 2]\n"); +check("array-of-array -> block", + toYaml({ a: [[1, 2], "x"] }) === "a:\n - [1, 2]\n - x\n"); + +// --- toYaml: maps ------------------------------------------------------- +check("nested map", toYaml({ a: { b: 1 } }) === "a:\n b: 1\n"); +check("empty map -> {}", toYaml({ a: {} }) === "a: {}\n"); + +// --- toYaml: escaping must produce round-trippable YAML ----------------- +// (keys are emitted verbatim historically; values with leading YAML +// indicators or YAML-1.1 number forms must be quoted or they re-parse wrong.) +check("special key quoted", toYaml({ "min: max": 1 }) === '"min: max": 1\n'); +check("leading-dash value quoted", toYaml({ a: "- pending" }) === 'a: "- pending"\n'); +check("leading-question value quoted", toYaml({ a: "? x" }) === 'a: "? x"\n'); +check("underscore-number string quoted", toYaml({ a: "1_000" }) === 'a: "1_000"\n'); +check("digit-leading string quoted", toYaml({ a: "3things" }) === 'a: "3things"\n'); +check("tilde string quoted", toYaml({ a: "~" }) === 'a: "~"\n'); +check("bare dash quoted", toYaml({ a: "-" }) === 'a: "-"\n'); + +// --- CDF_TYPE_NAMES ----------------------------------------------------- +check("type map double", CDF_TYPE_NAMES[45] === "CDF_DOUBLE"); +check("type map tt2000", CDF_TYPE_NAMES[33] === "CDF_TIME_TT2000"); +check("type map char", CDF_TYPE_NAMES[51] === "CDF_CHAR"); + +// --- buildSkeleton ------------------------------------------------------ +const rawVars = [{ + name: "v", + typeName: "CDF_FLOAT", + shape: [3], + isNrv: false, + compression: "none", + attributes: { units: "mV", scale: new Int32Array([1, 2, 3]) }, + attributeTypes: { units: 51, scale: 4 }, +}]; +const rawGlobals = [{ name: "Project", entries: ["THEMIS", [1, 2]], types: [51, 11] }]; +const skel = buildSkeleton(rawVars, rawGlobals, { compression: "gzip" }); + +check("top-level compression", skel.compression === "gzip"); +check("top-level key order", eq(Object.keys(skel), ["compression", "attributes", "variables"])); +check("global attr values pass-through", eq(skel.attributes.Project.values, ["THEMIS", [1, 2]])); +check("global attr type names", eq(skel.attributes.Project.types, ["CDF_CHAR", "CDF_UINT1"])); + +const v = skel.variables.v; +check("var key order", eq(Object.keys(v), ["type", "shape", "is_nrv", "compression", "attributes"])); +check("var type name", v.type === "CDF_FLOAT"); +check("var shape", eq(v.shape, [3])); +check("var is_nrv", v.is_nrv === false); +check("var compression", v.compression === "none"); +check("var attr units single-entry values", eq(v.attributes.units.values, ["mV"])); +check("var attr units type", eq(v.attributes.units.types, ["CDF_CHAR"])); +check("var attr scale wrapped single entry", + v.attributes.scale.values.length === 1 && eq(Array.from(v.attributes.scale.values[0]), [1, 2, 3])); +check("var attr scale type", eq(v.attributes.scale.types, ["CDF_INT4"])); + +// --- end to end --------------------------------------------------------- +const yaml = toYaml(skel); +check("e2e has variables block", yaml.includes("variables:\n")); +check("e2e var name indented", yaml.includes("\n v:\n")); +check("e2e renders var type name", yaml.includes("type: CDF_FLOAT\n")); +check("e2e renders global char type", yaml.includes("CDF_CHAR")); + +process.exit(failures === 0 ? 0 : 1); diff --git a/wacdfpp/app.js b/wacdfpp/app.js index 424f4804..8253befc 100644 --- a/wacdfpp/app.js +++ b/wacdfpp/app.js @@ -1,11 +1,12 @@ // Orchestration: load (file/URL/?url=/drag-drop), hold current CdfFile + model + // selection, wire search and list selection to re-render. import { loadModule } from "./wasm.js"; -import { rawFromCdfFile, buildModel, filterModel } from "./cdf-model.js"; +import { rawFromCdfFile, buildModel, filterModel, VAR_GROUPS } from "./cdf-model.js"; import { renderList, renderDetail, setSelected } from "./render.js"; import { renderPlot } from "./plot.js"; import { openValidation, openValidationBytes } from "./astralint.js"; import { runCompare, setView, setFilter } from "./compare.js"; +import { toYaml, buildSkeleton } from "./cdf-yaml.js"; const els = { fileInput: document.getElementById("fileInput"), @@ -23,6 +24,7 @@ const els = { app: document.querySelector(".app"), resizer: document.getElementById("resizer"), validateBtn: document.getElementById("validateBtn"), + exportYamlBtn: document.getElementById("exportYamlBtn"), modeToggle: document.getElementById("modeToggle"), compareInputs: document.getElementById("compareInputs"), compareBar: document.getElementById("compareBar"), @@ -42,6 +44,7 @@ let currentModel; // built once per file let currentUrl = null; // source URL of the loaded file (null for local/drag-drop) let currentBytes = null; // raw bytes of the loaded file (for the postMessage handoff) let currentName = null; // file name of the loaded file +let currentCompression = null; // file-level compression (for the YAML skeleton) let selectedName = null; let searchQuery = ""; let busy = false; @@ -52,6 +55,11 @@ function updateValidate() { els.validateBtn.title = ready ? "Validate this CDF against ISTP in AstraLint" : "Load a CDF to validate it against ISTP in AstraLint"; + const exportable = !!currentCdf; + els.exportYamlBtn.disabled = !exportable; + els.exportYamlBtn.title = exportable + ? "Download a YAML metadata skeleton of this CDF" + : "Load a CDF to export its YAML skeleton"; } function setStatus(cls, text) { els.status.className = cls; els.statusText.textContent = text; } @@ -83,6 +91,7 @@ function inspect(data, name, sourceUrl) { currentUrl = null; currentBytes = null; currentName = null; + currentCompression = null; updateValidate(); refreshList(); els.detail.innerHTML = `
Validate (ISTP)
+