This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit f1c5b6340dc76315da6fbe01958b570748084244 Author: James Bognar <[email protected]> AuthorDate: Tue Aug 18 13:38:13 2026 -0400 TODO-408/414: Add a themeable red tag/stage triad; make Theme.getTokens() iteration order deterministic TODO-408: adds a --jc-tag-red-* token triad (background/text/border) to Theme, completing the four-state status vocabulary (green/blue/neutral/red) the tag/stage pill styling needs. chrome.css gains the .tag.status.fail(ed) / .tag.stage.fail(ed) selector mapping onto it. TODO-414: Theme's constructor now copies the caller's token map with an explicit LinkedHashMap copy (cp/u helpers) instead of Map.copyOf. ConsoleChromeMixin emits getTokens()'s iteration order directly into the served stylesheet's :root{} block, and Map.copyOf's order is perturbed by a per-JVM salt -- two processes serving an identical token set could otherwise serve byte-different responses. The two are bundled because both edit Theme.java and both append to the same ConsoleChromeMixin_Test block; splitting them would produce two commits each smaller than the file-split overhead is worth. New Theme_TokenOrdering_Test covers TODO-414 on its own. Partial-file note: this commit contains only test methods n01-o02 of ConsoleChromeMixin_Test's post-backup block (red-triad completeness/theming/ serving, then token-order determinism and cross-process byte-identity), plus the j03 pinned-token-count bump from 32 to 35 that the new triad forces. l01-m12 in the same file belong to a sibling commit's unrelated mount fix that happens to append to the same block -- see that commit's message. --- .../apache/juneau/rest/server/console/Theme.java | 13 ++- .../resources/org/apache/juneau/console/chrome.css | 5 + .../server/console/ConsoleChromeMixin_Test.java | 107 ++++++++++++++++++++- .../server/console/Theme_TokenOrdering_Test.java | 96 ++++++++++++++++++ 4 files changed, 218 insertions(+), 3 deletions(-) diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java index 83a74fb51a..e6d42d9391 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java +++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java @@ -97,6 +97,9 @@ public final class Theme { .token("--jc-tag-neutral-bg", "#e2e3e5") .token("--jc-tag-neutral-text", "#383d41") .token("--jc-tag-neutral-border", "#c6c8ca") + .token("--jc-tag-red-bg", "#f8d7da") + .token("--jc-tag-red-text", "#721c24") + .token("--jc-tag-red-border", "#f5c6cb") .build(); private final String name; @@ -104,7 +107,11 @@ public final class Theme { private Theme(String name, Map<String,String> tokens) { this.name = name; - this.tokens = Map.copyOf(tokens); + // Insertion-ordered rather than Map.copyOf: ConsoleChromeMixin emits this map's iteration order directly as + // the served stylesheet's :root{} declarations, and Map.copyOf's order is perturbed by a per-JVM salt - which + // would make the response body differ between two processes serving an identical token set. cp(Map) is + // contractually a LinkedHashMap copy, so it preserves that order; do not swap it for an unordered copy. + this.tokens = u(cp(tokens)); } /** @@ -133,7 +140,9 @@ public final class Theme { /** * Returns this theme's token overrides. * - * @return An immutable map of token name (e.g. {@code "--jc-accent"}) to CSS value. Never <jk>null</jk>. + * @return + * An immutable map of token name (e.g. {@code "--jc-accent"}) to CSS value, iterating in the order the tokens + * were declared on the builder. Never <jk>null</jk>. */ public Map<String,String> getTokens() { return tokens; } diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css index 737c09b967..342ba26819 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css +++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css @@ -346,6 +346,11 @@ a:hover { text-decoration: underline; } .tag.stage.unknown, .tag.stage.archived { background-color: var(--jc-tag-neutral-bg); color: var(--jc-tag-neutral-text); border-color: var(--jc-tag-neutral-border); } +.tag.status.fail, +.tag.status.failed, +.tag.stage.fail, +.tag.stage.failed { background-color: var(--jc-tag-red-bg); color: var(--jc-tag-red-text); border-color: var(--jc-tag-red-border); } + /* ========================================================================== Data table (IRS / DataTables style) ========================================================================== */ diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java index c91e5e0aca..7e645b72c8 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java +++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java @@ -497,8 +497,12 @@ class ConsoleChromeMixin_Test extends TestBase { // A0 must not add a --jc-logo or --jc-page-bg-image token - the logo/page-bg mechanism is deliberately // NOT part of the Theme token model (finding 4 of the design doc). If this count ever changes, it must be // a DIFFERENT, deliberate change to Theme.OPEN - not a side effect of the asset feature. - assertEquals(32, Theme.OPEN.getTokens().size()); + // + // 35 = the original 32, plus the three-token red tag triad added so a four-state status vocabulary has a + // fourth colour. Bumping this number is only ever correct alongside a reviewed edit to Theme.OPEN itself. + assertEquals(35, Theme.OPEN.getTokens().size()); assertFalse(Theme.OPEN.getTokens().containsKey("--jc-logo")); + assertFalse(Theme.OPEN.getTokens().containsKey("--jc-page-bg-image")); } //----------------------------------------------------------------------------------------------------------------- @@ -716,6 +720,107 @@ class ConsoleChromeMixin_Test extends TestBase { assertEquals(2, MOUNT_CACHE_MIXIN.debugBuildCount(), "expected exactly one assembly per distinct mount"); } + //----------------------------------------------------------------------------------------------------------------- + // n) Tag colour palette: the red triad that makes a four-state (pass/warn/fail/unknown) vocabulary expressible + //----------------------------------------------------------------------------------------------------------------- + + /** Every colour family in the tag palette, in the order Theme.OPEN declares them. */ + private static final List<String> TAG_PALETTE = List.of("green", "blue", "amber", "neutral", "red"); + + /** The three properties every tag colour family covers - a family missing one of them cannot paint a whole pill. */ + private static final List<String> TAG_TRIAD_PROPERTIES = List.of("bg", "text", "border"); + + @Test void n01_everyTagColourFamily_isACompleteTriad_includingRed() { + for (var colour : TAG_PALETTE) + for (var property : TAG_TRIAD_PROPERTIES) { + var name = "--jc-tag-" + colour + '-' + property; + assertTrue(Theme.OPEN.getTokens().containsKey(name), () -> "Theme.OPEN is missing tag token '" + name + "'"); + } + } + + @Test void n02_redTriadValues_areLiteralHexColours_andSurviveTheValueGrammar() { + for (var property : TAG_TRIAD_PROPERTIES) { + var value = Theme.OPEN.getTokens().get("--jc-tag-red-" + property); + assertNotNull(value, () -> "no value for --jc-tag-red-" + property); + assertTrue(value.matches("#[0-9a-f]{6}"), () -> "--jc-tag-red-" + property + " is not a 6-digit hex colour like its siblings: " + value); + assertEquals(value, CssValueGrammar.normalizeAndValidate(value)); + } + } + + @Test void n03_chromeCss_mapsAFailValueOntoTheRedTriad() throws IOException { + var css = readChromeCss(); + assertTrue(css.contains(".tag.status.fail"), () -> "no .tag.status.fail mapping rule, css:\n" + css); + for (var property : TAG_TRIAD_PROPERTIES) + assertTrue(css.contains("var(--jc-tag-red-" + property + ")"), () -> "--jc-tag-red-" + property + " is defined but never consumed"); + } + + @Test void n04_servedChromeCss_emitsTheRedTriad_alongsideTheOtherFourFamilies() throws Exception { + var body = bodyOf(MockRestClient.buildLax(DefaultHost.class)); + for (var colour : TAG_PALETTE) + for (var property : TAG_TRIAD_PROPERTIES) { + var declaration = "--jc-tag-" + colour + '-' + property + ':'; + assertTrue(body.contains(declaration), () -> "served chrome.css never declares '" + declaration + "', body:\n" + body); + } + } + + private static final Theme RED_OVERRIDE_THEME = Theme.create("red-override") + .token("--jc-tag-red-bg", "#ffe0e2") + .token("--jc-tag-red-text", "#5a0f14") + .token("--jc-tag-red-border", "#f0b3b7") + .build(); + + @Rest(mixins=ConsoleChromeMixin.class) + public static class RedOverrideHost extends BasicRestServlet { + private static final long serialVersionUID = 1L; + @Bean public ConsoleChromeMixin console() { return ConsoleChromeMixin.create().theme(RED_OVERRIDE_THEME).build(); } + } + + @Test void n05_redTriad_isThemeable_throughTheSameApiAsEveryOtherTriad() throws Exception { + var body = bodyOf(MockRestClient.buildLax(RedOverrideHost.class)); + assertEquals(2, countRootBlocks(body), () -> "expected Theme.OPEN block + the override block, body:\n" + body); + assertTrue(body.contains("--jc-tag-red-bg:#ffe0e2;"), () -> "red bg override not emitted, body:\n" + body); + assertTrue(body.contains("--jc-tag-red-text:#5a0f14;"), () -> "red text override not emitted, body:\n" + body); + assertTrue(body.contains("--jc-tag-red-border:#f0b3b7;"), () -> "red border override not emitted, body:\n" + body); + } + + /** + * The point of the red family: a four-state {@code pass}/{@code warn}/{@code fail}/{@code unknown} vocabulary + * has to reach four distinct colours. Sharing one between {@code warn} and {@code fail} would make a check that + * could not run indistinguishable from one that passed with a caveat. + */ + @Test void n06_fourStateStatusVocabulary_resolvesToFourDistinctFills() { + var fills = new LinkedHashSet<String>(); + for (var colour : List.of("green", "amber", "red", "neutral")) { + var fill = Theme.OPEN.getTokens().get("--jc-tag-" + colour + "-bg"); + assertNotNull(fill, () -> "no fill for the '" + colour + "' family"); + fills.add(fill); + } + assertEquals(4, fills.size(), () -> "pass/warn/fail/unknown collapse onto fewer than four fills: " + fills); + } + + //----------------------------------------------------------------------------------------------------------------- + // o) Deterministic token emission: the :root{} block must be byte-stable for a given token set + //----------------------------------------------------------------------------------------------------------------- + + /** + * Pins that the emitted declaration order is exactly {@link Theme#getTokens()}'s iteration order, i.e. that + * the theme's ordering guarantee reaches the wire rather than being re-bucketed on the way out. The guarantee + * itself - that the iteration order is the declaration order and not a per-JVM hash order, which is what makes + * the response byte-stable enough to ever carry an {@code ETag} - is proved in {@code Theme_TokenOrdering_Test}. + */ + @Test void o01_openBlockDeclarationOrder_matchesThemeOpensDeclarationOrder() throws Exception { + var block = firstRootBlock(bodyOf(MockRestClient.buildLax(DefaultHost.class))); + var emitted = new ArrayList<String>(); + var m = Pattern.compile("(--jc-[a-z0-9-]+)\\s*:").matcher(block); + while (m.find()) + emitted.add(m.group(1)); + assertEquals(new ArrayList<>(Theme.OPEN.getTokens().keySet()), emitted); + } + + @Test void o02_twoIndependentlyBuiltMixinsWithTheSameTheme_serveByteIdenticalBodies() throws Exception { + assertEquals(bodyOf(MockRestClient.buildLax(DefaultHost.class)), bodyOf(MockRestClient.buildLax(DefaultHost.class))); + } + //----------------------------------------------------------------------------------------------------------------- // Test helpers //----------------------------------------------------------------------------------------------------------------- diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_TokenOrdering_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_TokenOrdering_Test.java new file mode 100644 index 0000000000..17ec6a4491 --- /dev/null +++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_TokenOrdering_Test.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.console; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +import org.apache.juneau.*; +import org.junit.jupiter.api.*; + +/** + * {@link Theme#getTokens()} must iterate in the order the tokens were declared, because + * {@code ConsoleChromeMixin} writes that iteration straight out as the served stylesheet's {@code :root{}} + * declarations - so a hash-ordered map makes the response body differ, byte for byte, between two processes + * serving an identical token set. + * + * <p> + * The load-bearing assertion is (a01)'s <i>insertion</i>-order check, not (a03)'s repeat-build check. A + * {@code java.util.ImmutableCollections} map perturbs its probe sequence with a per-JVM salt, so its order is + * arbitrary but <i>fixed</i> within one process: a test that only builds the same theme twice and compares + * cannot see the defect at all. + */ +class Theme_TokenOrdering_Test extends TestBase { + + /** Declared in reverse-alphabetical order so a map that happened to sort its keys would not pass by accident. */ + private static final List<String> NAMES = List.of( + "--jc-zulu", "--jc-yankee", "--jc-xray", "--jc-whiskey", "--jc-victor", "--jc-uniform", + "--jc-tango", "--jc-sierra", "--jc-romeo", "--jc-quebec", "--jc-papa", "--jc-oscar"); + + private static Theme themeWithNames() { + var b = Theme.create("ordering"); + for (var i = 0; i < NAMES.size(); i++) + b.token(NAMES.get(i), String.format("#%06x", i)); + return b.build(); + } + + @Test void a01_getTokens_iteratesInDeclarationOrder() { + assertEquals(NAMES, new ArrayList<>(themeWithNames().getTokens().keySet())); + } + + @Test void a02_themeOpen_iteratesInDeclarationOrder_soTheTagPaletteStaysGrouped() { + var names = new ArrayList<>(Theme.OPEN.getTokens().keySet()); + assertEquals("--jc-font", names.get(0), () -> "Theme.OPEN's first declared token is not first, order: " + names); + var tags = names.stream().filter(x -> x.startsWith("--jc-tag-")).toList(); + assertEquals( + List.of( + "--jc-tag-green-bg", "--jc-tag-green-text", "--jc-tag-green-border", + "--jc-tag-blue-bg", "--jc-tag-blue-text", "--jc-tag-blue-border", + "--jc-tag-amber-bg", "--jc-tag-amber-text", "--jc-tag-amber-border", + "--jc-tag-neutral-bg", "--jc-tag-neutral-text", "--jc-tag-neutral-border", + "--jc-tag-red-bg", "--jc-tag-red-text", "--jc-tag-red-border"), + tags); + } + + @Test void a03_repeatedBuildsOfTheSameTokenSet_produceTheSameOrder() { + assertEquals(new ArrayList<>(themeWithNames().getTokens().keySet()), new ArrayList<>(themeWithNames().getTokens().keySet())); + } + + @Test void a04_reDeclaringAToken_updatesInPlace_withoutMovingIt() { + var theme = Theme.create("ordering") + .token("--jc-accent", "#111111") + .token("--jc-link", "#222222") + .token("--jc-accent", "#333333") + .build(); + assertEquals(List.of("--jc-accent", "--jc-link"), new ArrayList<>(theme.getTokens().keySet())); + assertEquals("#333333", theme.getTokens().get("--jc-accent")); + } + + @Test void a05_getTokens_isStillUnmodifiable() { + var tokens = Theme.OPEN.getTokens(); + assertThrows(UnsupportedOperationException.class, () -> tokens.put("--jc-injected", "#000000")); + } + + /** Mutating the builder after {@code build()} must not reach through into the built theme's copy. */ + @Test void a06_builtTheme_isDecoupledFromLaterBuilderMutation() { + var b = Theme.create("ordering").token("--jc-accent", "#111111"); + var theme = b.build(); + b.token("--jc-link", "#222222"); + assertEquals(List.of("--jc-accent"), new ArrayList<>(theme.getTokens().keySet())); + } +}
