diff --git a/frontend/source/class/agrammon/module/input/NavBar.js b/frontend/source/class/agrammon/module/input/NavBar.js index 3aabaaa36..92f1574c9 100644 --- a/frontend/source/class/agrammon/module/input/NavBar.js +++ b/frontend/source/class/agrammon/module/input/NavBar.js @@ -601,6 +601,14 @@ qx.Class.define('agrammon.module.input.NavBar', { var olen = rec.options.length; metaData.options = rec.options; metaData.optionsLang = rec.optionsLang; + // Cross-version enum alias map ({alias: canonical}) — + // declared via `accepts =` in the .nhd. Used by + // NavFolder.setData (load), the Replace cell renderer + // (orange highlight + canonical label), and the + // PropTable replaceMap. + if (rec.enumAliases) { + metaData.enumAliases = rec.enumAliases; + } } else { var err = 'This should not happen: unknown variable type=' + rec.type + ' for variable ' + rec.variable; diff --git a/frontend/source/class/agrammon/module/input/NavFolder.js b/frontend/source/class/agrammon/module/input/NavFolder.js index c2481e6c2..8c9b29261 100644 --- a/frontend/source/class/agrammon/module/input/NavFolder.js +++ b/frontend/source/class/agrammon/module/input/NavFolder.js @@ -66,6 +66,14 @@ qx.Class.define('agrammon.module.input.NavFolder', { __propData: null, __childrenHash: null, __instanceOrder: null, + // Tracks whether any input under this folder currently holds a value + // that was display-mapped from a foreign-version enum alias. Drives + // the orange dot/circle in the navbar. Session-only; never persisted. + __mapped: false, + + isMapped: function() { + return this.__mapped; + }, destruct : function() { this.__propData = null; @@ -269,6 +277,20 @@ qx.Class.define('agrammon.module.input.NavFolder', { } } + // Aggregate mapped state from children: any descendant input + // holding a foreign-version alias makes this folder orange-when- + // otherwise-green. Missing data still wins (red), since orange + // is informational and must not mask incomplete inputs. + var mapped = false; + for (key in this.__childrenHash) { + if (typeof this.__childrenHash[key].isMapped === 'function' + && this.__childrenHash[key].isMapped()) { + mapped = true; + break; + } + } + this.__mapped = mapped; + // no direct data if (this.isPlain()) { if (complete == undefined) { @@ -277,22 +299,28 @@ qx.Class.define('agrammon.module.input.NavFolder', { complete = true; } } - else if (complete) { - this.setIcon('agrammon/green-dot.png'); + else if (!complete) { + this.setIcon('agrammon/red-dot.png'); + } + else if (mapped) { + this.setIcon('agrammon/orange-dot.png'); } else { - this.setIcon('agrammon/red-dot.png'); + this.setIcon('agrammon/green-dot.png'); } } if (this.canInstance()) { if (complete == undefined) { this.setIcon('agrammon/empty-circle.png'); } - else if (complete) { - this.setIcon('agrammon/green-circle.png'); + else if (!complete) { + this.setIcon('agrammon/red-circle.png'); + } + else if (mapped) { + this.setIcon('agrammon/orange-circle.png'); } else { - this.setIcon('agrammon/red-circle.png'); + this.setIcon('agrammon/green-circle.png'); } } if (this.isRoot()) { @@ -336,6 +364,7 @@ qx.Class.define('agrammon.module.input.NavFolder', { // not empty var complete = true; + var mapped = false; for (let i=0; i &default { %values{$input.name} //= default(Agrammon::Environment.new( - input => %values, + input => $module.canonicalize-input-hash(%values), technical => $module.technical-hash, technical-override => %technical{$taxonomy})) } diff --git a/lib/Agrammon/LanguageParser.rakumod b/lib/Agrammon/LanguageParser.rakumod index be1374e40..3875f942c 100644 --- a/lib/Agrammon/LanguageParser.rakumod +++ b/lib/Agrammon/LanguageParser.rakumod @@ -9,8 +9,16 @@ sub parse-lang-values(Str $value, Str $context --> Hash) is export { warn "Failed to parse language value for $context: ol=\>$ol\<"; next; } - $o ~~ s:g/_/ /; - %opt-lang{$l} = $o; + # `accepts` is a comma-separated list of foreign enum keys this + # option accepts as aliases (cross-version migration). Preserve + # underscores and split on commas; do not treat as language text. + if $l eq 'accepts' { + %opt-lang{$l} = $o.split(/\s* ',' \s*/).grep(*.chars).list; + } + else { + $o ~~ s:g/_/ /; + %opt-lang{$l} = $o; + } } %opt-lang } diff --git a/lib/Agrammon/Model.rakumod b/lib/Agrammon/Model.rakumod index 24ebe4dd3..b44e7a15b 100644 --- a/lib/Agrammon/Model.rakumod +++ b/lib/Agrammon/Model.rakumod @@ -130,7 +130,7 @@ class Agrammon::Model { my $*AGRAMMON-TAXONOMY = my $tax = $!module.taxonomy; my %*AGRAMMON-GUI = %(:de(@gui[1]), :fr(@gui[2]), :en(@gui[3])) if @gui; my $env = Agrammon::Environment.new( - input => $input.input-hash-for($tax), + input => $!module.canonicalize-input-hash($input.input-hash-for($tax)), technical => $!module.technical-hash, technical-override => %technical{$tax}, output => $outputs diff --git a/lib/Agrammon/Model/Input.rakumod b/lib/Agrammon/Model/Input.rakumod index 11b5b8de2..30509c30d 100644 --- a/lib/Agrammon/Model/Input.rakumod +++ b/lib/Agrammon/Model/Input.rakumod @@ -23,6 +23,7 @@ class Agrammon::Model::Input { has Str @.models; has @!enum-order; has %!enum-lookup; + has %!enum-aliases; has Int $.order; has Bool $!hidden = False; has Bool $!distribute = False; @@ -46,7 +47,20 @@ class Agrammon::Model::Input { $!distribute = .lc eq 'true'; } if @enum { - @!enum-order = @enum.map({ .key => parse-lang-values(.value, "input $!name") }); + @!enum-order = @enum.map({ + my $key = .key; + my %lang-values = parse-lang-values(.value, "input $!name"); + with %lang-values:delete -> $accepts { + for @$accepts -> $alias { + if %!enum-aliases{$alias}:exists { + warn "Duplicate enum alias '$alias' in input $!name " ~ + "(already maps to '%!enum-aliases{$alias}', now also '$key')"; + } + %!enum-aliases{$alias} = $key; + } + } + $key => %lang-values + }); %!enum-lookup = @!enum-order; } with $filter { @@ -63,7 +77,25 @@ class Agrammon::Model::Input { } method is-valid-enum-value($value) { - %!enum-lookup{$value}:exists + %!enum-lookup{$value}:exists or %!enum-aliases{$value}:exists + } + + #| Returns the canonical (locally declared) enum key for $value. + #| If $value is already a local key, returns it unchanged. If it is + #| a declared alias, returns the local key it maps to. Otherwise Nil. + method canonical-enum-value($value) { + return $value if %!enum-lookup{$value}:exists; + return %!enum-aliases{$value} // Nil; + } + + #| True iff $value is a declared alias (i.e. comes from another model + #| version) and is being mapped onto a local enum key. + method is-mapped-enum-value($value --> Bool) { + %!enum-aliases{$value}:exists + } + + method enum-aliases(--> Hash) { + %!enum-aliases } method is-distribute(--> Bool) { @@ -109,6 +141,7 @@ class Agrammon::Model::Input { hasFormula => $.default-formula.defined, )), :enum(%!enum-lookup), + :enumAliases(%!enum-aliases), :$!filter, :%!help, :%!labels, diff --git a/lib/Agrammon/Model/Module.rakumod b/lib/Agrammon/Model/Module.rakumod index a186812c0..a05ede212 100644 --- a/lib/Agrammon/Model/Module.rakumod +++ b/lib/Agrammon/Model/Module.rakumod @@ -49,4 +49,22 @@ class Agrammon::Model::Module { method set-instance-root(Str $!instance-root) {} method set-gui-root(Agrammon::Model::Module $!gui-root-module) {} + + #| Returns a copy of $input-hash with enum-typed input values canonicalized: + #| values that are declared `accepts =` aliases (e.g. cross-version + #| migration) are rewritten to the local enum key so that formulas only + #| ever see the canonical local values. Non-enum values pass through. + method canonicalize-input-hash(%input-hash --> Hash) { + my %out = %input-hash; + for @!input -> Agrammon::Model::Input $input { + next unless $input.type eq 'enum'; + my $name = $input.name; + with %out{$name} -> $value { + if $input.is-mapped-enum-value($value) { + %out{$name} = $input.canonical-enum-value($value); + } + } + } + %out + } } diff --git a/runWebDev.sh b/runWebDev.sh index 9b698ddaf..711ab5bf4 100755 --- a/runWebDev.sh +++ b/runWebDev.sh @@ -12,4 +12,10 @@ if ! podman container exists agrammon-dev-db 2>/dev/null; then fi export PERL5LIB=Inline/perl5 +# SOURCE_MODE=1 makes the Cro static-content routes serve the qooxdoo +# source target (frontend/compiled/source/) instead of the production +# build target (public/). Frontend edits then pick up after a single +# `npx qx compile` (or live via `npx qx compile --watch`) without +# needing a full minified rebuild. +export SOURCE_MODE=1 exec raku -Ilib bin/agrammon.raku --cfg-file=dev/agrammon.dev.yaml web version6/End.nhd diff --git a/t/enum-aliases.rakutest b/t/enum-aliases.rakutest new file mode 100644 index 000000000..4251acfed --- /dev/null +++ b/t/enum-aliases.rakutest @@ -0,0 +1,44 @@ +use v6; +use Agrammon::ModuleBuilder; +use Agrammon::ModuleParser; +use Test; + +plan 14; + +my $test-root = $*PROGRAM.parent.add('test-data'); +my $module-file = "$test-root/EnumAliases.nhd"; + +my $parsed = Agrammon::ModuleParser.parsefile( + $module-file, + actions => Agrammon::ModuleBuilder.new +); +ok $parsed, 'EnumAliases.nhd parses'; + +my $module = $parsed.ast; +my @input = $module.input; +is @input.elems, 1, 'one input'; + +my $input = @input[0]; +is $input.name, 'housing_type', 'correct input name'; +is $input.type, 'enum', 'type is enum'; + +# Local enum keys are unchanged. +is $input.enum-ordered.elems, 2, 'two local enum options'; +is $input.enum-ordered.map(*.key).sort.list, , 'local options are yes/no'; + +# Validation accepts both local keys and aliases. +ok $input.is-valid-enum-value('yes'), 'local "yes" valid'; +ok $input.is-valid-enum-value('no'), 'local "no" valid'; +ok $input.is-valid-enum-value('fix'), 'alias "fix" valid'; +ok $input.is-valid-enum-value('mobile'), 'alias "mobile" valid'; +nok $input.is-valid-enum-value('bogus'), 'unknown value rejected'; + +# Canonicalization: aliases map to their local key; local keys pass through. +is $input.canonical-enum-value('fix'), 'yes', 'fix → yes'; +is $input.canonical-enum-value('mobile'), 'yes', 'mobile → yes'; + +# Ensure module-level canonicalization rewrites alias values for formula eval +# but leaves non-enum and unmapped values intact. +my %in = housing_type => 'mobile'; +my %out = $module.canonicalize-input-hash(%in); +is %out, 'yes', 'canonicalize-input-hash rewrites alias'; diff --git a/t/test-data/EnumAliases.nhd b/t/test-data/EnumAliases.nhd new file mode 100644 index 000000000..aa8a7d7d8 --- /dev/null +++ b/t/test-data/EnumAliases.nhd @@ -0,0 +1,27 @@ +# Synthetic test fixture for enum alias support (cross-version migration). +# The `accepts =` line on a +++Option block declares foreign enum keys +# that map onto this option (e.g. values from another model version). + +*** general *** + +author = Test Author +date = 2026-05-08 +taxonomy = Test::EnumAliases +description = Test fixture for enum alias support. + +*** input *** + ++housing_type + type=enum + ++labels + en = Housing type + ++enum + +++yes + en = yes + accepts = fix, mobile + +++no + en = no + ++units + en = - + ++description + Test enum with aliased values. diff --git a/t/webservice.rakutest b/t/webservice.rakutest index cb0054516..9c66fbfab 100644 --- a/t/webservice.rakutest +++ b/t/webservice.rakutest @@ -495,7 +495,7 @@ transactionally { # These variables should have all the same elements next unless $var ~~ / '::animals' /; - is-deeply $input.keys.sort, qw|branch defaults enum filter gui help labels models options optionsLang order type units validator variable|, + is-deeply $input.keys.sort, qw|branch defaults enum enumAliases filter gui help labels models options optionsLang order type units validator variable|, "$var has expected keys"; subtest "$var" => { is $input, 'false', 'branch is false';