PureYAML/Tests/TaggedNodeRoundTripTests.swift
Mihaela Mihaljevic e51df57c35 feat(tagged): carry mapping keys as full nodes
Closes #12 and closes #13. Tagged.Pair stores keyNode instead of a
bare string: alias mapping keys parse on the Tagged path (composer
resolves them through the anchors table, dumper re-emits the reference
with the mandatory space before the colon since a colon is a legal
anchor character), key anchors register for later aliases and re-emit,
and a key's presentation style survives dump and reparse. key and
keyTag remain as read-through computed accessors and the string-key
convenience init keeps most call sites source compatible. A style-less
hand-built key keeps the plain-when-safe emission convention. The
corpus round-trip law compares key nodes structurally; alias-key
documents move from rejected to round-tripping.
2026-07-16 15:42:56 +02:00

266 lines
10 KiB
Swift

@testable import PureYAML
import Testing
/// The round-trip law for the tag-preserving tree: for a document with
/// anchors, aliases, tags, and styles, `parseTagged(dump(parseTagged(d)))`
/// equals `parseTagged(d)`: the alias topology survives as references, not
/// expanded copies.
@Suite("Tagged node anchors, aliases, and styles round-trip")
struct TaggedNodeRoundTripTests {
@Test("An anchored scalar and its alias parse into anchor and alias nodes")
func test_aliasIsPreservedNotExpanded() throws {
let node = try PureYAML.parseTagged("first: &shared value\nsecond: *shared\n")
guard case let .mapping(mapping) = node else {
Issue.record("expected mapping, got \(node.kind)")
return
}
guard case let .scalar(first)? = mapping["first"] else {
Issue.record("expected scalar for first")
return
}
#expect(first.anchor == "shared")
#expect(first.style == .plain)
guard case let .alias(anchor, resolved)? = mapping["second"] else {
Issue.record("expected alias for second, got \(String(describing: mapping["second"]?.kind))")
return
}
#expect(anchor == "shared")
#expect(resolved == .scalar(first))
}
@Test("An anchored scalar document round-trips through dump and reparse")
func test_scalarAliasRoundTrip() throws {
let source = "first: &shared value\nsecond: *shared\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("&shared"))
#expect(dumped.contains("*shared"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("An anchored mapping referenced by alias round-trips as a reference")
func test_mappingAliasRoundTrip() throws {
let source = """
defaults: &base
retries: 3
timeout: 30
service: *base
"""
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("&base"))
#expect(dumped.contains("*base"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("An anchored sequence referenced twice keeps both alias sites")
func test_sequenceAliasTwoReferences() throws {
let source = "list: &items [1, 2]\na: *items\nb: *items\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
guard case let .mapping(mapping) = reparsed else {
Issue.record("expected mapping")
return
}
#expect(mapping["a"]?.kind == .alias)
#expect(mapping["b"]?.kind == .alias)
}
@Test("Explicit tags on scalars and collections round-trip")
func test_explicitTagsRoundTrip() throws {
let source = "count: !!str 42\nthing: !Custom widget\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
guard case let .mapping(mapping) = reparsed else {
Issue.record("expected mapping")
return
}
#expect(mapping["count"]?.tag == .string)
#expect(mapping["thing"]?.tag == .init("!Custom"))
}
@Test("Flow style on a nested collection survives a block document")
func test_perNodeFlowStyleSurvives() throws {
let source = "outer:\n inner: [1, 2, 3]\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("[1, 2, 3]"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("Literal block scalar style survives dump and reparse")
func test_literalBlockStyleSurvives() throws {
let source = "log: |\n line one\n line two\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("|"))
let reparsed = try PureYAML.parseTagged(dumped)
guard
case let .mapping(expected) = parsed,
case let .scalar(expectedScalar)? = expected["log"],
case let .mapping(actual) = reparsed,
case let .scalar(actualScalar)? = actual["log"]
else {
Issue.record("expected scalar log values")
return
}
#expect(actualScalar.value == expectedScalar.value)
#expect(actualScalar.style == .literal)
}
@Test("Single and double quoted scalar styles are honored on dump")
func test_quotedStylesHonored() {
let single = PureYAML.Tagged.Node.scalar(.init(
rawValue: "it's",
value: .string("it's"),
style: .singleQuoted,
))
#expect(PureYAML.dump(single) == "'it''s'\n")
let double = PureYAML.Tagged.Node.scalar(.init(
rawValue: "plain",
value: .string("plain"),
style: .doubleQuoted,
))
#expect(PureYAML.dump(double) == "\"plain\"\n")
}
@Test("A hand-built anchored tree dumps anchors and aliases")
func test_handBuiltTreeDumps() throws {
let shared = PureYAML.Tagged.Node.scalar(.init(
rawValue: "reused",
value: .string("reused"),
anchor: "common",
style: .plain,
))
let tree = PureYAML.Tagged.Node.mapping(.init(
pairs: [
.init(
keyNode: .scalar(.init(rawValue: "original", value: .string("original"), style: .plain)),
value: shared,
),
.init(
keyNode: .scalar(.init(rawValue: "reference", value: .string("reference"), style: .plain)),
value: .alias(anchor: "common", node: shared),
),
],
style: .block,
))
let dumped = PureYAML.dump(tree)
#expect(dumped == "original: &common reused\nreference: *common\n")
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == tree)
}
@Test("An anchored empty collection round-trips")
func test_anchoredEmptyCollections() throws {
let source = "empty_map: &em {}\nempty_seq: &es []\nref: *em\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("modelValueErasingTags reads through aliases to expanded values")
func test_modelValueReadsThroughAliases() throws {
let source = "first: &shared value\nsecond: *shared\n"
let tagged = try PureYAML.parseTagged(source)
let expanded = try PureYAML.parse(source)
#expect(tagged.modelValueErasingTags == expanded)
}
@Test("Alias topology survives, not just values: reference stays a reference")
func test_aliasTopologyNotJustValues() throws {
let source = "a: &x deep\nb: *x\nc: deep\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
let reparsed = try PureYAML.parseTagged(dumped)
guard case let .mapping(mapping) = reparsed else {
Issue.record("expected mapping")
return
}
#expect(mapping["b"]?.kind == .alias)
#expect(mapping["c"]?.kind == .scalar)
}
@Test("Aliases nested inside anchored collections round-trip")
func test_nestedAliasInsideAnchoredCollection() throws {
let source = """
base: &b
name: &n shared-name
alias_inside: *n
copy: *b
"""
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("The validator does not duplicate issues through alias references")
func test_validatorSkipsAliasSubtrees() throws {
let source = "a: &dup\n k: 1\n k: 2\nb: *dup\nc: *dup\n"
let node = try PureYAML.parseTagged(source)
let result = PureYAML.Tagged.Validator().collect(node)
#expect(result.values.count == 1)
}
}
/// Issue #12/#13 coverage: alias keys, anchored keys, and key presentation
/// styles carried on `Tagged.Pair.keyNode`.
@Suite("Tagged mapping key nodes round-trip")
struct TaggedMappingKeyNodeTests {
@Test("An alias mapping key parses and re-emits as a reference")
func test_aliasKeyRoundTrips() throws {
let source = "&a a: &b b\n*b : *a\n"
let parsed = try PureYAML.parseTagged(source)
guard case let .mapping(mapping) = parsed else {
Issue.record("expected mapping")
return
}
#expect(mapping.pairs[1].keyNode.kind == .alias)
#expect(mapping.pairs[1].key == "b")
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("*b :"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("The nested alias-key form from the test suite round-trips")
func test_nestedAliasKeyRoundTrips() throws {
let source = "top1: &x scalar1\ntop3:\n *x : scalar3\n"
let parsed = try PureYAML.parseTagged(source)
let reparsed = try PureYAML.parseTagged(PureYAML.dump(parsed))
#expect(reparsed == parsed)
#expect(try parsed.modelValueErasingTags == (PureYAML.parse(source)))
}
@Test("A quoted key keeps its quoting through dump and reparse")
func test_quotedKeyStyleRoundTrips() throws {
let source = "\"on\": true\nplain: false\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("\"on\":"))
#expect(dumped.contains("plain:"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
@Test("An anchored key's anchor survives and resolves for later aliases")
func test_anchoredKeyAnchorSurvives() throws {
let source = "&k name: 1\nref: *k\n"
let parsed = try PureYAML.parseTagged(source)
let dumped = PureYAML.dump(parsed)
#expect(dumped.contains("&k name:"))
#expect(dumped.contains("*k"))
let reparsed = try PureYAML.parseTagged(dumped)
#expect(reparsed == parsed)
}
}