diff --git a/src/EcologicalNetworksDynamics.jl b/src/EcologicalNetworksDynamics.jl index e206fc686..5abefbf9e 100644 --- a/src/EcologicalNetworksDynamics.jl +++ b/src/EcologicalNetworksDynamics.jl @@ -1,5 +1,9 @@ module EcologicalNetworksDynamics +# Improve APIs comfort. +include("./aliasing_dicts.jl") +using .AliasingDicts + # The entire implementation has been brutally made private # so that we can focus on constructing # an implementation-independent API on top of it, from scratch. diff --git a/src/Internals/Internals.jl b/src/Internals/Internals.jl index d3d3d49ef..647b6cd06 100644 --- a/src/Internals/Internals.jl +++ b/src/Internals/Internals.jl @@ -38,6 +38,8 @@ using Statistics using Decimals using SciMLBase +using ..AliasingDicts + const Solution = SciMLBase.AbstractODESolution include(joinpath(".", "macros.jl")) diff --git a/src/Internals/aliasing_dict.jl b/src/Internals/aliasing_dict.jl deleted file mode 100644 index bfc165105..000000000 --- a/src/Internals/aliasing_dict.jl +++ /dev/null @@ -1,314 +0,0 @@ -# Design a data structure behaving like a julia's Dict{Symbol, T}, -# but alternate references can be given as a key, aka key aliases. -# Constructing the type requires providing aliases specifications, -# under the form of an 'AliasingSystem'. -# Terminology: -# - "reference": anything given by user to access data in the structure. -# - "standard": the actual key used to store the data -# - "alias": non-standard key possibly used to access the data. -# The structure should protect from ambiguous aliasing specifications. - -""" -(not exported, so we need these few `EcologicalNetworksDynamics.Internals.` adjustments in doctest) - -```jldoctest -julia> import EcologicalNetworksDynamics.Internals: OrderedCollections.OrderedDict - -julia> import EcologicalNetworksDynamics.Internals: - AliasingSystem, create_aliased_dict_type, AliasingError - -julia> import EcologicalNetworksDynamics.Internals: standards, references, aliases, standardize - -julia> import EcologicalNetworksDynamics.Internals: name, is, isin, shortest - -julia> al = AliasingSystem("fruit", (:apple => [:ap, :a], :pear => [:p, :pe])); - -julia> length(al) -2 - -julia> [s for s in standards(al)] # Original order is conserved. -2-element Vector{Symbol}: - :apple - :pear - -julia> [r for r in references(al)] # Shortest then lexicographic. -6-element Vector{Symbol}: - :a - :ap - :apple - :p - :pe - :pear - -julia> aliases(al) # Cheat-sheet. -OrderedDict{Symbol, Vector{Symbol}} with 2 entries: - :apple => [:a, :ap] - :pear => [:p, :pe] - -julia> name(al) -"fruit" - -julia> standardize('p', al), standardize(:ap, al) -(:pear, :apple) - -julia> is(:pe, :pear, al), is(:a, :pear, al) # Test references equivalence. -(true, false) - -julia> isin(:p, (:pear, :apple), al) # Find in iterable. -true - -julia> shortest(:apple, al) -:a - -julia> standardize(:xy, al) -ERROR: AliasingError("Invalid fruit name: 'xy'.") -[...] - -julia> AliasingSystem("fruit", ("peach" => ['p', 'h'], "pear" => ['r', 'p'])) -ERROR: AliasingError("Ambiguous fruit reference: 'p' either means 'peach' or 'pear'.") -[...] - -julia> AliasingSystem("fruit", ("peach" => ['h', 'e'], "pear" => ['p', 'p'])) -ERROR: AliasingError("Duplicated fruit alias for 'pear': 'p'.") -[...] -``` -""" -struct AliasingSystem - # {standard ↦ [ref, ref, ref..]} with refs sorted by length then lexicog. - _references::OrderedDict{Symbol,Vector{Symbol}} # Standards are ordered by user. - # {reference ↦ standard} - _surjection::Dict{Symbol,Symbol} - # What kind of things are we referring to? - name::String - - # Construct from a non-sorted aliases dict. - function AliasingSystem(name, g) - references = OrderedDict() - surjection = Dict() - for (std, aliases) in g - std = Symbol(std) - refs = vcat([Symbol(a) for a in aliases], [std]) - references[std] = sort!(sort!(refs); by = x -> length(string(x))) - for ref in refs - # Protect from ambiguity. - if ref in keys(surjection) - target = surjection[ref] - if target == std - throw(AliasingError("Duplicated $name alias for '$std': '$ref'.")) - end - throw( - AliasingError( - "Ambiguous $name reference: " * - "'$ref' either means '$target' or '$std'.", - ), - ) - end - surjection[ref] = std - end - end - new(references, surjection, name) - end -end -struct AliasingError <: Exception - message::String -end -Base.length(a::AliasingSystem) = Base.length(a._references) -# Access copies of underlying aliasing information. -# (not actual references: we do *not* want the alias system to be mutated) -name(a::AliasingSystem) = string(a.name) -standards(a::AliasingSystem) = (r for r in keys(a._references)) -references(a::AliasingSystem) = (r for refs in values(a._references) for r in refs) -# One cheat-sheet with all standards, their order and aliases. -aliases(a::AliasingSystem) = - OrderedDict(s => [r for r in references(s, a) if r != s] for s in standards(a)) -function standardize(ref, a::AliasingSystem) - key = Symbol(ref) - if key in references(a) - return a._surjection[key] - end - throw(AliasingError("Invalid $(a.name) name: '$ref'.")) -end -# Get all alternate references. -references(ref, a::AliasingSystem) = (r for r in a._references[standardize(ref, a)]) -# Get first alias (shortest + earliest lexically). -shortest(ref, a::AliasingSystem) = first(references(ref, a)) -# Match reference to others, regardless of aliasing. -is(ref_a, ref_b, a::AliasingSystem) = standardize(ref_a, a) == standardize(ref_b, a) -isin(ref, refs, a::AliasingSystem) = - any(standardize(ref, a) == standardize(r, a) for r in refs) - - -""" -Generate an "AliasDict" type, with all associated methods, from an aliasing system. - -```jldoctest AliasDict -julia> import EcologicalNetworksDynamics.Internals: OrderedCollections.OrderedDict - -julia> import EcologicalNetworksDynamics.Internals: - AliasingError, create_aliased_dict_type, AliasingError - -julia> import EcologicalNetworksDynamics.Internals: standards, references, aliases, standardize - -julia> import EcologicalNetworksDynamics.Internals: name, is, isin, shortest - -julia> create_aliased_dict_type(:FruitDict, "fruit", (:apple => [:a], :berry => [:b, :br])) - -julia> import EcologicalNetworksDynamics.Internals: FruitDict # (not exported) - -julia> FruitDict() -FruitDict{Any}() - -julia> d = FruitDict(:a => 5, :b => 8.0) # Mimick Dict constructor. -FruitDict{Real} with 2 entries: - :apple => 5 - :berry => 8.0 - -julia> d = FruitDict(:a => 5, :b => 8.0, :berry => 7.0) # Guard against ambiguities. -ERROR: AliasingError("Fruit type 'berry' specified twice: once with 'b' and once with 'berry'.") -[...] - -julia> d = FruitDict(; a = 5, b = 8.0) # Take advantage of Symbol indexing to use this form. -FruitDict{Real} with 2 entries: - :apple => 5 - :berry => 8.0 - -julia> d = FruitDict(; b = 5, a = 8.0) # Entries are (re)-ordered to standard order. -FruitDict{Real} with 2 entries: - :apple => 8.0 - :berry => 5 - -julia> d[:a], d[:apple], d['a'], d["apple"] # Index with anything consistent. -(8.0, 8.0, 8.0, 8.0) - -julia> d[:a] = 40; # Set values. - d[:apple] -40 - -julia> haskey(d, :b), haskey(d, :berry) -(true, true) - -julia> length(d) # Still, no superfluous value is stored. -2 - -julia> d[:xy] = 50 # Guard against inconsistent entries. -ERROR: AliasingError("Invalid fruit name: 'xy'.") -[...] - -julia> [r for r in references(d)] # Access AliasingSystem methods from the instance.. -5-element Vector{Symbol}: - :a - :apple - :b - :br - :berry - -julia> :b in references(d), :xy in references(FruitDict) # .. or from the type itself. -(true, false) - -julia> name(d), name(FruitDict) -("fruit", "fruit") - -julia> shortest(:berry, d), shortest(:berry, FruitDict) -(:b, :b) - -julia> standardize(:br, d), standardize(:apple, FruitDict) -(:berry, :apple) - -julia> [r for r in references(:br, d)] -3-element Vector{Symbol}: - :b - :br - :berry -``` -""" -function create_aliased_dict_type(type_name, system_name, g) - DictName = Symbol(type_name) - - # Mutable, but protected here as a mute variable within this function. - alias_system = AliasingSystem(system_name, g) - - eval( - quote - - # A "newtype" pattern just wrapping a plain Dict. - struct $DictName{T} <: AbstractDict{Symbol,T} - _d::Dict{Symbol,T} - - end - - # Defer basic interface to the interface of dict. - Base.length(adict::$DictName) = length(adict._d) - Base.merge(a::$DictName, b::$DictName) = $DictName(merge(a._d, b._d)) - Base.iterate(adict::$DictName) = Base.iterate(adict._d) - Base.iterate(adict::$DictName, state) = Base.iterate(adict._d, state) - Base.haskey(adict::$DictName, ref) = - Base.haskey(adict._d, standardize(ref, adict)) - Base.:(==)(a::$DictName, b::$DictName) = a._d == b._d - - # Correct data access with aliasing ystem. - Base.getindex(adict::$DictName, ref) = - (Base.getindex(adict._d, standardize(ref, $alias_system))) - Base.setindex!(adict::$DictName, v, ref) = - (Base.setindex!(adict._d, v, standardize(ref, $alias_system))) - - # Construct. - $DictName{T}() where {T} = $DictName{T}(Dict{Symbol,T}()) - $DictName(args...) = $DictName((k => v) for (k, v) in args) - $DictName(; args...) = $DictName((k => v) for (k, v) in args) - function $DictName(g::Base.Generator) - T = pair_second_type(Dict(g)) - d = Dict{Symbol,T}() - # Guard against redundant/ambiguous specifications. - norm = Dict() # standard => ref - for (ref, value) in g - standard = standardize(ref, $alias_system) - if standard in keys(norm) - aname = titlecase($alias_system.name) - throw( - AliasingError( - "$aname type '$standard' specified twice: " * - "once with '$(norm[standard])' " * - "and once with '$ref'.", - ), - ) - end - norm[standard] = ref - d[standard] = value - end - $DictName{T}(d) - end - - # Access underlying AliasingSystem information. - for (fn, first_args) in [ - (:name, [()]), - (:standards, [()]), - (:aliases, [()]), - (:references, [(), (:ref,)]), - (:standardize, [(:ref,)]), - (:shortest, [(:ref,)]), - (:is, [(:ref_a, :ref_b)]), - (:isin, [(:ref, :refs)]), - ] - for code in [ - # Versions from the raw unspecialized type. - :($fn(::Type{$$DictName}) where {} = $fn($$alias_system)), - # Versions from the specialized type. - :($fn(::Type{$$DictName{T}}) where {T} = $fn($$alias_system)), - # Versions from an instance. - :($fn(::$$DictName{T}) where {T} = $fn($$DictName)), - ], - fargs in first_args - - for a in fargs - insert!(code.args[1].args[1].args, 2, a) - insert!(code.args[2].args[2].args, 2, a) - end - eval(code) - end - end - - end, - ) -end -# Utility to the above. -pair_second_type(::Dict{A,B}) where {A,B} = B diff --git a/src/Internals/inputs/MultiplexNetwork_signature.jl b/src/Internals/inputs/MultiplexNetwork_signature.jl index 6fd9401a3..e79f6f0ea 100644 --- a/src/Internals/inputs/MultiplexNetwork_signature.jl +++ b/src/Internals/inputs/MultiplexNetwork_signature.jl @@ -2,12 +2,12 @@ # to make the sophisticated signature of the MultiplexNetwork() function work. # For use within this file. -pstandards() = standards(MultiplexParametersDict) -istandards() = standards(InteractionDict) -pstandard(p) = standardize(p, MultiplexParametersDict) -istandard(i) = standardize(i, InteractionDict) -p_references() = references(MultiplexParametersDict) -i_references() = references(InteractionDict) +pstandards() = AliasingDicts.standards(MultiplexParametersDict) +istandards() = AliasingDicts.standards(InteractionDict) +pstandard(p) = AliasingDicts.standardize(p, MultiplexParametersDict) +istandard(i) = AliasingDicts.standardize(i, InteractionDict) +p_references() = AliasingDicts.references(MultiplexParametersDict) +i_references() = AliasingDicts.references(InteractionDict) ############################################################################################ # Protect against aliasing ambiguity. ###################################################### @@ -171,7 +171,7 @@ function parse_MultiplexNetwork_arguments(foodweb, args) for ArgType in [ParmIntNestedArg, IntParmNestedArg] ro = reorder(ArgType) FirstDict, NestedDict = ro(MultiplexParametersDict, InteractionDict) - if arg in references(FirstDict) + if arg in AliasingDicts.references(FirstDict) found_transversal = true try # Scroll sub-arguments within the nested specification. @@ -335,14 +335,14 @@ function parse_MultiplexNetwork_arguments(foodweb, args) # Anything not provided by user is set to default. # During this step, drop arguments name informations (eg. (arg, V) -> V). for parm in pstandards(), int in istandards() - if isin(parm, [:C, :L, :symmetry], MultiplexParametersDict) + if AliasingDicts.isin(parm, [:C, :L, :symmetry], MultiplexParametersDict) # These annex parameters don't need defaults. continue end if !already(parm, int) # Missing: read from defaults. def = defaults[parm][int] - if is(parm, :A, MultiplexParametersDict) + if AliasingDicts.is(parm, :A, MultiplexParametersDict) # Special case: this default is a function of the foodweb. def = def(foodweb) end diff --git a/src/Internals/inputs/nontrophic_interactions.jl b/src/Internals/inputs/nontrophic_interactions.jl index 07644b81e..5b20af915 100644 --- a/src/Internals/inputs/nontrophic_interactions.jl +++ b/src/Internals/inputs/nontrophic_interactions.jl @@ -22,9 +22,8 @@ end # The official list of supported interactions (one per possible layer), # their aliases (typically shortened names) # and their canonical order . -include("../aliasing_dict.jl") -create_aliased_dict_type( - :InteractionDict, +@aliasing_dict( + InteractionDict, "interaction", ( :trophic => [:t, :trh], @@ -47,8 +46,8 @@ end # There are also constraints on the 'parameter' part, # because not all of them can be correctly combined together. -create_aliased_dict_type( - :MultiplexParametersDict, +@aliasing_dict( + MultiplexParametersDict, "layer_parameter", ( # This mirrors fields in Layer type.. diff --git a/src/aliasing_dicts.jl b/src/aliasing_dicts.jl new file mode 100644 index 000000000..2f41b50d9 --- /dev/null +++ b/src/aliasing_dicts.jl @@ -0,0 +1,261 @@ +module AliasingDicts + +using OrderedCollections + +# Design a data structure behaving like a julia's Dict{Symbol, T}, +# but alternate references can be given as a key, aka key aliases. +# Constructing the type requires providing aliases specifications, +# under the form of an 'AliasingSystem'. +# Terminology: +# - "reference": anything given by user to access data in the structure. +# - "standard": the actual key used to store the data +# - "alias": non-standard key possibly used to access the data. +# The structure should protect from ambiguous aliasing specifications. + +#------------------------------------------------------------------------------------------- +# The aliasing system is the value taking care +# of bookeeping aliases, references and standards. + +struct AliasingSystem + # {standard ↦ [ref, ref, ref..]} with refs sorted by length then lexicog. + _references::OrderedDict{Symbol,Vector{Symbol}} # Standards are ordered by user. + # {reference ↦ standard} + _surjection::Dict{Symbol,Symbol} + # What kind of things are we referring to? (useful for error messages) + name::String + + # Construct from a non-sorted aliases dict. + function AliasingSystem(name, g) + err(mess) = throw(AliasingError(name, mess)) + references = OrderedDict() + surjection = Dict() + for (std, aliases) in g + std = Symbol(std) + refs = vcat([Symbol(a) for a in aliases], [std]) + references[std] = sort!(sort!(refs); by = x -> length(string(x))) + for ref in refs + # Protect from ambiguity. + if ref in keys(surjection) + target = surjection[ref] + if target == std + err("Duplicated $name alias for '$std': '$ref'.") + end + err( + "Ambiguous $name reference: " * + "'$ref' either means '$target' or '$std'.", + ) + end + surjection[ref] = std + end + end + new(references, surjection, name) + end +end + +Base.length(a::AliasingSystem) = Base.length(a._references) + +# Access copies of underlying aliasing information. +# (not actual references: we do *not* want the alias system to be mutated) +name(a::AliasingSystem) = string(a.name) +standards(a::AliasingSystem) = (r for r in keys(a._references)) +references(a::AliasingSystem) = (r for refs in values(a._references) for r in refs) + +# One cheat-sheet with all standards, their order and aliases. +aliases(a::AliasingSystem) = + OrderedDict(s => [r for r in references(s, a) if r != s] for s in standards(a)) +function standardize(ref, a::AliasingSystem) + key = Symbol(ref) + if key in references(a) + return a._surjection[key] + end + throw(AliasingError(a.name, "Invalid $(a.name) name: '$ref'.")) +end + +# Get all alternate references. +references(ref, a::AliasingSystem) = (r for r in a._references[standardize(ref, a)]) + +# Get first alias (shortest + earliest lexically). +shortest(ref, a::AliasingSystem) = first(references(ref, a)) + +# Match reference to others, regardless of aliasing. +is(ref_a, ref_b, a::AliasingSystem) = standardize(ref_a, a) == standardize(ref_b, a) +isin(ref, refs, a::AliasingSystem) = + any(standardize(ref, a) == standardize(r, a) for r in refs) + +#------------------------------------------------------------------------------------------- +# The actual aliasing dict type internally refers to one aliasing system to work. +abstract type AliasingDict{T} <: AbstractDict{Symbol,T} end + +# Defer basic interface to the interface of dict, +# assuming all subtypes are of the form: +# struct Sub <: AliasingDict{T} +# _d::Dict{Symbol,T} +# end +Base.getindex(a::AliasingDict, k) = Base.getindex(a._d, standardize(k, a)) +Base.setindex!(a::AliasingDict, k, v) = Base.setindex!(a._d, standardize(k, a), v) +Base.get(a::AliasingDict, k, d) = Base.get(a._d, standardize(k, a), d) +Base.get!(a::AliasingDict, k, d) = Base.get!(a._d, standardize(k, a), d) +Base.get(f, a::AliasingDict, k) = Base.get(f, a._d, standardize(k, a)) +Base.get!(f, a::AliasingDict, k) = Base.get!(f, a._d, standardize(k, a)) +Base.haskey(a::AliasingDict, k) = Base.haskey(a._d, standardize(k, a)) +Base.length(a::AliasingDict) = length(a._d) +Base.merge(a::AliasingDict, b::AliasingDict) = AliasingDict(merge(a._d, b._d)) +Base.iterate(a::AliasingDict) = Base.iterate(a._d) +Base.iterate(a::AliasingDict, state) = Base.iterate(a._d, state) +Base.:(==)(a::AliasingDict, b::AliasingDict) = a._d == b._d +# Forward all basic request on instances to the actual types. + +# The methods for types are defined within the type definition macro. +name(a::AliasingDict) = name(typeof(a)) +standards(a::AliasingDict) = standards(typeof(a)) +aliases(a::AliasingDict) = aliases(typeof(a)) +references(a::AliasingDict) = references(typeof(a)) +references(ref, a::AliasingDict) = references(ref, typeof(a)) +standardize(ref, a::AliasingDict) = standardize(ref, typeof(a)) +shortest(ref, a::AliasingDict) = shortest(ref, typeof(a)) +is(ref_a, ref_b, a::AliasingDict) = is(ref_a, ref_b, typeof(a)) +isin(ref, refs, a::AliasingDict) = isin(ref, refs, typeof(a)) + +# Generate a correct subtype for the above class, +# with the associated aliasing system. +macro aliasing_dict(DictName, system_name, g_xp) + argerr(mess) = throw(ArgumentError(mess)) + + DictName isa Symbol || + argerr("Not a symbol name for an aliasing dict type: $(repr(DictName)).") + + system_name isa String || + argerr("Not a string name for an aliasing dict type: $(repr(system_name)).") + + # The aliasing system is unfortunately mutable: do not expose to the invoker. + alias_system = Symbol(DictName, :_alias_system) + + # Type/methods generation. + DictName = esc(DictName) + res = quote + + # Unexposed as unescaped here. + $alias_system = $AliasingSystem($system_name, $g_xp) + + # Newtype a plain dict. + struct $DictName{T} <: $AliasingDict{T} + _d::Dict{Symbol,T} + + # Construct from generator with explicit type. + function $DictName{T}(::$Type{$InnerConstruct}, generator) where {T} + d = Dict{Symbol,T}() + # Guard against redundant/ambiguous specifications. + norm = Dict{Symbol,Symbol}() # standard => ref + for (ref, value) in generator + standard = $standardize(ref, $alias_system) + if standard in keys(norm) + aname = titlecase($alias_system.name) + throw( + $AliasingError( + $system_name, + "$aname type '$standard' specified twice: " * + "once with '$(norm[standard])' " * + "and once with '$ref'.", + ), + ) + end + norm[standard] = ref + d[standard] = value + end + new{T}(d) + end + + end + + # Infer common type from pairs, and automatically convert keys to symbols. + function $DictName(args::Pair...) + g = ((Symbol(k), v) for (k, v) in args) + $DictName{$common_type_for(g)}($InnerConstruct, g) + end + # Same with keyword arguments as keys, default to Any for empty dict. + $DictName(; kwargs...) = + if isempty(kwargs) + $DictName{Any}($InnerConstruct, ()) + else + $DictName{$common_type_for(kwargs)}($InnerConstruct, kwargs) + end + # Automatically convert keys to symbols, and values to the given T. + $DictName{T}(args...) where {T} = + $DictName{T}($InnerConstruct, ((Symbol(k), v) for (k, v) in args)) + # Same with keyword arguments as keys. + $DictName{T}(; kwargs...) where {T} = $DictName{T}($InnerConstruct, kwargs) + + # Correct data access with aliasing ystem. + $Base.getindex(adict::$DictName, ref) = + (Base.getindex(adict._d, $standardize(ref, $alias_system))) + $Base.setindex!(adict::$DictName, v, ref) = + (Base.setindex!(adict._d, v, $standardize(ref, $alias_system))) + + end + + # Specialize dedicated methods to access underlying AliasingSystem information. + push_res!(quoted) = push!(res.args, quoted.args[2]) + for (fn, first_args) in [ + (:name, [()]), + (:standards, [()]), + (:aliases, [()]), + (:references, [(), (:ref,)]), + (:standardize, [(:ref,)]), + (:shortest, [(:ref,)]), + (:is, [(:ref_a, :ref_b)]), + (:isin, [(:ref, :refs)]), + ] + + fn = :($AliasingDicts.$fn) + + for fargs in first_args + + # Specialize for the UnionAll type. + code = quote + $fn(::Type{$DictName}) = $fn($alias_system) + end + for a in fargs + insert!(code.args[2].args[1].args, 2, a) + insert!(code.args[2].args[2].args[2].args, 2, a) + end + push_res!(code) + + # Specialize for the DataType. + code = quote + $fn(::Type{$DictName{T}}) where {T} = $fn($alias_system) + end + for a in fargs + insert!(code.args[2].args[1].args[1].args, 2, a) + insert!(code.args[2].args[2].args[2].args, 2, a) + end + push_res!(code) + + end + + end + + res + +end +export @aliasing_dict + +# Marker dispatching to the underlying constructor. +struct InnerConstruct end + +# Extract the less abstract common type from the given keyword arguments. +function common_type_for(pairs_generator) + GItem = Base.@default_eltype(pairs_generator) # .. provided I am allowed to use this? + T = GItem.parameters[2] + return T +end + +# Dedicated exception type. +struct AliasingError <: Exception + name::String + message::String +end +function Base.showerror(io::IO, e::AliasingError) + print(io, "In aliasing system for $(repr(e.name)): $(e.message)") +end + +end diff --git a/test/aliasing_dicts.jl b/test/aliasing_dicts.jl new file mode 100644 index 000000000..5d995d3b7 --- /dev/null +++ b/test/aliasing_dicts.jl @@ -0,0 +1,164 @@ +module TestAliasingDicts + +using EcologicalNetworksDynamics.AliasingDicts +using ..TestFailures +import .AliasingDicts as AD +using Test + +function TestFailures.check_exception(e::AD.AliasingError, name, message_pattern) + e.name == name || error("Expected error for '$name' aliasing system, got '$(e.name)'.") + TestFailures.check_message(message_pattern, eval(e.message)) +end + +macro aliasfails(xp, name, mess) + TestFailures.fails_with( + __source__, + __module__, + xp, + :($(AD.AliasingError) => ($name, $mess)), + false, + ) +end + +# Leverage `repr` summarize most tested features within a single string comparison. +macro check(xp, repr, type) + esc(quote + d = $xp + @test repr(d) == $repr + @test d isa FruitDict{$type} + end) +end + +@testset "Aliased references" begin + + #--------------------------------------------------------------------------------------- + # Test internal aliasing system. + + al = AD.AliasingSystem("fruit", (:apple => [:ap, :a], :pear => [:p, :pe])) + @test length(al) == 2 + + # Original order is conserved. + @test [s for s in AD.standards(al)] == [:apple, :pear] + + # Shortest then lexicographic. + @test [r for r in AD.references(al)] == [:a, :ap, :apple, :p, :pe, :pear] + + # Cheat-sheet. + @test AD.aliases(al) == AD.OrderedDict(:apple => [:a, :ap], :pear => [:p, :pe]) + + @test AD.name(al) == "fruit" + + @test AD.standardize('p', al) == :pear + @test AD.standardize(:ap, al) == :apple + + # Test references equivalence. + @test AD.is(:pe, :pear, al) + @test !AD.is(:a, :pear, al) + + # Find in iterable. + @test AD.isin(:p, (:pear, :apple), al) + + @test AD.shortest(:apple, al) == :a + + # Guard against invalid or ambiguous referencing. + @aliasfails(AD.standardize(:xy, al), "fruit", "Invalid fruit name: 'xy'.") + @aliasfails( + AD.AliasingSystem("fruit", ("peach" => ['p', 'h'], "pear" => ['r', 'p'])), + "fruit", + "Ambiguous fruit reference: 'p' either means 'peach' or 'pear'.", + ) + @aliasfails( + AD.AliasingSystem("fruit", ("peach" => ['h', 'e'], "pear" => ['p', 'p'])), + "fruit", + "Duplicated fruit alias for 'pear': 'p'.", + ) + + #--------------------------------------------------------------------------------------- + # Construct an actual "AliasDict" type, + # with all associated methods, + # from an aliasing system. + @aliasing_dict(FruitDict, "fruit", (:apple => [:a], :berry => [:b, :br])) + + # Fit into the type system. + @test FruitDict <: AbstractDict + @test FruitDict{Any} <: AbstractDict{Symbol,Any} + @test FruitDict{Int64} <: AbstractDict{Symbol,Int64} + + # Construct with or without an explicit type, concrete or abstract. + # If heterogeneous, the values type is inferred to the less abstract common type. + # Keys are implicitly converted to symbols if possible. + + # Empty. + @check FruitDict() "$FruitDict{Any}()" Any + @check FruitDict{Int64}() "$FruitDict{Int64}()" Int64 + @check FruitDict{Real}() "$FruitDict{Real}()" Real + + # With single kwarg. + @check FruitDict(; a = 5.0) "$FruitDict(:apple => 5.0)" Float64 + @check FruitDict{Int64}(; a = 5.0) "$FruitDict(:apple => 5)" Int64 # (conversion) + @check FruitDict{Real}(; a = 5.0) "$FruitDict{Real}(:apple => 5.0)" Real # (retained abstract) + + # With single arg. + @check FruitDict(:a => 5.0) "$FruitDict(:apple => 5.0)" Float64 + @check FruitDict('a' => 5.0) "$FruitDict(:apple => 5.0)" Float64 + @check FruitDict{Int64}(:a => 5.0) "$FruitDict(:apple => 5)" Int64 + @check FruitDict{Int64}('a' => 5.0) "$FruitDict(:apple => 5)" Int64 # (key conversion) + @check FruitDict{Real}(:a => 5.0) "$FruitDict{Real}(:apple => 5.0)" Real + @check FruitDict{Real}("a" => 5.0) "$FruitDict{Real}(:apple => 5.0)" Real# (key conversion) + + # With double kwargs. + @check FruitDict(; a = 5, b = 8.0) "$FruitDict{Real}(:apple => 5, :berry => 8.0)" Real + @check FruitDict{Int64}(; a = 5, b = 8.0) "$FruitDict(:apple => 5, :berry => 8)" Int64 + @check FruitDict{Real}(; a = 5, b = 8.0) "$FruitDict{Real}(:apple => 5, :berry => 8.0)" Real + + # With double args. + @check FruitDict(:a => 5, :b => 8.0) "$FruitDict{Real}(:apple => 5, :berry => 8.0)" Real + @check FruitDict('a' => 5, "b" => 8.0) "$FruitDict{Real}(:apple => 5, :berry => 8.0)" Real + @check FruitDict{Int64}(:a => 5, :b => 8.0) "$FruitDict(:apple => 5, :berry => 8)" Int64 + @check FruitDict{Int64}('a' => 5, "b" => 8.0) "$FruitDict(:apple => 5, :berry => 8)" Int64 + @check FruitDict{Real}(:a => 5, :b => 8.0) "$FruitDict{Real}(:apple => 5, :berry => 8.0)" Real + @check FruitDict{Real}('a' => 5, "b" => 8.0) "$FruitDict{Real}(:apple => 5, :berry => 8.0)" Real + + # Guard against ambiguities. + @aliasfails( + FruitDict(:a => 5, :b => 8.0, :berry => 7.0), + "fruit", + "Fruit type 'berry' specified twice: once with 'b' and once with 'berry'.", + ) + + # Entries are (re)-ordered to standard order. + @test collect(keys(FruitDict(; b = 8.0, a = 5))) == [:apple, :berry] + + # Index with anything consistent. + @test (d[:a], d[:apple], d['a'], d["apple"]) == (5, 5, 5, 5) + + # Set values. + d[:a] = 42 + @test d[:apple] == 42 + + @test haskey(d, :b) + @test haskey(d, :berry) + + # Guard against inconsistent entries. + @aliasfails(d[:xy], "fruit", "Invalid fruit name: 'xy'.") + + # Access underlying aliasing system methods from the instance.. + @test AD.name(d) == "fruit" + @test collect(AD.references(d)) == [:a, :apple, :b, :br, :berry] + @test collect(AD.references(:br, d)) == [:b, :br, :berry] + @test (:b in AD.references(d), :xy in AD.references(d)) == (true, false) + @test AD.shortest(:berry, d) == :b + @test (AD.standardize(:br, d), AD.standardize(:apple, d)) == (:berry, :apple) + + # .. or from the type itself. + @test AD.name(FruitDict) == "fruit" + @test collect(AD.references(FruitDict)) == [:a, :apple, :b, :br, :berry] + @test collect(AD.references(:br, FruitDict)) == [:b, :br, :berry] + @test (:b in AD.references(FruitDict), :xy in AD.references(FruitDict)) == (true, false) + @test AD.shortest(:berry, FruitDict) == :b + @test (AD.standardize(:br, FruitDict), AD.standardize(:apple, FruitDict)) == + (:berry, :apple) + +end + +end diff --git a/test/runtests.jl b/test/runtests.jl index 98980fe75..b1f59c883 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -12,8 +12,8 @@ include("./failures.jl") sep("Test internals.") include("./internals/runtests.jl") -# System/Components framework for the API. -sep("Test API framework.") +sep("Test API utils.") +include("./aliasing_dicts.jl") include("./framework/runtests.jl") sep("Test user-facing behaviour.")