diff --git a/cmake/common/compiler/options.cmake b/cmake/common/compiler/options.cmake index 20c05eaa96..d0548ad6b1 100644 --- a/cmake/common/compiler/options.cmake +++ b/cmake/common/compiler/options.cmake @@ -3,7 +3,7 @@ # Flag categories handled here: # - Diagnostics (-W...): always on, every config # - Language semantics (-fwrapv, -fstrict-aliasing, -fno-rtti on GCC): always on -# - Optimization-related (loop unrolling, vectorization, fast-math relaxations): +# - Optimization-related (loop unrolling, vectorization): # gated to non-Debug configs because they have no effect at -O0 but still # cost Clang/GCC pipeline time # @@ -59,15 +59,13 @@ function(sourcemeta_add_default_options visibility target) # See https://users.cs.utah.edu/~regehr/papers/overflow12.pdf # See https://www.postgresql.org/message-id/1689.1134422394@sss.pgh.pa.us -fwrapv - # Fast-math relaxations relax IEEE conformance (errno after math.h, - # signed-zero handling, reassociation), so they affect observable - # behavior and must apply to every config to keep Debug and Release - # semantics aligned + # Fast-math relaxations, applied to every config to keep Debug and + # Release semantics aligned. Signed zeros and reassociation stay at + # their IEEE defaults: the sign of zero is observable in serialised + # output, and GCC disables reassociation without -fno-signed-zeros -fno-math-errno -fno-trapping-math - -fno-signed-zeros -freciprocal-math - -fassociative-math # Optimization-only: emitted only when not building Debug. At -O0 these # run analyses that never reach codegen, costing build time for no diff --git a/src/core/json/stringify.h b/src/core/json/stringify.h index e79e873915..bb52f485a1 100644 --- a/src/core/json/stringify.h +++ b/src/core/json/stringify.h @@ -5,17 +5,17 @@ #include "grammar.h" -#include // std::transform, std::sort -#include // std::array -#include // assert -#include // std::to_chars -#include // std::size_t -#include // std::int64_t -#include // std::next, std::cbegin, std::cend, std::back_inserter -#include // std::basic_ostream -#include // std::ostringstream -#include // std::basic_string -#include // std::vector +#include // std::array +#include // assert +#include // std::to_chars +#include // std::signbit +#include // std::size_t +#include // std::int64_t +#include // std::next, std::cbegin, std::cend, std::back_inserter +#include // std::basic_ostream +#include // std::ostringstream +#include // std::basic_string +#include // std::vector namespace sourcemeta::core::internal { constexpr auto LINE_WIDTH{80}; @@ -76,15 +76,15 @@ auto stringify( const double value, const bool is_integral, std::basic_ostream &stream) -> void { - // RFC 8259 Section 6 permits the -0.0 number syntax, but this build compiles - // with -fno-signed-zeros, which lets the compiler assume the sign of a zero - // is insignificant, so the distinction between a negative and a positive zero - // cannot be relied upon here. Under GCC that assumption folds a negative zero - // to a positive one and makes std::signbit report it as positive, so probing - // the sign to emit "-0.0" is not portable. Every zero is therefore written - // with the same spelling + // RFC 8259 Section 6 permits the -0.0 number syntax and parsing preserves + // the sign of a zero, so serialisation keeps the sign as well and the + // round trip is lossless if (value == static_cast(0.0)) { - stream.write("0.0", 3); + if (std::signbit(value)) { + stream.write("-0.0", 4); + } else { + stream.write("0.0", 3); + } } else if (is_integral) { // Write the integer digits followed by an explicit ".0" to preserve the // real type. Using to_chars rather than a formatted stream keeps the diff --git a/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h b/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h index d1eb1e1f53..9ae12344ed 100644 --- a/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h +++ b/src/core/jsonld/include/sourcemeta/core/jsonld_materialize.h @@ -137,8 +137,8 @@ using JSONLDWeakAnnotationList = JSONLDBasicAnnotationList; /// /// Materialize an instance into expanded JSON-LD using an annotation list that /// assigns JSON-LD semantics to instance positions. An undescribed member of a -/// collection defaults to a plain literal, or to an unordered collection for a -/// nested array. The result is always a JSON array. For example: +/// collection defaults to a plain literal, or to a collection of the enclosing +/// kind for a nested array. The result is always a JSON array. For example: /// /// ```cpp /// #include @@ -174,8 +174,8 @@ auto jsonld_materialize(const JSON &instance, /// Materialize an instance into expanded JSON-LD using a weak annotation list /// whose positions are non-owning views into strings owned elsewhere. The /// backing strings must outlive the call. An undescribed member of a -/// collection defaults to a plain literal, or to an unordered collection for a -/// nested array. The result is always a JSON array. For example: +/// collection defaults to a plain literal, or to a collection of the enclosing +/// kind for a nested array. The result is always a JSON array. For example: /// /// ```cpp /// #include diff --git a/src/core/jsonld/jsonld_compaction.cc b/src/core/jsonld/jsonld_compaction.cc index 78bf1b8c09..ac960b6831 100644 --- a/src/core/jsonld/jsonld_compaction.cc +++ b/src/core/jsonld/jsonld_compaction.cc @@ -54,6 +54,20 @@ auto add_value(JSON &result, const JSON::String &key, JSON &&value, existing.push_back(std::move(value)); } +// A value object carrying a JSON literal whose value is null. +auto is_json_literal_null(const JSON &element) -> bool { + if (!element.is_object()) { + return false; + } + const auto *const contents{element.try_at(KEYWORD_VALUE, KEYWORD_VALUE_HASH)}; + if (contents == nullptr || !contents->is_null()) { + return false; + } + const auto *const type{element.try_at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)}; + return type != nullptr && type->is_string() && + type->to_string() == KEYWORD_JSON; +} + // The object a property is written into: the result itself, or a nesting // container when the term carries an @nest mapping. auto nest_target(JSON &result, const TermDefinition *const definition, @@ -106,7 +120,12 @@ auto compact(ExpansionState &state, const ActiveContext &active_context, for (const auto &item : element.as_array()) { auto compacted{compact(state, active_context, inverse_context, active_property, item, compact_arrays)}; - if (!compacted.is_null()) { + // A JSON literal holding null compacts to a bare null under a term + // coerced to @json, and that null is data rather than absence, so it + // survives even though the algorithm otherwise drops null items + // (JSON-LD 1.1 API Section 6.1.2: "If compacted item is not null, then + // append it to result"). Dropping it would silently lose the literal. + if (!compacted.is_null() || is_json_literal_null(item)) { result.push_back(std::move(compacted)); } } diff --git a/src/core/jsonld/jsonld_materialize.cc b/src/core/jsonld/jsonld_materialize.cc index 24bd629a97..baa97db5fd 100644 --- a/src/core/jsonld/jsonld_materialize.cc +++ b/src/core/jsonld/jsonld_materialize.cc @@ -12,7 +12,7 @@ #include // std::optional, std::nullopt #include // std::is_same_v #include // std::move, std::unreachable -#include // std::holds_alternative, std::get +#include // std::get, std::get_if #include // std::vector namespace sourcemeta::core { @@ -64,7 +64,8 @@ auto fill_node(JSON &node, const JSON &instance_object, PointerT &pointer, template auto materialize_member(const JSON &value, PointerT &pointer, const AnnotationRange &range, - std::vector &standalone) -> std::optional; + std::vector &standalone, const bool ordered) + -> std::optional; // Append an object key to the pointer, copying it for an owning pointer and // taking a non-owning view for a weak pointer. @@ -215,7 +216,7 @@ auto build_collection(const JSON &value, PointerT &pointer, pointer.push_back(index); auto element{materialize_member(value.at(index), pointer, child_range(iterator, range.end, pointer), - standalone)}; + standalone, ordered)}; pointer.pop_back(); if (!element.has_value()) { continue; @@ -292,7 +293,18 @@ auto build_language_collection(const JSON &value) -> JSON { return elements; } -// The index keys carry no RDF and are dropped. +auto assign_index(JSON &element, const JSON::String &index) -> void { + if (!element.defines(KEYWORD_INDEX, KEYWORD_INDEX_HASH)) { + element.assign_assume_new(JSON::String{KEYWORD_INDEX}, JSON{index}, + KEYWORD_INDEX_HASH); + } +} + +// Each index map key is retained on its expanded members unless the key is +// the reserved @none (JSON-LD 1.1 API Section 5.1.2: "if container mapping +// includes @index, item does not have an entry @index, and expanded index is +// not @none, add the key-value pair (@index-index) to item"). Only the final +// deserialization to RDF discards the keys. template auto build_index_collection(const JSON &value, PointerT &pointer, const AnnotationRange &range, @@ -303,18 +315,26 @@ auto build_index_collection(const JSON &value, PointerT &pointer, push_property(pointer, key.get()); auto element{materialize_member(value.at(key.get()), pointer, child_range(iterator, range.end, pointer), - standalone)}; + standalone, false)}; pointer.pop_back(); if (!element.has_value()) { continue; } + const bool none{key.get() == KEYWORD_NONE}; + // A nested set flattens into the enclosing collection. if (element->is_array()) { for (auto &nested : element->as_array()) { + if (!none) { + assign_index(nested, key.get()); + } elements.push_back(std::move(nested)); } } else { + if (!none) { + assign_index(element.value(), key.get()); + } elements.push_back(std::move(element.value())); } } @@ -322,13 +342,17 @@ auto build_index_collection(const JSON &value, PointerT &pointer, } // An undescribed collection member still materializes with a default kind, a -// scalar as a plain literal and a nested array as an unordered collection. -// An undescribed object member keeps the anonymous node treatment of any -// other position. +// scalar as a plain literal and a nested array as a collection of the +// enclosing kind, so that arrays nested in a list are themselves lists +// (JSON-LD 1.1 API Section 5.1.2: "If the container mapping of active +// property includes @list, and expanded item is an array, set expanded item +// to a new map containing the entry @list"). An undescribed object member +// keeps the anonymous node treatment of any other position. template auto materialize_member(const JSON &value, PointerT &pointer, const AnnotationRange &range, - std::vector &standalone) -> std::optional { + std::vector &standalone, const bool ordered) + -> std::optional { const auto described{range.begin != range.end && (*range.begin)->pointer.size() == pointer.size()}; if (described || value.is_object()) { @@ -340,7 +364,7 @@ auto materialize_member(const JSON &value, PointerT &pointer, } if (value.is_array()) { - return build_collection(value, pointer, range, standalone, false); + return build_collection(value, pointer, range, standalone, ordered); } return materialize_literal(JSONLDLiteral{}, value); @@ -378,8 +402,13 @@ auto materialize_node(const JSONLDNode &descriptor, const JSON &value, if (inner.object_size() > (descriptor.id.has_value() ? 1 : 0)) { graph.push_back(std::move(inner)); } + // The free-floating drop applies inside a named graph just like at the + // top level (JSON-LD 1.1 API Section 5.1.2: "If active property is null + // or @graph, drop free-floating values"). for (auto &extra : graph_nodes) { - graph.push_back(std::move(extra)); + if (!extra.empty()) { + graph.push_back(std::move(extra)); + } } node.assign_assume_new(JSON::String{KEYWORD_GRAPH}, std::move(graph), KEYWORD_GRAPH_HASH); @@ -400,10 +429,6 @@ auto materialize_value(const JSON &value, PointerT &pointer, *matched_edges = nullptr; } - if (value.is_null()) { - return std::nullopt; - } - // Every annotation in the range extends the current position, so one of // equal length is the annotation of the position itself and sorts first if (range.begin == range.end || @@ -423,20 +448,31 @@ auto materialize_value(const JSON &value, PointerT &pointer, } const auto &descriptor{(*range.begin)->descriptor}; + const auto *literal_descriptor{std::get_if(&descriptor.value)}; + + // A null value is treated as if its entry were absent, except under a JSON + // literal, where the null is the data itself (JSON-LD 1.1 API Section + // 5.1.2: "If the result's @type entry is @json, then the @value entry may + // contain any value, and is treated as a JSON literal"). + if (value.is_null() && + (literal_descriptor == nullptr || !literal_descriptor->json)) { + return std::nullopt; + } + range.begin += 1; if (matched_edges != nullptr) { *matched_edges = &descriptor.edges; } - if (std::holds_alternative(descriptor.value)) { - return materialize_node(std::get(descriptor.value), value, - pointer, range, standalone); + if (const auto *node_descriptor{std::get_if(&descriptor.value)}) { + return materialize_node(*node_descriptor, value, pointer, range, + standalone); } - if (std::holds_alternative(descriptor.value)) { - return materialize_literal(std::get(descriptor.value), - value); + if (literal_descriptor != nullptr) { + return materialize_literal(*literal_descriptor, value); } - if (std::holds_alternative(descriptor.value)) { - return materialize_reference(std::get(descriptor.value)); + if (const auto *reference_descriptor{ + std::get_if(&descriptor.value)}) { + return materialize_reference(*reference_descriptor); } const auto &collection{std::get(descriptor.value)}; @@ -531,20 +567,25 @@ auto materialize_root(const JSON &instance, if (root.has_value()) { // The default graph may only hold node objects. A top-level value or list // object, whether the root itself or an element of a root set, carries no - // triples and is dropped. + // triples and is dropped. An empty top-level node object is free-floating + // and is dropped as well (JSON-LD 1.1 API Section 5.1.2: "If result is a + // map which is empty, or contains only the entries @value or @list, set + // result to null"). if (root->is_array()) { for (auto &element : root->as_array()) { - if (is_node_object(element)) { + if (is_node_object(element) && !element.empty()) { result.push_back(std::move(element)); } } - } else if (is_node_object(root.value())) { + } else if (is_node_object(root.value()) && !root->empty()) { result.push_back(std::move(root.value())); } } for (auto &node : standalone) { - result.push_back(std::move(node)); + if (!node.empty()) { + result.push_back(std::move(node)); + } } return result; diff --git a/src/core/jsonld/jsonld_serialise.h b/src/core/jsonld/jsonld_serialise.h index b186878ca0..932e757dbc 100644 --- a/src/core/jsonld/jsonld_serialise.h +++ b/src/core/jsonld/jsonld_serialise.h @@ -8,6 +8,7 @@ #include // std::array #include // assert #include // std::to_chars, std::from_chars, std::chars_format +#include // std::signbit #include // std::size_t #include // std::int32_t #include // std::optional, std::nullopt @@ -41,11 +42,13 @@ inline auto is_floating_point_datatype(const JSON::StringView datatype) // "rounded to 15 digits after the decimal point" with trailing zeros dropped // down to a single digit after the required decimal point, the exponent // carries no plus sign or leading zeros, and "the canonical representation -// for zero is 0.0E0". The form is assembled in the conversion buffer itself +// for zero is 0.0E0". The value space distinguishes the two zeros, and the +// canonical mapping returns "'-0.0E0' when f is negativeZero" (XSD 1.1 Part +// 2 Section 3.3.5). The form is assembled in the conversion buffer itself // so the function performs at most a single allocation inline auto scientific_lexical_form(const double value) -> JSON::String { if (value == 0.0) { - return "0.0E0"; + return std::signbit(value) ? "-0.0E0" : "0.0E0"; } std::array buffer{}; diff --git a/src/lang/numeric/decimal.cc b/src/lang/numeric/decimal.cc index 39c5fbd691..d0ebb5d946 100644 --- a/src/lang/numeric/decimal.cc +++ b/src/lang/numeric/decimal.cc @@ -7,7 +7,7 @@ #include // assert #include // std::to_chars #include // std::isfinite, std::isnan, std::isinf, std::abs, - // std::frexp, std::ldexp + // std::frexp, std::ldexp, std::signbit #include // std::size_t #include // std::strlen #include // std::setprecision @@ -674,10 +674,13 @@ auto Decimal::exact_from(const double value) -> Decimal { return value < 0 ? Decimal::negative_infinity() : Decimal::infinity(); } - // The library builds without IEEE signed zeros, so a negative zero is - // indistinguishable from a positive zero and always yields an unsigned zero + // The decimal representation carries a dedicated sign, so a negative zero + // converts to a signed zero and the conversion is lossless if (value == 0.0) { Decimal output{static_cast(0)}; + if (std::signbit(value)) { + output.flags_ = static_cast(output.flags_ | FLAG_SIGN); + } output.flags_ = static_cast(output.flags_ & ~FLAG_INTEGER_LITERAL); return output; diff --git a/test/json/json_prettify_test.cc b/test/json/json_prettify_test.cc index 09e277d564..9137ff2177 100644 --- a/test/json/json_prettify_test.cc +++ b/test/json/json_prettify_test.cc @@ -73,11 +73,11 @@ TEST(real_zero) { EXPECT_EQ(stream.str(), "0.0"); } -TEST(real_minus_zero_normalizes_to_zero) { +TEST(real_minus_zero_keeps_sign) { const sourcemeta::core::JSON document{-0.0}; std::ostringstream stream; sourcemeta::core::prettify(document, stream); - EXPECT_EQ(stream.str(), "0.0"); + EXPECT_EQ(stream.str(), "-0.0"); } TEST(empty_string) { diff --git a/test/json/json_stringify_test.cc b/test/json/json_stringify_test.cc index 25c231e1de..29e41d5b9d 100644 --- a/test/json/json_stringify_test.cc +++ b/test/json/json_stringify_test.cc @@ -73,18 +73,26 @@ TEST(real_zero) { EXPECT_EQ(stream.str(), "0.0"); } -TEST(real_minus_zero_normalizes_to_zero) { +TEST(real_minus_zero_keeps_sign) { const sourcemeta::core::JSON document{-0.0}; std::ostringstream stream; sourcemeta::core::stringify(document, stream); - EXPECT_EQ(stream.str(), "0.0"); + EXPECT_EQ(stream.str(), "-0.0"); } -TEST(real_minus_zero_roundtrip_normalizes_to_zero) { +TEST(real_minus_zero_roundtrip_keeps_sign) { const sourcemeta::core::JSON document{sourcemeta::core::parse_json("-0.0")}; std::ostringstream stream; sourcemeta::core::stringify(document, stream); - EXPECT_EQ(stream.str(), "0.0"); + EXPECT_EQ(stream.str(), "-0.0"); +} + +TEST(real_minus_zero_in_array_keeps_sign) { + const sourcemeta::core::JSON document{ + sourcemeta::core::parse_json("[ -0.0, 0.0 ]")}; + std::ostringstream stream; + sourcemeta::core::stringify(document, stream); + EXPECT_EQ(stream.str(), "[-0.0,0.0]"); } TEST(real_zero_roundtrip) { diff --git a/test/jsonld/jsonld_compact_test.cc b/test/jsonld/jsonld_compact_test.cc index ac097ee2ac..063aa98ccd 100644 --- a/test/jsonld/jsonld_compact_test.cc +++ b/test/jsonld/jsonld_compact_test.cc @@ -312,3 +312,83 @@ TEST(iri_compaction_1_0_does_not_add_index_candidate_for_a_node_reference) { })"); EXPECT_EQ(result, expected); } + +// A JSON literal holding null compacts to a bare null under a term coerced +// to @json, and the null is data rather than absence, so it survives inside +// compacted lists instead of being dropped with other null items +TEST(json_literal_null_in_list_is_kept) { + const auto input = sourcemeta::core::parse_json(R"([ + { + "http://example.org/vocab#e": [ + { + "@list": [ + { "@value": null, "@type": "@json" }, + { "@value": 1, "@type": "@json" } + ] + } + ] + } + ])"); + const auto context = sourcemeta::core::parse_json(R"({ + "e": { + "@id": "http://example.org/vocab#e", + "@type": "@json", + "@container": "@list" + } + })"); + const auto result{sourcemeta::core::jsonld_compact(input, context)}; + const auto expected = sourcemeta::core::parse_json(R"({ + "e": [ null, 1 ], + "@context": { + "e": { + "@id": "http://example.org/vocab#e", + "@type": "@json", + "@container": "@list" + } + } + })"); + EXPECT_EQ(result, expected); +} + +TEST(json_literal_null_among_property_values_is_kept) { + const auto input = sourcemeta::core::parse_json(R"([ + { + "http://example.org/vocab#e": [ + { "@value": null, "@type": "@json" }, + { "@value": true, "@type": "@json" } + ] + } + ])"); + const auto context = sourcemeta::core::parse_json(R"({ + "e": { "@id": "http://example.org/vocab#e", "@type": "@json" } + })"); + const auto result{sourcemeta::core::jsonld_compact(input, context)}; + const auto expected = sourcemeta::core::parse_json(R"({ + "e": [ null, true ], + "@context": { + "e": { "@id": "http://example.org/vocab#e", "@type": "@json" } + } + })"); + EXPECT_EQ(result, expected); +} + +TEST(json_literal_null_without_json_term_keeps_value_object) { + const auto input = sourcemeta::core::parse_json(R"([ + { + "http://example.org/vocab#e": [ + { "@value": null, "@type": "@json" } + ] + } + ])"); + const auto context = sourcemeta::core::parse_json(R"({ + "e": { "@id": "http://example.org/vocab#e" } + })"); + const auto result{sourcemeta::core::jsonld_compact(input, context)}; + const auto expected = sourcemeta::core::parse_json(R"({ + "e": { "@value": null, "@type": "@json" }, + "@context": { + "e": { "@id": "http://example.org/vocab#e" } + } + })"); + EXPECT_EQ(result, expected); +} diff --git a/test/jsonld/jsonld_materialize_test.cc b/test/jsonld/jsonld_materialize_test.cc index aa5a2b628e..7e7b0d5e36 100644 --- a/test/jsonld/jsonld_materialize_test.cc +++ b/test/jsonld/jsonld_materialize_test.cc @@ -410,6 +410,198 @@ TEST(nested_ordered_collection_is_list_of_lists) { EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); } +// A nested undescribed array of a list is itself a list (JSON-LD 1.1 API +// Section 5.1.2: "If the container mapping of active property includes +// @list, and expanded item is an array, set expanded item to a new map +// containing the entry @list") +TEST(ordered_collection_wraps_undescribed_nested_arrays) { + const auto instance = + sourcemeta::core::parse_json(R"({ "tags": [ [ 1, 2 ], [ 3, 4 ] ] })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"tags"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/tags", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::List}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/tags": [ + { + "@list": [ + { "@list": [ { "@value": 1 }, { "@value": 2 } ] }, + { "@list": [ { "@value": 3 }, { "@value": 4 } ] } + ] + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(ordered_collection_wraps_deeply_nested_undescribed_arrays) { + const auto instance = + sourcemeta::core::parse_json(R"({ "matrix": [ [ [ 5 ] ] ] })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"matrix"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/rows", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::List}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/rows": [ + { + "@list": [ + { + "@list": [ + { "@list": [ { "@value": 5 } ] } + ] + } + ] + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(ordered_collection_wraps_mixed_nested_and_scalar_members) { + const auto instance = + sourcemeta::core::parse_json(R"({ "tags": [ [ 1, 2 ], 3 ] })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"tags"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/tags", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::List}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/tags": [ + { + "@list": [ + { "@list": [ { "@value": 1 }, { "@value": 2 } ] }, + { "@value": 3 } + ] + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(ordered_collection_wraps_empty_nested_array) { + const auto instance = sourcemeta::core::parse_json(R"({ "tags": [ [] ] })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"tags"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/tags", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::List}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/tags": [ + { + "@list": [ + { "@list": [] } + ] + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(weak_ordered_collection_wraps_undescribed_nested_arrays) { + const auto instance = + sourcemeta::core::parse_json(R"({ "tags": [ [ 1 ], 2 ] })"); + + const sourcemeta::core::JSON::String tags_key{"tags"}; + + sourcemeta::core::JSONLDWeakAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::WeakPointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::WeakPointer{std::cref(tags_key)}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/tags", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::List}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/tags": [ + { + "@list": [ + { "@list": [ { "@value": 1 } ] }, + { "@value": 2 } + ] + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + TEST(undescribed_object_with_described_child) { const auto instance = sourcemeta::core::parse_json( R"({ "name": "Doc", "meta": { "title": "T" } })"); @@ -420,21 +612,372 @@ TEST(undescribed_object_with_described_child) { sourcemeta::core::JSONLDDescriptor{.edges = {}, .value = sourcemeta::core::JSONLDNode{ .id = "https://example.com/doc"}}); - annotations.emplace_back(sourcemeta::core::Pointer{"name"}, - sourcemeta::core::JSONLDDescriptor{ - .edges = {{"https://schema.org/name", false}}, - .value = sourcemeta::core::JSONLDLiteral{}}); - annotations.emplace_back(sourcemeta::core::Pointer{"meta", "title"}, + annotations.emplace_back(sourcemeta::core::Pointer{"name"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://schema.org/name", false}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + annotations.emplace_back(sourcemeta::core::Pointer{"meta", "title"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/title", false}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://schema.org/name": [ { "@value": "Doc" } ] + }, + { "https://example.com/title": [ { "@value": "T" } ] } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(described_node_without_edge_is_standalone) { + const auto instance = + sourcemeta::core::parse_json(R"({ "extra": { "x": "v" } })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back(sourcemeta::core::Pointer{"extra"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/other"}}); + annotations.emplace_back(sourcemeta::core::Pointer{"extra", "x"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/x", false}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { "@id": "https://example.com/doc" }, + { + "@id": "https://example.com/other", + "https://example.com/x": [ { "@value": "v" } ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(null_value_produces_no_triple) { + const auto instance = sourcemeta::core::parse_json(R"({ "maybe": null })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back(sourcemeta::core::Pointer{"maybe"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://schema.org/name", false}}, + .value = sourcemeta::core::JSONLDLiteral{}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { "@id": "https://example.com/doc" } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(null_value_with_node_descriptor_produces_no_triple) { + const auto instance = sourcemeta::core::parse_json(R"({ "maybe": null })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back(sourcemeta::core::Pointer{"maybe"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/child", false}}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/thing", + .types = {"https://example.com/Thing"}}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { "@id": "https://example.com/doc" } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(null_value_with_reference_descriptor_produces_no_triple) { + const auto instance = sourcemeta::core::parse_json(R"({ "maybe": null })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back(sourcemeta::core::Pointer{"maybe"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/child", false}}, + .value = sourcemeta::core::JSONLDReference{ + .id = "https://example.com/ref"}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { "@id": "https://example.com/doc" } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(null_value_with_collection_descriptor_produces_no_triple) { + const auto instance = sourcemeta::core::parse_json(R"({ "maybe": null })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"maybe"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/items", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::List}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { "@id": "https://example.com/doc" } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +// A null under a JSON literal is data rather than absence (JSON-LD 1.1 API +// Section 5.1.2: "If the result's @type entry is @json, then the @value +// entry may contain any value, and is treated as a JSON literal") +TEST(json_literal_null_is_data) { + const auto instance = sourcemeta::core::parse_json(R"({ "payload": null })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"payload"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/raw", false}}, + .value = sourcemeta::core::JSONLDLiteral{.json = true}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/raw": [ + { "@value": null, "@type": "@json" } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(json_literal_null_member_in_ordered_collection) { + const auto instance = + sourcemeta::core::parse_json(R"({ "items": [ null ] })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"items"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/items", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::List}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"items", 0}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, .value = sourcemeta::core::JSONLDLiteral{.json = true}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/items": [ + { + "@list": [ + { "@value": null, "@type": "@json" } + ] + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(weak_json_literal_null_is_data) { + const auto instance = sourcemeta::core::parse_json(R"({ "payload": null })"); + + const sourcemeta::core::JSON::String payload_key{"payload"}; + + sourcemeta::core::JSONLDWeakAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::WeakPointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::WeakPointer{std::cref(payload_key)}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/raw", false}}, + .value = sourcemeta::core::JSONLDLiteral{.json = true}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/raw": [ + { "@value": null, "@type": "@json" } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +// A free-floating empty node object is dropped at the top level (JSON-LD 1.1 +// API Section 5.1.2: "If result is a map which is empty, or contains only +// the entries @value or @list, set result to null") +TEST(empty_node_descriptor_on_scalar_root_is_dropped) { + const auto instance = sourcemeta::core::parse_json(R"("orphan")"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, .value = sourcemeta::core::JSONLDNode{}}); + + const auto expected = sourcemeta::core::parse_json(R"([])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(empty_node_descriptor_on_object_root_is_dropped) { + const auto instance = sourcemeta::core::parse_json(R"({ "name": "X" })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, .value = sourcemeta::core::JSONLDNode{}}); + + const auto expected = sourcemeta::core::parse_json(R"([])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +// A top-level node holding only a minted identity is deliberately kept, even +// though the free-floating rule also drops maps whose only entry is @id, as +// the identity is the annotation's entire point +TEST(identified_node_descriptor_on_scalar_root_is_kept) { + const auto instance = sourcemeta::core::parse_json(R"("orphan")"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { "@id": "https://example.com/doc" } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(standalone_empty_node_is_dropped) { + const auto instance = sourcemeta::core::parse_json(R"({ "meta": "X" })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"meta"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, .value = sourcemeta::core::JSONLDNode{}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { "@id": "https://example.com/doc" } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(edge_attached_empty_node_below_root_is_kept) { + const auto instance = sourcemeta::core::parse_json(R"({ "meta": "X" })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back(sourcemeta::core::Pointer{"meta"}, sourcemeta::core::JSONLDDescriptor{ - .edges = {{"https://example.com/title", false}}, - .value = sourcemeta::core::JSONLDLiteral{}}); + .edges = {{"https://example.com/meta", false}}, + .value = sourcemeta::core::JSONLDNode{}}); const auto expected = sourcemeta::core::parse_json(R"([ { "@id": "https://example.com/doc", - "https://schema.org/name": [ { "@value": "Doc" } ] - }, - { "https://example.com/title": [ { "@value": "T" } ] } + "https://example.com/meta": [ {} ] + } ])"); const auto result{ @@ -443,9 +986,9 @@ TEST(undescribed_object_with_described_child) { EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); } -TEST(described_node_without_edge_is_standalone) { +TEST(named_graph) { const auto instance = - sourcemeta::core::parse_json(R"({ "extra": { "x": "v" } })"); + sourcemeta::core::parse_json(R"({ "prov": { "who": "X" } })"); sourcemeta::core::JSONLDAnnotationList annotations; annotations.emplace_back( @@ -453,21 +996,31 @@ TEST(described_node_without_edge_is_standalone) { sourcemeta::core::JSONLDDescriptor{.edges = {}, .value = sourcemeta::core::JSONLDNode{ .id = "https://example.com/doc"}}); - annotations.emplace_back(sourcemeta::core::Pointer{"extra"}, - sourcemeta::core::JSONLDDescriptor{ - .edges = {}, - .value = sourcemeta::core::JSONLDNode{ - .id = "https://example.com/other"}}); - annotations.emplace_back(sourcemeta::core::Pointer{"extra", "x"}, + annotations.emplace_back( + sourcemeta::core::Pointer{"prov"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/statedIn", false}}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/graph", .graph = true}}); + annotations.emplace_back(sourcemeta::core::Pointer{"prov", "who"}, sourcemeta::core::JSONLDDescriptor{ - .edges = {{"https://example.com/x", false}}, + .edges = {{"https://schema.org/name", false}}, .value = sourcemeta::core::JSONLDLiteral{}}); const auto expected = sourcemeta::core::parse_json(R"([ - { "@id": "https://example.com/doc" }, { - "@id": "https://example.com/other", - "https://example.com/x": [ { "@value": "v" } ] + "@id": "https://example.com/doc", + "https://example.com/statedIn": [ + { + "@id": "https://example.com/graph", + "@graph": [ + { + "@id": "https://example.com/graph", + "https://schema.org/name": [ { "@value": "X" } ] + } + ] + } + ] } ])"); @@ -477,8 +1030,12 @@ TEST(described_node_without_edge_is_standalone) { EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); } -TEST(null_value_produces_no_triple) { - const auto instance = sourcemeta::core::parse_json(R"({ "maybe": null })"); +// The free-floating drop applies inside a named graph too (JSON-LD 1.1 API +// Section 5.1.2: "If active property is null or @graph, drop free-floating +// values") +TEST(standalone_empty_node_inside_named_graph_is_dropped) { + const auto instance = sourcemeta::core::parse_json( + R"({ "prov": { "meta": "Y", "who": "X" } })"); sourcemeta::core::JSONLDAnnotationList annotations; annotations.emplace_back( @@ -486,13 +1043,36 @@ TEST(null_value_produces_no_triple) { sourcemeta::core::JSONLDDescriptor{.edges = {}, .value = sourcemeta::core::JSONLDNode{ .id = "https://example.com/doc"}}); - annotations.emplace_back(sourcemeta::core::Pointer{"maybe"}, + annotations.emplace_back( + sourcemeta::core::Pointer{"prov"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/statedIn", false}}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/graph", .graph = true}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"prov", "meta"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, .value = sourcemeta::core::JSONLDNode{}}); + annotations.emplace_back(sourcemeta::core::Pointer{"prov", "who"}, sourcemeta::core::JSONLDDescriptor{ .edges = {{"https://schema.org/name", false}}, .value = sourcemeta::core::JSONLDLiteral{}}); const auto expected = sourcemeta::core::parse_json(R"([ - { "@id": "https://example.com/doc" } + { + "@id": "https://example.com/doc", + "https://example.com/statedIn": [ + { + "@id": "https://example.com/graph", + "@graph": [ + { + "@id": "https://example.com/graph", + "https://schema.org/name": [ { "@value": "X" } ] + } + ] + } + ] + } ])"); const auto result{ @@ -501,9 +1081,9 @@ TEST(null_value_produces_no_triple) { EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); } -TEST(named_graph) { - const auto instance = - sourcemeta::core::parse_json(R"({ "prov": { "who": "X" } })"); +TEST(edge_attached_empty_node_inside_named_graph_is_kept) { + const auto instance = sourcemeta::core::parse_json( + R"({ "prov": { "meta": "Y", "who": "X" } })"); sourcemeta::core::JSONLDAnnotationList annotations; annotations.emplace_back( @@ -517,6 +1097,10 @@ TEST(named_graph) { .edges = {{"https://example.com/statedIn", false}}, .value = sourcemeta::core::JSONLDNode{ .id = "https://example.com/graph", .graph = true}}); + annotations.emplace_back(sourcemeta::core::Pointer{"prov", "meta"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/meta", false}}, + .value = sourcemeta::core::JSONLDNode{}}); annotations.emplace_back(sourcemeta::core::Pointer{"prov", "who"}, sourcemeta::core::JSONLDDescriptor{ .edges = {{"https://schema.org/name", false}}, @@ -531,6 +1115,7 @@ TEST(named_graph) { "@graph": [ { "@id": "https://example.com/graph", + "https://example.com/meta": [ {} ], "https://schema.org/name": [ { "@value": "X" } ] } ] @@ -1027,8 +1612,8 @@ TEST(index_collection_defaults_undescribed_members) { { "@id": "https://example.com/book", "https://schema.org/identifier": [ - { "@value": "10.1000/1" }, - { "@value": "978-0" } + { "@value": "10.1000/1", "@index": "doi" }, + { "@value": "978-0", "@index": "isbn" } ] } ])"); @@ -1855,11 +2440,13 @@ TEST(index_container_of_nodes) { "https://schema.org/blogPost": [ { "@id": "https://example.com/post/2023", - "https://schema.org/headline": [ { "@value": "A" } ] + "https://schema.org/headline": [ { "@value": "A" } ], + "@index": "2023" }, { "@id": "https://example.com/post/2024", - "https://schema.org/headline": [ { "@value": "B" } ] + "https://schema.org/headline": [ { "@value": "B" } ], + "@index": "2024" } ] } @@ -1899,7 +2486,10 @@ TEST(index_container_of_literals) { const auto expected = sourcemeta::core::parse_json(R"([ { "@id": "https://example.com/report", - "https://schema.org/value": [ { "@value": 80 }, { "@value": 90 } ] + "https://schema.org/value": [ + { "@value": 80, "@index": "art" }, + { "@value": 90, "@index": "math" } + ] } ])"); @@ -1943,7 +2533,10 @@ TEST(index_container_with_nested_set) { const auto expected = sourcemeta::core::parse_json(R"([ { "@id": "https://example.com/doc", - "https://schema.org/item": [ { "@value": "x" }, { "@value": "y" } ] + "https://schema.org/item": [ + { "@value": "x", "@index": "a" }, + { "@value": "y", "@index": "a" } + ] } ])"); @@ -1979,6 +2572,83 @@ TEST(index_container_empty) { EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); } +// The reserved @none key carries no index (JSON-LD 1.1 API Section 5.1.2: +// "item does not have an entry @index, and expanded index is not @none") +TEST(index_container_none_key_carries_no_index) { + const auto instance = sourcemeta::core::parse_json( + R"({ "posts": { "@none": "X", "a": "Y" } })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back(sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/blog"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"posts"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://schema.org/blogPost", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::Index}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/blog", + "https://schema.org/blogPost": [ + { "@value": "X" }, + { "@value": "Y", "@index": "a" } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(index_container_of_described_typed_literals) { + const auto instance = + sourcemeta::core::parse_json(R"({ "scores": { "math": 90 } })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back(sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/report"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"scores"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://schema.org/value", false}}, + .value = sourcemeta::core::JSONLDCollection{ + .container = sourcemeta::core::JSONLDContainer::Index}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"scores", "math"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {}, + .value = sourcemeta::core::JSONLDLiteral{ + .datatype = "http://www.w3.org/2001/XMLSchema#integer"}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/report", + "https://schema.org/value": [ + { + "@value": "90", + "@type": "http://www.w3.org/2001/XMLSchema#integer", + "@index": "math" + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + TEST(weak_collection_defaults_undescribed_scalar_elements) { const auto instance = sourcemeta::core::parse_json( R"({ "keywords": [ "reference", "standards" ] })"); @@ -2798,10 +3468,81 @@ TEST(zero_real_literal_with_double_datatype) { EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); } -// Zero has a single canonical representation regardless of sign +// The value space distinguishes the two zeros and the canonical mapping +// returns "'-0.0E0' when f is negativeZero" (XSD 1.1 Part 2 Section 3.3.5) TEST(negative_zero_real_literal_with_double_datatype) { const auto instance = sourcemeta::core::parse_json(R"({ "value": -0.0 })"); + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"value"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/value", false}}, + .value = sourcemeta::core::JSONLDLiteral{ + .datatype = "http://www.w3.org/2001/XMLSchema#double"}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/value": [ + { + "@value": "-0.0E0", + "@type": "http://www.w3.org/2001/XMLSchema#double" + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +TEST(negative_zero_real_literal_with_float_datatype) { + const auto instance = sourcemeta::core::parse_json(R"({ "value": -0.0 })"); + + sourcemeta::core::JSONLDAnnotationList annotations; + annotations.emplace_back( + sourcemeta::core::Pointer{}, + sourcemeta::core::JSONLDDescriptor{.edges = {}, + .value = sourcemeta::core::JSONLDNode{ + .id = "https://example.com/doc"}}); + annotations.emplace_back( + sourcemeta::core::Pointer{"value"}, + sourcemeta::core::JSONLDDescriptor{ + .edges = {{"https://example.com/value", false}}, + .value = sourcemeta::core::JSONLDLiteral{ + .datatype = "http://www.w3.org/2001/XMLSchema#float"}}); + + const auto expected = sourcemeta::core::parse_json(R"([ + { + "@id": "https://example.com/doc", + "https://example.com/value": [ + { + "@value": "-0.0E0", + "@type": "http://www.w3.org/2001/XMLSchema#float" + } + ] + } + ])"); + + const auto result{ + sourcemeta::core::jsonld_materialize(instance, annotations)}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(result)); +} + +// An integer zero carries no sign, so only the real parse of a negative zero +// takes the signed canonical form +TEST(negative_integer_zero_literal_with_double_datatype) { + const auto instance = sourcemeta::core::parse_json(R"({ "value": -0 })"); + sourcemeta::core::JSONLDAnnotationList annotations; annotations.emplace_back( sourcemeta::core::Pointer{}, diff --git a/test/numeric/numeric_decimal_test.cc b/test/numeric/numeric_decimal_test.cc index c968f6d3bf..2f439f3715 100644 --- a/test/numeric/numeric_decimal_test.cc +++ b/test/numeric/numeric_decimal_test.cc @@ -3960,11 +3960,12 @@ TEST(exact_from_zero) { } TEST(exact_from_negative_zero) { - // The library builds without IEEE signed zeros, so a negative zero converts - // to a plain unsigned zero const auto value{sourcemeta::core::Decimal::exact_from(-0.0)}; EXPECT_TRUE(value.is_zero()); + EXPECT_TRUE(value.is_signed()); + EXPECT_EQ(value.to_string(), "-0"); EXPECT_TRUE(value == sourcemeta::core::Decimal{0}); + EXPECT_TRUE(value == sourcemeta::core::Decimal{"-0"}); } TEST(exact_from_dyadic_half) {