diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6122ba55 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + +/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/*.js text eol=lf +/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/*.txt text eol=lf + diff --git a/.gitignore b/.gitignore index 003a85ff..a4d76839 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,12 @@ hs_err_pid* .project /.settings/ /lwjgl-extract/ + +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build + +# Ignore Kotlin plugin data +.kotlin diff --git a/.worktrees/nested-scroll-text-rendering b/.worktrees/nested-scroll-text-rendering new file mode 160000 index 00000000..dcbd3bdf --- /dev/null +++ b/.worktrees/nested-scroll-text-rendering @@ -0,0 +1 @@ +Subproject commit dcbd3bdf6590b3292e5e2d677fe983c9e9cdf78f diff --git a/AGENTS_CODE_STYLE.md b/AGENTS_CODE_STYLE.md new file mode 100644 index 00000000..09b8236f --- /dev/null +++ b/AGENTS_CODE_STYLE.md @@ -0,0 +1,80 @@ +# Agent Code Style And Principles + +This project is a modular Java GUI library with a browser-engine-like architecture. Treat the codebase as a DOM/CSS/layout/event/rendering pipeline, not as a widget toolkit with isolated controls. + +## Architectural Principles + +- Keep the core backend-agnostic. Core nodes, styles, layout, input, events, fonts, and parsers should not depend on NanoVG/OpenGL rendering details. +- Preserve the pipeline shape: parse/build nodes, parse CSS, resolve style, run layout, translate system events, dispatch GUI events, render an already styled and laid-out `Frame`. +- Prefer interface boundaries for services and put defaults in `impl` packages. Existing examples include parser, layout, style manager, event processor, font service, mouse service, and shortcut registry. +- Model GUI state explicitly on nodes and services. Hover, focus, pressed state, scroll offsets, layout boxes, mouse positions, and resolved styles are mutable runtime state. +- Keep CSS concepts typed where possible. Use `Property`, `Term`, `Selector`, `Length`, `Color`, and style type classes rather than passing raw strings past parser/property boundaries. +- Treat generated ANTLR files under `core/src/main/java/.../parser/impl/css/antlr` as generated. Change `CSS3.g4` and regenerate outputs instead of manually editing generated parser classes. + +## Java Style + +- Java modules are deliberate. Update `module-info.java` when adding exported APIs or new module dependencies. +- The build uses Java 25 source/target compatibility. +- Formatting follows the checked-in IntelliJ Google Java style. Keep two-space indentation and readable wrapped method chains. +- Lombok is part of the project style. Existing code uses `@Getter`, `@Setter`, `@RequiredArgsConstructor`, `@NonNull`, `@ToString`, `@NoArgsConstructor`, and `@Slf4j`. +- Lombok accessors are fluent and not chained. Prefer `element.box()` over `getBox()` and avoid introducing chained setter assumptions. +- Keep constructors small and use required-constructor dependency injection where the surrounding package already does so. +- Use static utility classes for cross-cutting calculations only when they match existing patterns such as `LayoutUtils`, `StyleUtils`, `NodeUtilities`, `TextUtil`, and NanoVG utility classes. +- Do not add broad frameworks for dependency injection, rendering, parsing, or event dispatch. Existing composition is manual and explicit. + +## Package Conventions + +- `core.node` owns the DOM-like tree model. Add tree behavior here only when it belongs to all nodes/elements, not to rendering or parsing. +- `core.style.stylesheet` owns the CSS object model. Add new CSS properties through property providers and the property store, not by special-casing `ResolvedStyle` first. +- `core.style.types` owns strongly typed CSS values. Add new value types here when a property needs a reusable domain object. +- `core.parser.impl.css.visitor` is the semantic CSS conversion layer. The grammar recognizes more CSS than visitors support; update visitors and property providers together. +- `core.layout.impl` owns layout algorithms and box updates. Keep rendering out of layout; layout should populate geometry and layout-parent relationships. +- `core.system.event.*` handles raw platform events. Convert them into GUI events before application listeners see them. +- `core.event.*` handles GUI-level events and listener dispatch. +- `core.backend.*` should consume `Frame`, `Element`, `Text`, style, and box data; it should not mutate core model state except through clearly intentional renderer lifecycle effects. + +## CSS And Style Rules + +- Respect rule ordering: default rules, frame stylesheets ordered by specificity, then inline style declarations. +- Preserve specificity semantics when adding selectors. Selector classes should implement matching and specificity consistently. +- Be explicit about unsupported CSS. Current visitors often return `null` or throw `NotImplementedException` for partial features; do not silently pretend full browser CSS support exists. +- The ANTLR grammar supports more constructs than the semantic model. When enabling a grammar feature, add model classes, visitors, property conversion, defaults, tests, and serialization behavior as needed. +- `ResolvedStyle` is a typed facade over a generic map. Add typed accessors for supported properties that are used outside property conversion. + +## Event And Input Rules + +- Keep the two-stage event pipeline intact: system event queue first, GUI event queue second. +- Event dispatch is exact-class based in current processors. Do not assume superclass listeners receive subclass events unless you change and test that behavior. +- System listeners should update state and emit GUI events in the same place, following existing cursor, mouse, key, char, scroll, and window listener patterns. +- Mouse hit-testing should respect `NodeUtilities` and each node's `Intersection` strategy. +- Keyboard behavior should pass through `KeyboardLayout` and `ShortcutRegistry`; avoid hard-coding native key codes in GUI-level code. + +## Layout Rules + +- Layout writes to `Box`, offset parent, layout child nodes, scroll sizes, client sizes, and text cursor positions. +- Keep block, flex, text, and none layout behavior separate. Add shared calculations to `LayoutUtils` only when multiple algorithms need them. +- Flex layout delegates to Yoga. Extend the mapping between SpinyGUI style values and Yoga properties rather than reimplementing flexbox manually. +- Use `Length` and style accessors for dimensions, border, padding, margin, and positioning. Avoid raw numeric shortcuts unless the existing method is already in pixel space. + +## Rendering Rules + +- `Renderer` is intentionally minimal: `initialize`, `render`, `destroy`. +- NanoVG code must manage native resources carefully. Follow try-with-resources patterns for `NVGColor`, `NVGPaint`, and other stack/calloc resources where applicable. +- Rendering should traverse the layout tree, not the raw child tree, because positioned and normal-flow children may differ. +- Current NanoVG text rendering is placeholder/debug-like. Do not document it or build on it as complete glyph rendering without implementing and testing actual text drawing. +- Current border rendering is not full side-specific CSS border support. Be explicit when extending it. + +## Testing And Verification + +- For parser changes, test both successful parse/model conversion and unsupported/invalid syntax behavior. +- For style changes, test property parsing, defaults, inheritance if applicable, shorthand expansion, and resolved style output. +- For layout changes, test box geometry, scroll/client size updates, positioned nodes, and text wrapping/metrics. +- For event changes, test queue behavior and listener side effects. Existing tests under `core/src/test/java/.../system/event/listener` are the closest pattern. +- For backend changes, run a demo when possible and verify lifecycle/resource cleanup paths. + +## Known Caution Areas + +- Some CSS features are grammar-recognized but semantically unsupported or partially supported. +- Generated ANTLR outputs are currently modified in the working tree; do not overwrite them accidentally. +- `GeneralSiblingSelector`, clickable-only recursive hit testing, text metrics edge handling, and exact-class event dispatch have potential edge cases noted in current code analysis. +- Demo classes contain exploratory assertions and embedded HTML/CSS examples. Do not treat them as complete production tests. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 00000000..925ec4a3 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,92 @@ +# Project Structure + +SpinyGUI is a modular Java GUI library with a browser-engine-like split between node tree, CSS parsing/style resolution, layout, event processing, and rendering backends. + +Generated package documents are stored under `docs/project-structure/packages/`. Each package document lists direct classes first, then links to child packages so the documentation can be read from deepest packages upward. + +## Gradle Modules + +- `core` - Core DOM-like node model, CSS parser/style system, layout, events, input, fonts, animation, and platform abstraction. +- `core.backend` - Renderer backend API shared by concrete rendering implementations. +- `core.backend.lwjgl.nanovg` - LWJGL/NanoVG renderer implementation for drawing the core scene graph. +- `demo.simple` - Small launcher-style examples for exercising the aggregate SpinyGUI module. +- `demo.complex` - GLFW/LWJGL demo harness and NanoVG example runner. +- `spinygui` - Aggregate module that re-exports the core and default backend modules. + +## Main Subsystems + +- Node tree: core node classes model frames, elements, text, attributes, parent/child links, pseudo-state, and box geometry. +- Style and CSS: stylesheet model, selectors, property providers, ANTLR visitors, typed CSS values, and `ResolvedStyle` convert parsed CSS into values usable by layout/rendering. +- Layout: layout contracts and implementations calculate box-model rectangles, text metrics, normal-flow/positioned layout trees, scroll sizes, and client sizes. +- Events and input: system events are translated by system listeners/processors into application events and node state changes. +- Fonts and metrics: font service/storage abstractions load platform fonts and expose text metrics for layout/rendering. +- Rendering: backend SPI defines `Renderer`; the LWJGL/NanoVG backend traverses layout nodes and draws elements, borders, and text. +- Demos: simple and complex demo modules exercise the aggregate API and NanoVG backend. + +## Package Index + +- [com](docs/project-structure/packages/com/README.md) - This reference describes Top-level Java namespace folder for project packages, lists 0 direct classes, and aggregates 60 descendant packages. +- [com.spinyowl](docs/project-structure/packages/com/spinyowl/README.md) - This reference describes SpinyOwl namespace folder, lists 0 direct classes, and aggregates 59 descendant packages. +- [com.spinyowl.spinygui](docs/project-structure/packages/com/spinyowl/spinygui/README.md) - This reference describes SpinyGUI namespace folder aggregating core, backend, and demo packages, lists 0 direct classes, and aggregates 58 descendant packages. +- [com.spinyowl.spinygui.core](docs/project-structure/packages/com/spinyowl/spinygui/core/README.md) - This reference describes Top-level core configuration and shared entry points for the GUI engine, lists 1 direct class, and aggregates 54 descendant packages. +- [com.spinyowl.spinygui.core.animation](docs/project-structure/packages/com/spinyowl/spinygui/core/animation/README.md) - This reference describes Frame-time animation contracts and a simple animator loop, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.backend](docs/project-structure/packages/com/spinyowl/spinygui/core/backend/README.md) - This reference describes Backend namespace folder for renderer APIs and implementations, lists 0 direct classes, and aggregates 4 descendant packages. +- [com.spinyowl.spinygui.core.backend.renderer](docs/project-structure/packages/com/spinyowl/spinygui/core/backend/renderer/README.md) - This reference describes Renderer SPI consumed by backend implementations, lists 1 direct class, and aggregates 3 descendant packages. +- [com.spinyowl.spinygui.core.backend.renderer.lwjgl](docs/project-structure/packages/com/spinyowl/spinygui/core/backend/renderer/lwjgl/README.md) - This reference describes Package for lwjgl related classes, lists 0 direct classes, and aggregates 2 descendant packages. +- [com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg](docs/project-structure/packages/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/README.md) - This reference describes NanoVG renderer orchestration and specialized element/text/border renderers, lists 4 direct classes, and aggregates 1 descendant package. +- [com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util](docs/project-structure/packages/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/util/README.md) - This reference describes NanoVG drawing and color helpers, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.clipboard](docs/project-structure/packages/com/spinyowl/spinygui/core/clipboard/README.md) - This reference describes Clipboard abstraction used by platform integrations, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.cursor](docs/project-structure/packages/com/spinyowl/spinygui/core/cursor/README.md) - This reference describes Cursor model and cursor service abstraction, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.event](docs/project-structure/packages/com/spinyowl/spinygui/core/event/README.md) - This reference describes Application-level events emitted to nodes and event targets, lists 21 direct classes, and aggregates 2 descendant packages. +- [com.spinyowl.spinygui.core.event.listener](docs/project-structure/packages/com/spinyowl/spinygui/core/event/listener/README.md) - This reference describes Generic event listener contract for application events, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.event.processor](docs/project-structure/packages/com/spinyowl/spinygui/core/event/processor/README.md) - This reference describes Dispatch logic for routing application events to node listeners, lists 2 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.font](docs/project-structure/packages/com/spinyowl/spinygui/core/font/README.md) - This reference describes CSS-like font value objects: family, size, stretch, style, and weight, lists 5 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.image](docs/project-structure/packages/com/spinyowl/spinygui/core/image/README.md) - This reference describes Image abstraction used by style and rendering layers, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.input](docs/project-structure/packages/com/spinyowl/spinygui/core/input/README.md) - This reference describes Input domain model for keyboard, mouse, shortcuts, and user-facing key mappings, lists 10 direct classes, and aggregates 1 descendant package. +- [com.spinyowl.spinygui.core.input.impl](docs/project-structure/packages/com/spinyowl/spinygui/core/input/impl/README.md) - This reference describes Default mutable implementations of input services, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.layout](docs/project-structure/packages/com/spinyowl/spinygui/core/layout/README.md) - This reference describes Layout contracts, layout context, and text/element layout interfaces, lists 5 direct classes, and aggregates 1 descendant package. +- [com.spinyowl.spinygui.core.layout.impl](docs/project-structure/packages/com/spinyowl/spinygui/core/layout/impl/README.md) - This reference describes Concrete layout algorithms and utilities for block, flex, none, text, and layout tree updates, lists 7 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.node](docs/project-structure/packages/com/spinyowl/spinygui/core/node/README.md) - This reference describes DOM-like node hierarchy: frames, elements, empty elements, text nodes, and builders, lists 6 direct classes, and aggregates 2 descendant packages. +- [com.spinyowl.spinygui.core.node.intersection](docs/project-structure/packages/com/spinyowl/spinygui/core/node/intersection/README.md) - This reference describes Hit-testing strategy objects for node intersection checks, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.node.layout](docs/project-structure/packages/com/spinyowl/spinygui/core/node/layout/README.md) - This reference describes Box-model geometry value objects used by layout and rendering, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.parser](docs/project-structure/packages/com/spinyowl/spinygui/core/parser/README.md) - This reference describes Parser interfaces for HTML-like node trees and stylesheets, lists 2 direct classes, and aggregates 4 descendant packages. +- [com.spinyowl.spinygui.core.parser.impl](docs/project-structure/packages/com/spinyowl/spinygui/core/parser/impl/README.md) - This reference describes Default parser implementations and parser factory code, lists 4 direct classes, and aggregates 3 descendant packages. +- [com.spinyowl.spinygui.core.parser.impl.css](docs/project-structure/packages/com/spinyowl/spinygui/core/parser/impl/css/README.md) - This reference describes CSS parser namespace containing generated ANTLR artifacts and handwritten semantic visitors, lists 0 direct classes, and aggregates 2 descendant packages. +- [com.spinyowl.spinygui.core.parser.impl.css.antlr](docs/project-structure/packages/com/spinyowl/spinygui/core/parser/impl/css/antlr/README.md) - This reference describes Generated ANTLR CSS3 lexer/parser/listener/visitor artifacts; Regenerate from the grammar instead of hand-editing, lists 6 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.parser.impl.css.visitor](docs/project-structure/packages/com/spinyowl/spinygui/core/parser/impl/css/visitor/README.md) - This reference describes ANTLR visitors that convert CSS parse trees into stylesheet, selector, declaration, and term model objects, lists 8 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style](docs/project-structure/packages/com/spinyowl/spinygui/core/style/README.md) - This reference describes Resolved style state applied to nodes after rule matching and property conversion, lists 1 direct class, and aggregates 18 descendant packages. +- [com.spinyowl.spinygui.core.style.manager](docs/project-structure/packages/com/spinyowl/spinygui/core/style/manager/README.md) - This reference describes Style manager contract and implementation for applying stylesheets to node trees, lists 2 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/README.md) - This reference describes CSS stylesheet domain model: properties, rulesets, declarations, terms, specificity, and provider registry, lists 12 direct classes, and aggregates 11 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.annotation](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/annotation/README.md) - This reference describes Annotations used by stylesheet property providers, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.atrule](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/atrule/README.md) - This reference describes CSS at-rule model objects, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.impl](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/impl/README.md) - This reference describes Default property-store implementation and provider scanner integration, lists 2 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.property](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/property/README.md) - This reference describes CSS property providers that parse declarations into typed style values, lists 21 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.selector](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/selector/README.md) - This reference describes Selector contracts and base selector types, lists 4 direct classes, and aggregates 4 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.selector.combinator](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/selector/combinator/README.md) - This reference describes Combinator selectors for descendant, child, sibling, adjacent sibling, and compound matching, lists 5 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoclass](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoclass/README.md) - This reference describes Pseudo-class selector implementations, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.selector.pseudoelement](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/selector/pseudoelement/README.md) - This reference describes Pseudo-element selector implementations, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.selector.simple](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/selector/simple/README.md) - This reference describes Simple selectors for all, element, class, and id matching, lists 4 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.term](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/term/README.md) - This reference describes Typed CSS term values produced by parser visitors, lists 9 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.stylesheet.util](docs/project-structure/packages/com/spinyowl/spinygui/core/style/stylesheet/util/README.md) - This reference describes Utility functions for converting and validating stylesheet values, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.types](docs/project-structure/packages/com/spinyowl/spinygui/core/style/types/README.md) - This reference describes Typed CSS value objects and constants for non-nested style domains, lists 10 direct classes, and aggregates 4 descendant packages. +- [com.spinyowl.spinygui.core.style.types.background](docs/project-structure/packages/com/spinyowl/spinygui/core/style/types/background/README.md) - This reference describes Background-origin, repeat, and sizing value objects, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.types.border](docs/project-structure/packages/com/spinyowl/spinygui/core/style/types/border/README.md) - This reference describes Border item and border-style value objects, lists 2 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.types.flex](docs/project-structure/packages/com/spinyowl/spinygui/core/style/types/flex/README.md) - This reference describes Flexbox alignment, direction, wrapping, and justification value constants, lists 6 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.style.types.length](docs/project-structure/packages/com/spinyowl/spinygui/core/style/types/length/README.md) - This reference describes CSS length units, length wrappers, and conversion contract, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.system](docs/project-structure/packages/com/spinyowl/spinygui/core/system/README.md) - This reference describes Package for system related classes, lists 0 direct classes, and aggregates 7 descendant packages. +- [com.spinyowl.spinygui.core.system.event](docs/project-structure/packages/com/spinyowl/spinygui/core/system/event/README.md) - This reference describes Raw platform/window/input events before conversion into application-level events, lists 16 direct classes, and aggregates 3 descendant packages. +- [com.spinyowl.spinygui.core.system.event.listener](docs/project-structure/packages/com/spinyowl/spinygui/core/system/event/listener/README.md) - This reference describes Adapters that translate raw system events into core event processing and state changes, lists 27 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.system.event.processor](docs/project-structure/packages/com/spinyowl/spinygui/core/system/event/processor/README.md) - This reference describes System-event processor contract and implementation for dispatching platform events, lists 2 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.system.event.provider](docs/project-structure/packages/com/spinyowl/spinygui/core/system/event/provider/README.md) - This reference describes Provider for mapping raw system event classes to listener instances, lists 2 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.system.font](docs/project-structure/packages/com/spinyowl/spinygui/core/system/font/README.md) - This reference describes Platform font loading, text metrics, and font storage abstractions, lists 7 direct classes, and aggregates 1 descendant package. +- [com.spinyowl.spinygui.core.system.font.impl](docs/project-structure/packages/com/spinyowl/spinygui/core/system/font/impl/README.md) - This reference describes Default font service, storage, and platform-specific font directory discovery, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.system.input](docs/project-structure/packages/com/spinyowl/spinygui/core/system/input/README.md) - This reference describes Platform-facing key, modifier, action, and mouse-button enums, lists 3 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.time](docs/project-structure/packages/com/spinyowl/spinygui/core/time/README.md) - This reference describes Time service abstraction for animation and frame timing, lists 1 direct class, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.core.util](docs/project-structure/packages/com/spinyowl/spinygui/core/util/README.md) - This reference describes Small utilities for class-key maps, IO, node visibility, references, and text handling, lists 7 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.demo](docs/project-structure/packages/com/spinyowl/spinygui/demo/README.md) - This reference describes Demo namespace folder for runnable examples, lists 0 direct classes, and aggregates 2 descendant packages. +- [com.spinyowl.spinygui.demo.complex](docs/project-structure/packages/com/spinyowl/spinygui/demo/complex/README.md) - This reference describes Windowed GLFW/LWJGL demo framework and concrete NanoVG demo, lists 2 direct classes, and aggregates 0 descendant packages. +- [com.spinyowl.spinygui.demo.simple](docs/project-structure/packages/com/spinyowl/spinygui/demo/simple/README.md) - This reference describes Simple demo entry points, lists 2 direct classes, and aggregates 0 descendant packages. + +## Reading Order + +For bottom-up navigation, start with the deepest packages such as `style.stylesheet.selector.*`, `style.stylesheet.term`, `style.types.*`, `system.event.listener`, `layout.impl`, and `backend.renderer.lwjgl.nanovg.util`; then move upward through their parent package documents and finish with this root overview. \ No newline at end of file diff --git a/README.md b/README.md index 841971a5..43f539bd 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ New implementation should be more flexible. ## System requirements -SpinyGUI requires Java 15+. +SpinyGUI requires Java 25+. ## Links diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 82a3854b..00000000 --- a/build.gradle +++ /dev/null @@ -1,74 +0,0 @@ -plugins { - id "org.sonarqube" version "$sonarqube_version" - id "net.nemerosa.versioning" version "3.0.0" - id "checkstyle" -} - -project.group = 'com.spinyowl' - -printProjectInfo() - -private void printProjectInfo() { - println "##################################################" - println "# Build info: #" - println sprintf("# Project group: %-25s#", project.group) - println sprintf("# Project name: %-25s#", project.name) - println sprintf("# Project version: %-25s#", project.version) - println sprintf("# Java version: %-25s#", JavaVersion.current()) - println "##################################################" -} - -subprojects { - group = 'com.spinyowl' - version = rootProject.version - - // replace with new api - getLayout().getBuildDirectory().set(new File(rootProject.projectDir, "build/${project.name}")) - - repositories { - mavenCentral() - maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } - maven { url "https://raw.githubusercontent.com/SpinyOwl/repo/releases" } - } - - plugins.withType(JavaLibraryPlugin).configureEach { - java { - modularity.inferModulePath = true - } - - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - - compileJava.options.encoding = "UTF-8" - compileTestJava.options.encoding = "UTF-8" - javadoc.options.encoding = 'UTF-8' - - dependencies { - api group: 'org.projectlombok', name: 'lombok', version: lombok_version - annotationProcessor group: 'org.projectlombok', name: 'lombok', version: lombok_version - - testCompileOnly group: 'org.projectlombok', name: 'lombok', version: lombok_version - testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version: lombok_version - - // api - // implementation - testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junit_version - testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-params', version: junit_version - testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: junit_version - } - } - - tasks.withType(Test).configureEach { - useJUnitPlatform() - } -} - -sonarqube { - properties { - property "sonar.projectKey", "SpinyOwl_SpinyGUI" - property "sonar.java.source", "17" - property "sonar.organization", "spinyowl" - property "sonar.host.url", "https://sonarcloud.io" - property "sonar.branch.name", versioning.info.branch - } -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..c04fae01 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("buildlogic.java-application-conventions") +} + +group = "com.spinyowl" + +subprojects { + group = rootProject.group +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..01878478 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +plugins { + // Support convention plugins written in Kotlin. Convention plugins are build scripts in 'src/main' that automatically become available as plugins in the main build. + `kotlin-dsl` +} + +repositories { + // Use the plugin portal to apply community plugins in convention plugins. + gradlePluginPortal() +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..31bf56aa --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,14 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This settings file is used to specify which projects to include in your build-logic build. + */ + +dependencyResolutionManagement { + // Reuse version catalog from the main build. + versionCatalogs { + create("libs", { from(files("../gradle/libs.versions.toml")) }) + } +} + +rootProject.name = "buildSrc" diff --git a/buildSrc/src/main/kotlin/buildlogic.java-application-conventions.gradle.kts b/buildSrc/src/main/kotlin/buildlogic.java-application-conventions.gradle.kts new file mode 100644 index 00000000..b9b34c9b --- /dev/null +++ b/buildSrc/src/main/kotlin/buildlogic.java-application-conventions.gradle.kts @@ -0,0 +1,11 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +plugins { + // Apply the common convention plugin for shared build configuration between library and application projects. + id("buildlogic.java-common-conventions") + + // Apply the application plugin to add support for building a CLI application in Java. + application +} diff --git a/buildSrc/src/main/kotlin/buildlogic.java-common-conventions.gradle.kts b/buildSrc/src/main/kotlin/buildlogic.java-common-conventions.gradle.kts new file mode 100644 index 00000000..ac6e39b6 --- /dev/null +++ b/buildSrc/src/main/kotlin/buildlogic.java-common-conventions.gradle.kts @@ -0,0 +1,46 @@ +val libs = extensions.getByType().named("libs") + +plugins { + java + `java-library` +} + +repositories { + mavenCentral() + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/") } + maven { url = uri("https://central.sonatype.com/repository/maven-snapshots") } + maven { url = uri("https://raw.githubusercontent.com/SpinyOwl/repo/releases") } + +} + +dependencies { + implementation(libs.findLibrary("slf4j").get()) + implementation(libs.findLibrary("logback").get()) + + compileOnly(libs.findLibrary("lombok").get()) + annotationProcessor(libs.findLibrary("lombok").get()) + + testImplementation(libs.findLibrary("junit").get()) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + testCompileOnly(libs.findLibrary("lombok").get()) + testAnnotationProcessor(libs.findLibrary("lombok").get()) +} + +java { + modularity.inferModulePath = true + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } + + withJavadocJar() + withSourcesJar() +} + +tasks.withType { + options.release.set(25) +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/buildSrc/src/main/kotlin/buildlogic.java-library-conventions.gradle.kts b/buildSrc/src/main/kotlin/buildlogic.java-library-conventions.gradle.kts new file mode 100644 index 00000000..a30eefe3 --- /dev/null +++ b/buildSrc/src/main/kotlin/buildlogic.java-library-conventions.gradle.kts @@ -0,0 +1,7 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +plugins { + id("buildlogic.java-common-conventions") +} diff --git a/core.backend.lwjgl.nanovg/build.gradle b/core.backend.lwjgl.nanovg/build.gradle deleted file mode 100644 index ccb10fec..00000000 --- a/core.backend.lwjgl.nanovg/build.gradle +++ /dev/null @@ -1,39 +0,0 @@ -plugins { - id 'java-library' - id 'jacoco' -} -dependencies { - api project(':core') - api project(':core.backend') - - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.lwjgl', name: 'lwjgl-nanovg', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl-nanovg', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl-nanovg', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl-nanovg', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version - api group: 'ch.qos.logback', name: 'logback-classic', version: logback_version -} - -test { - finalizedBy jacocoTestReport // report is always generated after tests run -} -jacocoTestReport { - dependsOn test // tests are required to run before generating the report - reports.xml.required = true -} \ No newline at end of file diff --git a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgBorderRenderer.java b/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgBorderRenderer.java deleted file mode 100644 index 4a9cc5cf..00000000 --- a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgBorderRenderer.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg; - -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.createScissor; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.resetScissor; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgShapes.drawRectStroke; - -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Node; -import com.spinyowl.spinygui.core.style.types.border.BorderStyle; -import org.joml.Vector2f; - -public class NvgBorderRenderer { - - public void render(Node node, long nanovg) { - Element element = node.asElement(); - - createScissor(nanovg, node); - var style = element.resolvedStyle(); - if (BorderStyle.NONE.equals(style.borderTopStyle())) return; - float borderThickness = element.box().border().top(); - - Vector2f position = element.absolutePosition().add(borderThickness / 2, borderThickness / 2); - Vector2f size = element.size().sub(borderThickness, borderThickness); - - drawRectStroke(nanovg, position, size, style.borderTopColor(), borderThickness); - resetScissor(nanovg); - } -} diff --git a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgElementRenderer.java b/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgElementRenderer.java deleted file mode 100644 index ce106613..00000000 --- a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgElementRenderer.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg; - -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.createScissor; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.getBorderRadius; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.resetScissor; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgShapes.drawRect; -import static com.spinyowl.spinygui.core.util.NodeUtilities.visible; -import static org.lwjgl.nanovg.NanoVG.nvgRestore; -import static org.lwjgl.nanovg.NanoVG.nvgSave; - -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Node; - -public class NvgElementRenderer { - - public void render(Node node, long nanovg) { - Element element = node.asElement(); - if (visible(element) /*&& visibleInParents(element)*/) { - var style = element.resolvedStyle(); - var backgroundColor = style.backgroundColor(); - var borderRadius = getBorderRadius(element, style); - - var position = element.absolutePosition(); - var size = element.size(); - - // render self - createScissor(nanovg, node); - nvgSave(nanovg); - drawRect(nanovg, position, size, backgroundColor, borderRadius); - nvgRestore(nanovg); - resetScissor(nanovg); - } - } -} diff --git a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgRenderer.java b/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgRenderer.java deleted file mode 100644 index 1e88b6b8..00000000 --- a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgRenderer.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg; - -import static org.lwjgl.nanovg.NanoVG.nvgBeginFrame; -import static org.lwjgl.nanovg.NanoVG.nvgEndFrame; -import static org.lwjgl.opengl.GL11.GL_BLEND; -import static org.lwjgl.opengl.GL11.GL_DEPTH_TEST; -import static org.lwjgl.opengl.GL11.GL_ONE_MINUS_SRC_ALPHA; -import static org.lwjgl.opengl.GL11.GL_SRC_ALPHA; -import static org.lwjgl.opengl.GL11.glBlendFunc; -import static org.lwjgl.opengl.GL11.glDisable; -import static org.lwjgl.opengl.GL11.glEnable; -import static org.lwjgl.opengl.GL11.glGetInteger; -import com.spinyowl.spinygui.core.backend.renderer.Renderer; -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.node.Node; -import com.spinyowl.spinygui.core.node.Text; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import org.joml.Vector2fc; -import org.joml.Vector2ic; -import org.lwjgl.nanovg.NanoVGGL2; -import org.lwjgl.nanovg.NanoVGGL3; -import org.lwjgl.opengl.GL30; - -public class NvgRenderer implements Renderer { - - private final boolean antialiasingEnabled; - private final AtomicBoolean initialized = new AtomicBoolean(false); - private final NvgElementRenderer elementRenderer; - private final NvgTextRenderer textRenderer; - private final NvgBorderRenderer borderRenderer; - - private boolean isVersionNew; - private long nanovgContext; - - public NvgRenderer(boolean antialiasingEnabled) { - this.antialiasingEnabled = antialiasingEnabled; - this.elementRenderer = new NvgElementRenderer(); - this.textRenderer = new NvgTextRenderer(); - this.borderRenderer = new NvgBorderRenderer(); - } - - public NvgRenderer() { - this(true); - } - - public void initialize() { - if (initialized.compareAndSet(false, true)) { - isVersionNew = - (glGetInteger(GL30.GL_MAJOR_VERSION) > 3) - || glGetInteger(GL30.GL_MAJOR_VERSION) == 3 - && glGetInteger(GL30.GL_MINOR_VERSION) >= 2; - - if (isVersionNew) { - int flags = - antialiasingEnabled - ? NanoVGGL3.NVG_STENCIL_STROKES | NanoVGGL3.NVG_ANTIALIAS - : NanoVGGL3.NVG_STENCIL_STROKES; - nanovgContext = NanoVGGL3.nvgCreate(flags); - } else { - int flags = - antialiasingEnabled - ? NanoVGGL2.NVG_STENCIL_STROKES | NanoVGGL2.NVG_ANTIALIAS - : NanoVGGL2.NVG_STENCIL_STROKES; - nanovgContext = NanoVGGL2.nvgCreate(flags); - } - - } - } - - @Override - public void render(long window, Vector2fc windowSize, Vector2ic frameBufferSize, Frame frame) { - - float pixelRatio = windowSize.x() / frameBufferSize.x(); - - preRender(windowSize, pixelRatio); - - renderLayoutTree(frame); - - postRender(); - } - - private void renderLayoutTree(Frame layoutTree) { - renderElement(layoutTree, layoutTree.layoutChildNodes()); - } - - private void renderElement(Node node, List children) { - elementRenderer.render(node, nanovgContext); - borderRenderer.render(node, nanovgContext); - - if (children != null) { - children.forEach(this::renderLayoutNode); - } - } - - private void renderLayoutNode(Node node) { - if (node instanceof Element) { - renderElement(node, node.layoutChildNodes()); - } else if (node instanceof Text) { - textRenderer.render(node, nanovgContext); - } - } - - private void postRender() { - - nvgEndFrame(nanovgContext); - - glDisable(GL_BLEND); - glEnable(GL_DEPTH_TEST); - - // imageReferenceManager.removeOldImages(nvgContext); - // context.getContextData().remove(NVG_CONTEXT); - // context.getContextData().remove(IMAGE_REFERENCE_MANAGER); - } - - private void preRender(Vector2fc windowSize, float pixelRatio) { - // loadFontsToNvg(); - // context.getContextData().put(NVG_CONTEXT, nvgContext); - - glDisable(GL_DEPTH_TEST); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - - nvgBeginFrame(nanovgContext, windowSize.x(), windowSize.y(), pixelRatio); - } - - public void destroy() { - if (isVersionNew) { - NanoVGGL3.nnvgDelete(nanovgContext); - } else { - NanoVGGL2.nnvgDelete(nanovgContext); - } - // - // RendererProvider.getInstance().getComponentRenderers().forEach(ComponentRenderer::destroy); - // imageReferenceManager.destroy();} - } -} diff --git a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgTextRenderer.java b/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgTextRenderer.java deleted file mode 100644 index 72d8ede3..00000000 --- a/core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgTextRenderer.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg; - -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.createScissor; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.resetScissor; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgShapes.drawRect; -import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgShapes.drawRectStroke; -import static org.lwjgl.nanovg.NanoVG.nvgRestore; -import static org.lwjgl.nanovg.NanoVG.nvgSave; - -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Node; -import com.spinyowl.spinygui.core.node.Text; -import com.spinyowl.spinygui.core.style.stylesheet.util.StyleUtils; -import com.spinyowl.spinygui.core.style.types.Color; - -public class NvgTextRenderer { - - public void render(Node node, long nanovg) { - Text text = node.asText(); - var position = text.absolutePosition(); - var size = text.size(); - - Element parent = text.parent(); - if (parent == null) return; - - Float fontSize = StyleUtils.getFontSize(text); - if (fontSize == null) return; - - createScissor(nanovg, node); - - nvgSave(nanovg); - drawRect(nanovg, position, size, Color.ROYALBLUE, 1); - drawRectStroke(nanovg, position, size, Color.RED, 1); - nvgRestore(nanovg); - - resetScissor(nanovg); - } -} diff --git a/core.backend/build.gradle b/core.backend/build.gradle deleted file mode 100644 index 86a20f10..00000000 --- a/core.backend/build.gradle +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - id 'java-library' - id 'jacoco' -} -dependencies { - api project(':core') - - api group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version - api group: 'ch.qos.logback', name: 'logback-classic', version: logback_version -} - -java { - withJavadocJar() - withSourcesJar() -} - -test { - finalizedBy jacocoTestReport // report is always generated after tests run -} - -jacocoTestReport { - dependsOn test // tests are required to run before generating the report - reports.xml.required = true -} diff --git a/core/build.gradle b/core/build.gradle deleted file mode 100644 index 820790a5..00000000 --- a/core/build.gradle +++ /dev/null @@ -1,73 +0,0 @@ -plugins { - id 'java-library' - id 'jacoco' - id 'antlr' -} - -dependencies { - antlr group: 'org.antlr', name: 'antlr4', version: antlr4_version - - // https://mvnrepository.com/artifact/commons-io/commons-io - implementation group: 'commons-io', name: 'commons-io', version: commons_io_version - - // https://mvnrepository.com/artifact/com.google.guava/guava - api group: 'com.google.guava', name: 'guava', version: guava_version - - // https://mvnrepository.com/artifact/org.jsoup/jsoup - implementation group: 'org.jsoup', name: 'jsoup', version: jsoup_version - - api group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version - api group: 'ch.qos.logback', name: 'logback-classic', version: logback_version - api group: 'org.apache.commons', name: 'commons-lang3', version: commons_lang_version - - api group: 'org.joml', name: 'joml', version: joml_version - api group: 'org.antlr', name: 'antlr4-runtime', version: antlr4_version - - api group: 'io.github.classgraph', name: 'classgraph', version: classgraph_version - - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.lwjgl', name: 'lwjgl-stb', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl-stb', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl-stb', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl-stb', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.lwjgl', name: 'lwjgl-yoga', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl-yoga', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl-yoga', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl-yoga', version: lwjgl_version, classifier: 'natives-macos' - - // https://mvnrepository.com/artifact/org.mockito/mockito-core - testImplementation group: 'org.mockito', name: 'mockito-core', version: mockito_version - // https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter - testImplementation group: 'org.mockito', name: 'mockito-junit-jupiter', version: mockito_version -} - -java { - withJavadocJar() - withSourcesJar() -} - -sourcesJar.dependsOn generateGrammarSource - -generateGrammarSource { - arguments = [ - "-listener", - "-visitor", - '-long-messages', - "-package", "com.spinyowl.spinygui.core.parser.impl.css.antlr", - ] - outputDirectory = file("src/main/java") -} -compileJava.dependsOn generateGrammarSource - -test { - finalizedBy jacocoTestReport // report is always generated after tests run -} -jacocoTestReport { - dependsOn test // tests are required to run before generating the report - reports.xml.required = true -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/event/WindowCloseEvent.java b/core/src/main/java/com/spinyowl/spinygui/core/event/WindowCloseEvent.java deleted file mode 100644 index b560ef05..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/event/WindowCloseEvent.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.spinyowl.spinygui.core.event; - -import lombok.Data; -import lombok.experimental.SuperBuilder; - -@Data -@SuperBuilder -public class WindowCloseEvent extends Event {} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/event/listener/EventListener.java b/core/src/main/java/com/spinyowl/spinygui/core/event/listener/EventListener.java deleted file mode 100644 index e24e9f7b..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/event/listener/EventListener.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.spinyowl.spinygui.core.event.listener; - -import com.spinyowl.spinygui.core.event.Event; - -public interface EventListener { - - void process(T event); -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/event/processor/EventProcessor.java b/core/src/main/java/com/spinyowl/spinygui/core/event/processor/EventProcessor.java deleted file mode 100644 index 61e166e2..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/event/processor/EventProcessor.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.spinyowl.spinygui.core.event.processor; - -import com.spinyowl.spinygui.core.event.Event; - -public interface EventProcessor { - - void push(Event event); - - void processEvents(); -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java b/core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java deleted file mode 100644 index 8cb9d979..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java +++ /dev/null @@ -1,372 +0,0 @@ -package com.spinyowl.spinygui.core.layout.impl; - -import static com.spinyowl.spinygui.core.layout.impl.LayoutUtils.findPositionedAncestor; -import static com.spinyowl.spinygui.core.layout.impl.LayoutUtils.getChildNodesHeight; -import static com.spinyowl.spinygui.core.layout.impl.LayoutUtils.setBorders; -import static com.spinyowl.spinygui.core.layout.impl.LayoutUtils.setPadding; -import static com.spinyowl.spinygui.core.style.stylesheet.util.StyleUtils.getFloatLength; -import static com.spinyowl.spinygui.core.style.stylesheet.util.StyleUtils.getFloatLengthOptional; - -import com.spinyowl.spinygui.core.layout.ElementLayout; -import com.spinyowl.spinygui.core.layout.LayoutContext; -import com.spinyowl.spinygui.core.layout.LayoutService; -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.node.layout.Box; -import com.spinyowl.spinygui.core.node.layout.Edges; -import com.spinyowl.spinygui.core.style.ResolvedStyle; -import com.spinyowl.spinygui.core.style.types.Display; -import com.spinyowl.spinygui.core.style.types.Position; -import com.spinyowl.spinygui.core.style.types.border.BorderStyle; -import com.spinyowl.spinygui.core.style.types.length.Length.PixelLength; -import com.spinyowl.spinygui.core.style.types.length.Unit; -import java.util.Optional; -import java.util.function.Consumer; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public class BlockLayout implements ElementLayout { - - @NonNull private final LayoutService layoutService; - - @Override - public void layout(Element element, LayoutContext context) { - layout(element, false, context); - } - - public void layout(Element element, boolean skipChildren, LayoutContext ctx) { - if (shouldSkip(element)) { - return; - } - - Box parentBox = getParentDimensions(element, element.parent()); - - ResolvedStyle style = element.resolvedStyle(); - - // calculate borders - setBorders(style, element.box().border()); - - // calculate paddings - setPadding( - parentBox.content().width(), parentBox.content().height(), style, element.box().padding()); - - // calculate content position - Position elementPosition = element.resolvedStyle().position(); - if (Position.STATIC.equals(elementPosition)) { - layoutStaticBlock(element, parentBox, style, skipChildren, ctx); - } else if (Position.ABSOLUTE.equals(elementPosition)) { - layoutAbsoluteBlock(element, parentBox, style, skipChildren, ctx); - } else if (Position.RELATIVE.equals(elementPosition)) { - layoutRelativeBlock(element, parentBox, style, skipChildren, ctx); - } - } - - private void layoutStaticBlock( - Element e, Box parentBox, ResolvedStyle style, boolean skipChildren, LayoutContext ctx) { - - Box box = e.box(); - Edges padding = box.padding(); - Edges border = box.border(); - Edges margin = box.margin(); - - float contentX = - parentBox.border().left() - + Math.max(parentBox.padding().left(), margin.left()) - + border.left() - + padding.left(); - - Float blockBottomY = ctx.lastBlockBottomY(); - float contentY = - border.top() - + padding.top() - + (blockBottomY != null - ? blockBottomY - : Math.max(parentBox.padding().top(), margin.top()) + parentBox.border().top()); - - box.contentPosition(contentX, contentY); - - float verticalAdditions = border.top() + border.bottom() + padding.top() + padding.bottom(); - float horizontalAdditions = border.left() + border.right() + padding.left() + padding.right(); - - float contentWidth; - if (e instanceof Frame frame) { - contentWidth = frame.frameSize().x; - } else { - contentWidth = getWidth(parentBox.content().width(), style); - } - contentWidth -= horizontalAdditions; - box.content().width(contentWidth); - - float borderBoxHeight; - if (e instanceof Frame frame) { - if (!skipChildren) { - layoutService.layoutChildNodes(e, ctx); - } - borderBoxHeight = frame.frameSize().y; - } else { - float childrenHeight = childrenHeight(e, style, skipChildren, ctx); - borderBoxHeight = - getHeight(parentBox.content().height(), childrenHeight + verticalAdditions, style); - } - float contentHeight = borderBoxHeight - verticalAdditions; - box.content().height(contentHeight); - - ctx.lastTextEndY(null); - ctx.previousNode(e); - ctx.lastBlockBottomY(box.borderBox().y() + box.borderBox().height()); - } - - private void layoutAbsoluteBlock( - Element e, Box parentBox, ResolvedStyle style, boolean skipChildren, LayoutContext ctx) { - Element ancestor = findPositionedAncestor(e); - - float verticalAdditions = - e.box().border().top() - + e.box().padding().top() - + e.box().border().bottom() - + e.box().padding().bottom(); - float horizontalAdditions = - e.box().border().left() - + e.box().border().right() - + e.box().padding().left() - + e.box().padding().right(); - - // should be called here to calculate children before calculating content width - float childrenHeight = childrenHeight(e, style, skipChildren, ctx); - - // calculate content x position and width - calculateHorizontalPositionAndWidth( - parentBox, style, ancestor.box(), e.box(), horizontalAdditions); - - float contentY; - float borderBoxHeight; - if (style.top().isAuto() && style.bottom().isAuto()) { - float parentPaddingBoxHeight = parentBox.paddingBox().height(); - float parentOffset = parentBox.content().y(); - contentY = getAutoVerticalContentY(ctx, e.box().border(), e.box().padding(), parentOffset); - - borderBoxHeight = - getHeight(parentPaddingBoxHeight, childrenHeight + verticalAdditions, style); - - } else { - float parentPaddingBoxHeight = - ancestor.box().padding().top() - + ancestor.box().padding().bottom() - + ancestor.box().content().height(); - - float parentOffset = ancestor.box().content().y(); - contentY = parentOffset + e.box().border().top() + e.box().padding().top(); - float bottom = contentY + parentPaddingBoxHeight; - - if (style.top().isLength()) { - contentY += - getFloatLength(style.top(), parentPaddingBoxHeight) - ancestor.box().padding().top(); - } - if (style.bottom().isLength()) { - bottom = - parentOffset - + ancestor.box().padding().bottom() - + ancestor.box().content().height() - - getFloatLength(style.bottom(), parentPaddingBoxHeight); - } - - if (style.bottom().isLength() && style.top().isLength()) { - borderBoxHeight = - getBorderBoxHeight( - e, - style, - verticalAdditions, - childrenHeight, - contentY, - parentPaddingBoxHeight, - bottom); - } else { - borderBoxHeight = - getHeight(parentPaddingBoxHeight, childrenHeight + verticalAdditions, style); - - if (style.bottom().isLength()) { - contentY = bottom - borderBoxHeight + e.box().border().top() + e.box().padding().top(); - } - } - } - - e.box().content().y(contentY); - e.box().content().height(borderBoxHeight - verticalAdditions); - } - - private float getBorderBoxHeight( - Element e, - ResolvedStyle style, - float verticalAdditions, - float childrenHeight, - float contentY, - float parentPaddingBoxHeight, - float bottom) { - float borderBoxHeight; - if (style.height().isAuto()) { - borderBoxHeight = bottom - contentY + e.box().padding().top() + e.box().border().top(); - } else { - borderBoxHeight = - getHeight(parentPaddingBoxHeight, childrenHeight + verticalAdditions, style); - } - return borderBoxHeight; - } - - private static float getAutoVerticalContentY( - LayoutContext ctx, Edges border, Edges padding, float parentOffset) { - float contentY; - contentY = parentOffset + border.top() + padding.top(); - - Float blockBottomY = ctx.lastBlockBottomY(); - if (blockBottomY != null) { - contentY = blockBottomY + border.top(); - } - return contentY; - } - - private void calculateHorizontalPositionAndWidth( - Box parentBox, ResolvedStyle style, Box ancestorBox, Box box, float horizontalAdditions) { - float contentX; - float contentWidth; - if (style.left().isAuto() && style.right().isAuto()) { - float parentOffset = parentBox.content().x(); - contentX = parentOffset + box.border().left() + box.padding().left(); - - float parentPaddingBoxWidth = - Math.max( - parentBox.paddingBox().width(), - ancestorBox.paddingBox().width() - parentOffset + ancestorBox.border().right()); - - contentWidth = getWidth(parentPaddingBoxWidth, style); - } else { - float parentPaddingBoxWidth = ancestorBox.paddingBox().width(); - float left = box.border().left() + box.padding().left() + ancestorBox.border().left(); - float right = left + parentPaddingBoxWidth; - if (style.left().isLength()) { - left += getFloatLength(style.left(), parentPaddingBoxWidth); - } - if (style.right().isLength()) { - right -= getFloatLength(style.right(), parentPaddingBoxWidth); - } - - if (style.left().isLength() && style.right().isLength()) { - contentX = left; - contentWidth = right - left; - } else { - contentWidth = getWidth(parentPaddingBoxWidth, style); - if (style.left().isLength()) { - contentX = left; - } else { - contentX = right - contentWidth; - } - } - } - - contentWidth -= horizontalAdditions; - - box.content().x(contentX); - box.content().width(contentWidth); - } - - private void layoutRelativeBlock( - Element element, - Box parentBox, - ResolvedStyle style, - boolean skipChildren, - LayoutContext context) { - Box box = element.box(); - layoutStaticBlock(element, parentBox, style, skipChildren, context); - float x = box.content().x(); - float y = box.content().y(); - - if (!style.left().isAuto()) { - x += getFloatLength(style.left(), parentBox.content().width()); - } else if (!style.right().isAuto()) { - x -= getFloatLength(style.right(), parentBox.content().width()); - } - - if (!style.top().isAuto()) { - y += getFloatLength(style.top(), parentBox.content().height()); - } else if (!style.bottom().isAuto()) { - y -= getFloatLength(style.bottom(), parentBox.content().height()); - } - box.contentPosition(x, y); - } - - private Box getParentDimensions(Element element, Element parent) { - Box parentBox; - if (element instanceof Frame frame) { - parentBox = new Box(); - parentBox.contentSize(frame.frameSize().x, frame.frameSize().y); - } else if (parent == null) { - parentBox = new Box(); - var frame = element.frame(); - parentBox.contentSize(frame.frameSize().x, frame.frameSize().y); - } else { - parentBox = parent.box(); - } - return parentBox; - } - - private float childrenHeight( - Element element, ResolvedStyle style, boolean skipChildren, LayoutContext context) { - float childrenHeight = 0; - Unit height = style.height(); - if (!skipChildren) { - layoutService.layoutChildNodes(element, context); - } - if (style.display().equals(Display.BLOCK) && height.isAuto() && !skipChildren) { - childrenHeight = getChildNodesHeight(element); - } - return childrenHeight; - } - - private float getWidth(float parentWidth, ResolvedStyle style) { - Optional width = getFloatLengthOptional(style.width(), parentWidth); - Optional minWidth = getFloatLengthOptional(style.minWidth(), parentWidth); - Optional maxWidth = getFloatLengthOptional(style.maxWidth(), parentWidth); - - float w = width.orElse(parentWidth); - w = Math.max(w, minWidth.orElse(w)); - w = Math.min(w, maxWidth.orElse(w)); - return w; - } - - /** - * Returns the height of the element's content, i.e. the height of the element's content box. - * - * @param parentHeight the height of the element's containing block. - * @param borderBoxHeight the height of the element's children with border and padding. - * @param style the element's style. - * @return the height of the element's content. - */ - private float getHeight(float parentHeight, float borderBoxHeight, ResolvedStyle style) { - Optional height; - if (!style.height().isAuto()) { - height = getFloatLengthOptional(style.height(), parentHeight); - } else { - height = Optional.empty(); - } - Optional minHeight = getFloatLengthOptional(style.minHeight(), parentHeight); - Optional maxHeight = getFloatLengthOptional(style.maxHeight(), parentHeight); - - float h = height.orElse(borderBoxHeight); - h = Math.max(h, minHeight.orElse(h)); - h = Math.min(h, maxHeight.orElse(h)); - return h; - } - - private boolean shouldSkip(Element element) { - // skip layout if element has no frame - that means that it is not attached to any - // node tree (and tree root is frame). - return element.frame() == null || (element.parent() == null && !(element instanceof Frame)); - } - - private void applyPadding( - PixelLength borderWidth, BorderStyle borderStyle, Consumer borderConsumer) { - if (borderWidth != null && !BorderStyle.NONE.equals(borderStyle)) { - borderConsumer.accept(borderWidth.convert()); - } - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/layout/impl/LayoutServiceProvider.java b/core/src/main/java/com/spinyowl/spinygui/core/layout/impl/LayoutServiceProvider.java deleted file mode 100644 index 7bc06c22..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/layout/impl/LayoutServiceProvider.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.spinyowl.spinygui.core.layout.impl; - -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.layout.ElementLayout; -import com.spinyowl.spinygui.core.layout.LayoutService; -import com.spinyowl.spinygui.core.style.types.Display; -import com.spinyowl.spinygui.core.system.event.processor.SystemEventProcessor; -import com.spinyowl.spinygui.core.system.font.FontService; -import com.spinyowl.spinygui.core.time.TimeService; -import java.util.HashMap; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import lombok.NonNull; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class LayoutServiceProvider { - public static LayoutService create( - @NonNull SystemEventProcessor systemEventProcessor, - @NonNull EventProcessor eventProcessor, - @NonNull TimeService timeService, - @NonNull FontService fontService) { - - var textLayout = new TextLayoutImpl(fontService); - var elementLayoutMap = new HashMap(); - LayoutService layoutService = new LayoutServiceImpl(textLayout, elementLayoutMap); - - elementLayoutMap.put(Display.NONE, new NoneLayout()); - - var blockLayout = new BlockLayout(layoutService); - elementLayoutMap.put(Display.BLOCK, blockLayout); - - var flexLayout = - new FlexLayout( - systemEventProcessor, eventProcessor, timeService, blockLayout, layoutService); - elementLayoutMap.put(Display.FLEX, flexLayout); - - return layoutService; - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/style/manager/StyleManagerImpl.java b/core/src/main/java/com/spinyowl/spinygui/core/style/manager/StyleManagerImpl.java deleted file mode 100644 index cf18650b..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/style/manager/StyleManagerImpl.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.spinyowl.spinygui.core.style.manager; - -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.parser.StyleSheetParser; -import com.spinyowl.spinygui.core.parser.impl.ParseException; -import com.spinyowl.spinygui.core.style.stylesheet.Declaration; -import com.spinyowl.spinygui.core.style.stylesheet.Property; -import com.spinyowl.spinygui.core.style.stylesheet.PropertyStore; -import com.spinyowl.spinygui.core.style.stylesheet.Ruleset; -import com.spinyowl.spinygui.core.style.stylesheet.StyleSheet; -import com.spinyowl.spinygui.core.style.stylesheet.selector.simple.AllSelector; -import com.spinyowl.spinygui.core.style.stylesheet.selector.simple.ElementSelector; -import java.util.ArrayList; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import lombok.Data; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; - -@RequiredArgsConstructor -public class StyleManagerImpl implements StyleManager { - - private static final Ruleset EMPTY_RULE_SET = - new Ruleset(List.of(new ElementSelector("element")), List.of()); - - @NonNull private final PropertyStore propertyStore; - @NonNull private final StyleSheetParser styleSheetParser; - - private final Map elementStyleDataMap = new IdentityHashMap<>(); - - private List properties; - private Ruleset defaultRuleset; - - public void recalculate(Frame frame) { - updateStyles(frame, frame.styleSheets()); - resolveStyles(frame); - } - - private void resolveStyles(Element element) { - List rules = element.resolvedStyle().rules(); - rules.forEach(rs -> rs.declarations().forEach(declaration -> declaration.apply(element))); - element.children().forEach(this::resolveStyles); - } - - private void updateStyles(Element element, List styleSheets) { - List rulesets = new ArrayList<>(); - // Initializing with default rule sets. - rulesets.add(defaultRuleset()); - // find all rule sets applicable to element. - for (StyleSheet styleSheet : styleSheets) { - rulesets.addAll(styleSheet.searchSpecificRules(element)); - } - // at the end we need to add styles specified in "style" attribute. - rulesets.add(elementStyleRuleSet(element)); - - element.resolvedStyle().rules(rulesets); - - element.children().forEach(child -> updateStyles(child, styleSheets)); - } - - private Ruleset elementStyleRuleSet(Element element) { - StyleData styleData = elementStyleDataMap.computeIfAbsent(element, e -> new StyleData()); - Ruleset ruleSet; - if (!Objects.equals(styleData.style(), element.style()) || styleData.styleRuleset() == null) { - try { - String style = element.style(); - List declarations; - if (StringUtils.isBlank(style)) { - declarations = List.of(); - } else { - declarations = styleSheetParser.parseDeclarations(style); - } - ruleSet = new Ruleset(List.of(new ElementSelector("element")), declarations); - - } catch (ParseException e) { - ruleSet = styleData.styleRuleset == null ? EMPTY_RULE_SET : styleData.styleRuleset; - } - styleData.style(element.style()); - styleData.styleRuleset(ruleSet); - } else { - ruleSet = styleData.styleRuleset; - } - - return ruleSet; - } - - public Ruleset defaultRuleset() { - List propertyStoreProperties = propertyStore.getProperties(); - if (properties == null || !properties.equals(propertyStoreProperties)) { - properties = List.copyOf(propertyStoreProperties); - List collect = new ArrayList<>(); - for (Property p : properties) { - collect.add(new Declaration(p, p.defaultValue())); - } - defaultRuleset = new Ruleset(List.of(new AllSelector()), collect); - } - return defaultRuleset; - } - - @Data - private static class StyleData { - private String style; - private Ruleset styleRuleset; - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListener.java b/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListener.java deleted file mode 100644 index cb913b74..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListener.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import com.spinyowl.spinygui.core.event.CharEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemCharEvent; -import com.spinyowl.spinygui.core.time.TimeService; -import com.spinyowl.spinygui.core.util.TextUtil; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.NonNull; - -@EqualsAndHashCode -public class SystemCharEventListener extends AbstractSystemEventListener { - - @Builder - public SystemCharEventListener( - @NonNull EventProcessor eventProcessor, @NonNull TimeService timeService) { - super(eventProcessor, timeService); - } - - /** - * Used to listen, process and translate system event to gui event. - * - * @param event system event to process - * @param frame target frame for system event. - */ - @Override - public void process(@NonNull SystemCharEvent event, @NonNull Frame frame) { - var focusedElement = frame.getFocusedElement(); - if (focusedElement == null) { - return; - } - - eventProcessor.push( - CharEvent.builder() - .source(frame) - .target(focusedElement) - .timestamp(timeService.currentTime()) - .input(TextUtil.cpToStr(event.codepoint())) - .build()); - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCursorPosEventListener.java b/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCursorPosEventListener.java deleted file mode 100644 index add43af9..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemCursorPosEventListener.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import static com.spinyowl.spinygui.core.input.MouseButton.LEFT; -import static com.spinyowl.spinygui.core.input.MouseButton.RIGHT; - -import com.spinyowl.spinygui.core.event.CursorEnterEvent; -import com.spinyowl.spinygui.core.event.CursorExitEvent; -import com.spinyowl.spinygui.core.event.MouseDragEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.input.MouseService; -import com.spinyowl.spinygui.core.input.MouseService.CursorPositions; -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemCursorPosEvent; -import com.spinyowl.spinygui.core.time.TimeService; -import com.spinyowl.spinygui.core.util.NodeUtilities; -import java.util.List; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.NonNull; -import org.joml.Vector2f; -import org.joml.Vector2fc; - -@EqualsAndHashCode -public class SystemCursorPosEventListener - extends AbstractSystemEventListener { - - @NonNull private final MouseService mouseService; - - @Builder - public SystemCursorPosEventListener( - @NonNull EventProcessor eventProcessor, - @NonNull TimeService timeService, - @NonNull MouseService mouseService) { - super(eventProcessor, timeService); - this.mouseService = mouseService; - } - - /** - * Used to listen, process and translate system event to gui event. - * - * @param event system event to process - * @param frame target frame for system event. - */ - @Override - public void process(@NonNull SystemCursorPosEvent event, @NonNull Frame frame) { - Vector2fc current = new Vector2f(event.posX(), event.posY()); - Vector2fc previous = mouseService.getCursorPositions(frame).current(); - mouseService.setCursorPositions(frame, new CursorPositions(current, previous)); - - var focusedElement = frame.getFocusedElement(); - - // Generate enter / exit events. - generateEnterAndExitEvents(frame, current, previous); - - // Generate drag events. - if (focusedElement != null && (mouseService.pressed(LEFT) || mouseService.pressed(RIGHT))) { - Vector2f delta = current.sub(previous, new Vector2f()); - eventProcessor.push( - MouseDragEvent.builder().source(frame).target(focusedElement).delta(delta).build()); - } - } - - private void generateEnterAndExitEvents(Frame frame, Vector2fc current, Vector2fc previous) { - var currentTargetElements = NodeUtilities.getTargetElementList(frame, current); - var prevTargetElements = NodeUtilities.getTargetElementList(frame, previous); - if (!currentTargetElements.equals(prevTargetElements)) { - generateEnterEvent(frame, current, currentTargetElements); - generateExitEvent(frame, current, currentTargetElements, prevTargetElements); - } - } - - private void generateEnterEvent( - Frame frame, Vector2fc current, List currentTargetElements) { - for (Element element : currentTargetElements) { - if (!element.hovered()) { - element.hovered(true); - Vector2f intersection = element.box().borderBoxPosition().sub(current).negate(); - CursorEnterEvent enterEvent = - CursorEnterEvent.builder() - .source(frame) - .target(element) - .timestamp(timeService.currentTime()) - .intersection(intersection) - .cursorPosition(current) - .build(); - eventProcessor.push(enterEvent); - } - } - } - - private void generateExitEvent( - Frame frame, - Vector2fc current, - List currentTargetElements, - List previousTargetElements) { - - previousTargetElements.removeAll(currentTargetElements); - for (Element prevTarget : previousTargetElements) { - Vector2f intersection = prevTarget.box().borderBoxPosition().sub(current).negate(); - CursorExitEvent exitEvent = - CursorExitEvent.builder() - .source(frame) - .target(prevTarget) - .intersection(intersection) - .timestamp(timeService.currentTime()) - .cursorPosition(current) - .build(); - eventProcessor.push(exitEvent); - prevTarget.hovered(false); - } - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListener.java b/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListener.java deleted file mode 100644 index d9bad8c2..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListener.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import com.spinyowl.spinygui.core.event.KeyboardEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.input.KeyAction; -import com.spinyowl.spinygui.core.input.Keyboard; -import com.spinyowl.spinygui.core.input.KeyboardKey; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemKeyEvent; -import com.spinyowl.spinygui.core.time.TimeService; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.NonNull; - -@EqualsAndHashCode -public class SystemKeyEventListener extends AbstractSystemEventListener { - - @NonNull private final Keyboard keyboard; - - @Builder - public SystemKeyEventListener( - @NonNull EventProcessor eventProcessor, - @NonNull TimeService timeService, - @NonNull Keyboard keyboard) { - super(eventProcessor, timeService); - this.keyboard = keyboard; - } - - /** - * Used to listen, process and translate system event to gui event. - * - * @param event system event to process - * @param frame target frame for system event. - */ - @Override - public void process(@NonNull SystemKeyEvent event, @NonNull Frame frame) { - var element = frame.getFocusedElement(); - if (element != null) { - - int keyCode = event.keyCode(); - var key = new KeyboardKey(keyboard.layout().keyCode(keyCode), keyCode, event.scancode()); - - eventProcessor.push( - KeyboardEvent.builder() - .source(frame) - .target(element) - .key(key) - .timestamp(timeService.currentTime()) - .mods(event.mappedMods()) - .action(getAction(event)) - .build()); - } - } - - private KeyAction getAction(SystemKeyEvent event) { - return switch (event.action()) { - case PRESS -> KeyAction.PRESS; - case RELEASE -> KeyAction.RELEASE; - case REPEAT -> KeyAction.REPEAT; - }; - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemScrollEventListener.java b/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemScrollEventListener.java deleted file mode 100644 index c7f5eebb..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/event/listener/SystemScrollEventListener.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import com.spinyowl.spinygui.core.event.ScrollEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.input.MouseService; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemScrollEvent; -import com.spinyowl.spinygui.core.time.TimeService; -import com.spinyowl.spinygui.core.util.NodeUtilities; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.NonNull; -import org.joml.Vector2fc; - -@EqualsAndHashCode -public class SystemScrollEventListener extends AbstractSystemEventListener { - - @NonNull private final MouseService mouseService; - - @Builder - public SystemScrollEventListener( - @NonNull EventProcessor eventProcessor, - @NonNull TimeService timeService, - @NonNull MouseService mouseService) { - super(eventProcessor, timeService); - this.mouseService = mouseService; - } - - /** - * Used to listen, process and translate system event to gui event. - * - * @param event system event to process - * @param frame target frame for system event. - */ - @Override - public void process(@NonNull SystemScrollEvent event, @NonNull Frame frame) { - Vector2fc current = mouseService.getCursorPositions(frame).current(); - var currentTargetElements = NodeUtilities.getTargetElementList(frame, current); - float multiplier = 50; - for (var target : currentTargetElements) { - // TODO: - // 1. If target prevents scroll - skip scrolling. - // 2. Instead of direct updating of scrollTop and scrollLeft as another option we can start - // scroll animation. - - float scrollTop = target.scrollTop() - event.offsetY() * multiplier; - if (target.scrollHeight() > target.clientHeight()) { - target.scrollTop(scrollTop); - } - - float scrollLeft = target.scrollLeft() - event.offsetX() * multiplier; - if (target.scrollWidth() > target.box().content().width()) { - target.scrollLeft(scrollLeft); - } - eventProcessor.push( - ScrollEvent.builder() - .source(frame) - .target(target) - .timestamp(timeService.currentTime()) - .offsetX(event.offsetX()) - .offsetY(event.offsetY()) - .build()); - } - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/font/FontService.java b/core/src/main/java/com/spinyowl/spinygui/core/system/font/FontService.java deleted file mode 100644 index d7045b32..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/font/FontService.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.spinyowl.spinygui.core.system.font; - -import com.spinyowl.spinygui.core.font.Font; -import lombok.NonNull; - -/** Font service, responsible for loading and caching font data, and calculating text metrics. */ -public interface FontService { - - /** - * Loads font from file. - * - * @param path path to font file - * @return loaded font - * @throws FontLoadingException in case of font loading failure. - */ - Font loadFont(String path) throws FontLoadingException; - - /** - * Verifies if font exists and available to use. - * - * @param font font to verify. - * @return true if font exists, false otherwise. - */ - boolean isFontAvailable(@NonNull Font font); - - /** - * Calculates text metrics. - * - * @param text text to calculate metrics for. - * @param offsetX starting x offset for the first line of text. - * @param font font to use. - * @param fontSize font size. - * @param lineHeight height of line box. It specifies the minimum height of line boxes within the - * element. Default is {@code 1}. - * @param maxWidth maximum width of text in pixels. - * @param wordWrap if true, text will be wrapped by nearest characters to maxWidth, otherwise text - * will be wrapped by spaces to fit maxWidth. - * @return text metrics - */ - TextMetrics getTextMetrics( - @NonNull String text, - float offsetX, - @NonNull Font font, - float fontSize, - float lineHeight, - float maxWidth, - boolean wordWrap); - - /** - * Calculates text line metrics. - * - * @param text text to calculate metrics for. - * @param font font to use. - * @param fontSize font size. - * @return text line metrics. - */ - TextLineMetrics getTextLineMetrics( - @NonNull String text, @NonNull Font font, float fontSize, float lineHeight); -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/font/FontStorage.java b/core/src/main/java/com/spinyowl/spinygui/core/system/font/FontStorage.java deleted file mode 100644 index a82a57de..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/font/FontStorage.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.spinyowl.spinygui.core.system.font; - -import java.nio.ByteBuffer; -import lombok.NonNull; - -public interface FontStorage { - /** - * Returns data if it was loaded before. Otherwise, loads font data from specified path. - * - * @param path path to font file. - * @return {@link ByteBuffer} with font data. - */ - ByteBuffer getFontData(@NonNull String path); - /** - * Loads font data from specified path. - * - * @param fontPath path to font file. - * @return {@link ByteBuffer} with font data. - */ - ByteBuffer loadFont(String fontPath); -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/font/TextLineMetrics.java b/core/src/main/java/com/spinyowl/spinygui/core/system/font/TextLineMetrics.java deleted file mode 100644 index 297ee115..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/font/TextLineMetrics.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.spinyowl.spinygui.core.system.font; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.Setter; - -@AllArgsConstructor -@EqualsAndHashCode -@Getter -@Setter(AccessLevel.NONE) -@Builder -public final class TextLineMetrics { - - private CharSequence characters; - - /** Character count in the line. */ - private int charCount; - - /** Width of the line in pixels. */ - private float width; - - /** Height of the line in pixels. */ - private float height; - - public String toString() { - return characters.toString(); - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/font/TextMetrics.java b/core/src/main/java/com/spinyowl/spinygui/core/system/font/TextMetrics.java deleted file mode 100644 index fc7bacc5..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/font/TextMetrics.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.spinyowl.spinygui.core.system.font; - -import com.google.common.collect.ImmutableSet; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.Setter; -import lombok.Singular; -import lombok.ToString; - -@AllArgsConstructor -@EqualsAndHashCode -@Getter -@Setter(AccessLevel.NONE) -@ToString -@Builder -public final class TextMetrics { - - @Singular private final ImmutableSet lines; - - private float height; - private float fullLineHeight; -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/font/impl/FontServiceImpl.java b/core/src/main/java/com/spinyowl/spinygui/core/system/font/impl/FontServiceImpl.java deleted file mode 100644 index 37bc6bd0..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/font/impl/FontServiceImpl.java +++ /dev/null @@ -1,291 +0,0 @@ -package com.spinyowl.spinygui.core.system.font.impl; - -import static org.lwjgl.stb.STBTruetype.STBTT_MS_EID_UNICODE_BMP; -import static org.lwjgl.stb.STBTruetype.STBTT_MS_LANG_ENGLISH; -import static org.lwjgl.stb.STBTruetype.STBTT_PLATFORM_ID_MICROSOFT; -import static org.lwjgl.stb.STBTruetype.stbtt_GetCodepointHMetrics; -import static org.lwjgl.stb.STBTruetype.stbtt_GetFontNameString; -import static org.lwjgl.stb.STBTruetype.stbtt_ScaleForMappingEmToPixels; -import static org.lwjgl.stb.STBTruetype.stbtt_ScaleForPixelHeight; -import static org.slf4j.LoggerFactory.getLogger; - -import com.spinyowl.spinygui.core.font.Font; -import com.spinyowl.spinygui.core.font.FontStretch; -import com.spinyowl.spinygui.core.font.FontStyle; -import com.spinyowl.spinygui.core.font.FontWeight; -import com.spinyowl.spinygui.core.system.font.FontLoadingException; -import com.spinyowl.spinygui.core.system.font.FontService; -import com.spinyowl.spinygui.core.system.font.FontStorage; -import com.spinyowl.spinygui.core.system.font.TextLineMetrics; -import com.spinyowl.spinygui.core.system.font.TextMetrics; -import java.nio.ByteBuffer; -import java.nio.IntBuffer; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.lwjgl.stb.STBTTFontinfo; -import org.lwjgl.stb.STBTruetype; -import org.lwjgl.system.MemoryStack; -import org.slf4j.Logger; - -@RequiredArgsConstructor -public class FontServiceImpl implements FontService { - private static final Logger LOG = getLogger(FontServiceImpl.class); - - private static final String SUBINDEX_SPLIT_REGEX = "\\s+"; - private static final String SUBFEATURE_SPLIT_REGEX = "(?=\\p{Upper})"; - private static final int FONT_FAMILY_INDEX = 1; - private static final int FONT_SUBFAMILY_INDEX = 2; - private static final int TYPOGRAPHIC_FONT_FAMILY_INDEX = 16; - private static final int TYPOGRAPHIC_FONT_SUBFAMILY_INDEX = 17; - - @NonNull private final FontStorage fontStorage; - private final boolean roundToPixel; - private final Map fontInfoMap = new ConcurrentHashMap<>(); - - /** {@inheritDoc} */ - @Override - @SuppressWarnings("squid:S3776") - public Font loadFont(String path) throws FontLoadingException { - STBTTFontinfo fontInfo = getFontInfo(path); - String fontFamily = getFontFamily(fontInfo); - String subfamily = getSubfamily(fontInfo); - - // split subfamily by capital letter and trim spaces - String[] fontFeatures = subfamily.split(SUBINDEX_SPLIT_REGEX); - - FontStyle fontStyle = FontStyle.NORMAL; - FontWeight fontWeight = FontWeight.NORMAL; - FontStretch fontStretch = FontStretch.NORMAL; - for (String f : fontFeatures) { - String fontFeature = f.trim(); - if (FontStyle.contains(fontFeature)) { - fontStyle = FontStyle.find(fontFeature); - } else if (FontStretch.contains(fontFeature)) { - fontStretch = FontStretch.find(fontFeature); - } else if (FontWeight.contains(fontFeature)) { - fontWeight = FontWeight.find(fontFeature); - } else { - String[] subFeatures = fontFeature.split(SUBFEATURE_SPLIT_REGEX); - for (String sf : subFeatures) { - String sff = sf.trim(); - if (FontStyle.contains(sff)) { - fontStyle = FontStyle.find(sff); - } else if (FontStretch.contains(sff)) { - fontStretch = FontStretch.find(sff); - } else if (FontWeight.contains(sff)) { - fontWeight = FontWeight.find(sff); - } - } - } - } - - if (LOG.isInfoEnabled()) { - LOG.info( - "Font [ {} | {} ] loaded successfully from '{}'", - fontFamily, - Arrays.toString(fontFeatures), - path); - } - return new Font(fontFamily, fontStyle, fontStretch, fontWeight, path); - } - - @Override - public boolean isFontAvailable(@NonNull Font font) { - return fontInfoMap.containsKey(font.path()); - } - - private String getSubfamily(STBTTFontinfo fontInfo) { - String typographicSubfamily = getInfo(fontInfo, TYPOGRAPHIC_FONT_SUBFAMILY_INDEX); - return typographicSubfamily.isBlank() - ? getInfo(fontInfo, FONT_SUBFAMILY_INDEX) - : typographicSubfamily; - } - - private String getFontFamily(STBTTFontinfo fontInfo) { - String typographicFontFamily = getInfo(fontInfo, TYPOGRAPHIC_FONT_FAMILY_INDEX); - return typographicFontFamily.isBlank() - ? getInfo(fontInfo, FONT_FAMILY_INDEX) - : typographicFontFamily; - } - - public TextMetrics getTextMetrics( - @NonNull String text, - float offsetX, - @NonNull Font font, - float fontSize, - float lineHeight, - float maxWidth, - boolean wordWrap) { - if (maxWidth < 0.1) { - TextMetrics.builder().height(0).fullLineHeight(0).build(); - } - - STBTTFontinfo fontInfo = getFontInfo(font.path()); - try (MemoryStack stack = MemoryStack.stackPush()) { - IntBuffer pCodePoint = stack.mallocInt(1); - IntBuffer pAdvance = stack.mallocInt(1); - IntBuffer pLeftSideBearing = stack.mallocInt(1); - - float scaleFactor = stbtt_ScaleForMappingEmToPixels(fontInfo, fontSize); - - int textLength = text.length(); - float lineWidth = offsetX; - float fullLineHeight = lineHeight * fontSize; - if (roundToPixel) { - fullLineHeight = Math.round(fullLineHeight); - } - - TextLineMetrics.TextLineMetricsBuilder textLineMetrics = - TextLineMetrics.builder().height(fullLineHeight); - - int lastSpace = -1; - float lastSpaceWidth = 0; - - int lineStart = 0; - int lineEnd = 0; - - int i = 0; - int lineCount = 1; - TextMetrics.TextMetricsBuilder textMetrics = TextMetrics.builder(); - while (i < textLength) { - int newLineStart = i + 1; - // get codepoint - int codePointSize = getCodePointSize(text, textLength, i, pCodePoint); - int codePoint = pCodePoint.get(0); - if (Character.isSpaceChar(codePoint)) { - lastSpace = i; - lastSpaceWidth = lineWidth; - } - i += codePointSize; - - // get char width - stbtt_GetCodepointHMetrics(fontInfo, codePoint, pAdvance, pLeftSideBearing); - float charWidth = pAdvance.get(0) * scaleFactor; - - if (lineWidth + charWidth > maxWidth) { - lineEnd = wordWrap && lastSpace >= lineStart ? lastSpace + 1 : newLineStart; - textMetrics.line( - textLineMetrics - .characters(text.subSequence(lineStart, lineEnd)) - .height(fullLineHeight) - .width(lineWidth) - .build()); - - lineCount++; - textLineMetrics = - TextLineMetrics.builder().height(fullLineHeight).width(lineWidth - lastSpaceWidth); - - lineStart = lineEnd; - lineWidth = lineWidth - lastSpaceWidth; - lastSpaceWidth = 0; - } - - lineWidth += charWidth; - } - textMetrics.line( - textLineMetrics - .characters(text.subSequence(lineStart, lineEnd)) - .height(fullLineHeight) - .width(lineWidth) - .build()); - textMetrics.height(lineCount * fullLineHeight).fullLineHeight(fullLineHeight); - return textMetrics.build(); - } - } - - @Override - public TextLineMetrics getTextLineMetrics( - @NonNull String text, @NonNull Font font, float fontSize, float lineHeight) { - STBTTFontinfo fontInfo = getFontInfo(font.path()); - try (MemoryStack stack = MemoryStack.stackPush()) { - IntBuffer pCodePoint = stack.mallocInt(1); - IntBuffer pAdvance = stack.mallocInt(1); - IntBuffer pLeftSideBearing = stack.mallocInt(1); - - float scaleFactor = stbtt_ScaleForPixelHeight(fontInfo, fontSize); - - int textLength = text.length(); - - float lineWidth = 0; - int i = 0; - while (i < textLength) { - i += getCodePointSize(text, textLength, i, pCodePoint); - int codePoint = pCodePoint.get(0); - - // get char width - stbtt_GetCodepointHMetrics(fontInfo, codePoint, pAdvance, pLeftSideBearing); - lineWidth += pAdvance.get(0) * scaleFactor; - } - return TextLineMetrics.builder() - .height(fontSize * lineHeight) - .width(lineWidth) - .characters(text) - .build(); - } - } - - // obtains font info from the map or if map has no entry, creates it and adds it to the map - private String getInfo(STBTTFontinfo stbttFontinfo, int i) { - String info = ""; - ByteBuffer name = - stbtt_GetFontNameString( - stbttFontinfo, - STBTT_PLATFORM_ID_MICROSOFT, - STBTT_MS_EID_UNICODE_BMP, - STBTT_MS_LANG_ENGLISH, - i); - if (name != null) { - int capacity = name.capacity(); - byte[] bytes = new byte[capacity]; - name.get(bytes); - info = new String(bytes, StandardCharsets.UTF_16); - } - return info; - } - - private STBTTFontinfo getFontInfo(String fontPath) throws FontLoadingException { - return fontInfoMap.computeIfAbsent(fontPath, this::createFontInfo); - } - - private STBTTFontinfo createFontInfo(String fontPath) throws FontLoadingException { - ByteBuffer fontData = fontStorage.getFontData(fontPath); - STBTTFontinfo stbttFontinfo = STBTTFontinfo.create(); - if (fontData == null || !STBTruetype.stbtt_InitFont(stbttFontinfo, fontData)) { - throw new FontLoadingException("Failed to load font from '%s'".formatted(fontPath)); - } - - for (int i = 0; i < 25; i++) { - ByteBuffer name = - stbtt_GetFontNameString( - stbttFontinfo, - STBTT_PLATFORM_ID_MICROSOFT, - STBTT_MS_EID_UNICODE_BMP, - STBTT_MS_LANG_ENGLISH, - i); - // bytebuffer to string - if (name != null) { - byte[] bytes = new byte[name.capacity()]; - name.get(bytes); - } - } - - return stbttFontinfo; - } - - private int getCodePointSize(String text, int to, int i, IntBuffer cpOut) { - char c1 = text.charAt(i); - if (Character.isHighSurrogate(c1) && i + 1 < to) { - char c2 = text.charAt(i + 1); - if (Character.isLowSurrogate(c2)) { - cpOut.put(0, Character.toCodePoint(c1, c2)); - return 2; - } - } - cpOut.put(0, c1); - return 1; - } -} diff --git a/core/src/main/java/com/spinyowl/spinygui/core/system/font/impl/FontStorageImpl.java b/core/src/main/java/com/spinyowl/spinygui/core/system/font/impl/FontStorageImpl.java deleted file mode 100644 index 216bf1c4..00000000 --- a/core/src/main/java/com/spinyowl/spinygui/core/system/font/impl/FontStorageImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.spinyowl.spinygui.core.system.font.impl; - -import static org.slf4j.LoggerFactory.getLogger; -import com.spinyowl.spinygui.core.system.font.FontStorage; -import com.spinyowl.spinygui.core.util.IOUtil; -import java.nio.ByteBuffer; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import lombok.NonNull; -import org.slf4j.Logger; - -public class FontStorageImpl implements FontStorage { - private static final Logger LOG = getLogger(FontStorageImpl.class); - private final Map dataMap = new ConcurrentHashMap<>(); - - @Override - public ByteBuffer getFontData(@NonNull String path) { - if (dataMap.containsKey(path)) return dataMap.get(path); - return loadFont(path); - } - - /** - * Error safe method to load font and add font data to file storage. In case of error it will - * return null. - * - * @param fontPath path to font which should be loaded. - * @return {@link ByteBuffer} with font data or null in case of failure. - */ - @Override - public ByteBuffer loadFont(String fontPath) { - ByteBuffer fontData = null; - try { - fontData = IOUtil.resourceAsByteBuffer(fontPath); - } catch (Exception e) { - LOG.warn("Failed to load font from {}", fontPath); - } - if (fontData != null) { - dataMap.put(fontPath, fontData); - } - return fontData; - } -} diff --git a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListenerTest.java b/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListenerTest.java deleted file mode 100644 index 1ef791e8..00000000 --- a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemCharEventListenerTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import static com.spinyowl.spinygui.core.node.NodeBuilder.frame; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import com.spinyowl.spinygui.core.event.CharEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemCharEvent; -import com.spinyowl.spinygui.core.time.TimeService; -import com.spinyowl.spinygui.core.util.TextUtil; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class SystemCharEventListenerTest { - - @Mock private EventProcessor eventProcessor; - @Mock private TimeService timeService; - - private SystemEventListener listener; - - @BeforeEach - void setUp() { - listener = - SystemCharEventListener.builder() - .eventProcessor(eventProcessor) - .timeService(timeService) - .build(); - } - - @Test - void process_generatesCharEvent() { - // Arrange - Frame frame = new Frame(); - Element element = new Element("input"); - frame.addChild(element); - - // make element focused so it will be used to generate char event. - element.focused(true); - double currentTime = 1; - - when(timeService.currentTime()).thenReturn(currentTime); - - SystemCharEvent source = createEvent(frame); - - CharEvent expected = - CharEvent.builder() - .source(frame) - .target(element) - .input(TextUtil.cpToStr(1)) - .timestamp(currentTime) - .build(); - - doNothing().when(eventProcessor).push(expected); - - // Act - listener.process(source, frame); - - // Verify - verify(timeService).currentTime(); - verify(eventProcessor).push(expected); - } - - @Test - void process_skipsGeneratingCharEvent() { - // Arrange - Frame frame = new Frame(); - Element focusedElement = new Element("input"); - frame.addChild(focusedElement); - - SystemCharEvent source = createEvent(frame); - - // Act - listener.process(source, frame); - - // Verify - verify(timeService, times(0)).currentTime(); - verify(eventProcessor, times(0)).push(any(CharEvent.class)); - } - - @Test - void process_throwsNPE_ifFrameIsNull() { - SystemCharEvent event = createEvent(frame()); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(event, null)); - } - - @Test - void process_throwsNPE_ifEventIsNull() { - Frame frame = frame(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(null, frame)); - } - - private SystemCharEvent createEvent(Frame frame) { - return SystemCharEvent.builder().frame(frame).codepoint(1).build(); - } -} diff --git a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemCursorPosEventListenerTest.java b/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemCursorPosEventListenerTest.java deleted file mode 100644 index c9ce4a70..00000000 --- a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemCursorPosEventListenerTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import static com.spinyowl.spinygui.core.input.MouseButton.LEFT; -import static com.spinyowl.spinygui.core.node.NodeBuilder.frame; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import com.spinyowl.spinygui.core.event.CursorEnterEvent; -import com.spinyowl.spinygui.core.event.CursorExitEvent; -import com.spinyowl.spinygui.core.event.MouseDragEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.input.MouseService; -import com.spinyowl.spinygui.core.input.MouseService.CursorPositions; -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemCursorPosEvent; -import com.spinyowl.spinygui.core.time.TimeService; -import org.joml.Vector2f; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class SystemCursorPosEventListenerTest { - - @Mock private MouseService mouseService; - @Mock private EventProcessor eventProcessor; - @Mock private TimeService timeService; - - private SystemEventListener listener; - - @BeforeEach - void setUp() { - listener = - SystemCursorPosEventListener.builder() - .eventProcessor(eventProcessor) - .mouseService(mouseService) - .timeService(timeService) - .build(); - } - - @Test - void process_generatesEnterEvent() { - // Arrange - - int posX = 1; - int posY = 1; - - Frame frame = new Frame(); - frame.box().contentSize(100, 100); - - SystemCursorPosEvent event = - SystemCursorPosEvent.builder().posX(posX).posY(posY).frame(frame).build(); - - Vector2f currentFirst = new Vector2f(-1, -1); - Vector2f previousFirst = new Vector2f(-2, -2); - - CursorPositions cursorPositions = new CursorPositions(currentFirst, previousFirst); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - Vector2f currentSecond = new Vector2f(posX, posY); - CursorPositions newCursorPosition = new CursorPositions(currentSecond, currentFirst); - - doNothing().when(mouseService).setCursorPositions(frame, newCursorPosition); - - CursorEnterEvent expectedEnterEvent = - CursorEnterEvent.builder() - .source(frame) - .target(frame) - .intersection(currentSecond) - .cursorPosition(currentSecond) - .build(); - - doNothing().when(eventProcessor).push(expectedEnterEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).getCursorPositions(frame); - verify(mouseService).setCursorPositions(frame, newCursorPosition); - - verify(eventProcessor).push(expectedEnterEvent); - verify(eventProcessor, times(0)).push(any(CursorExitEvent.class)); - verify(eventProcessor, times(0)).push(any(MouseDragEvent.class)); - } - - @Test - void process_generatesExitEvent() { - // Arrange - - int posX = -1; - int posY = -1; - Frame frame = new Frame(); - frame.box().contentSize(100, 100); - - SystemCursorPosEvent event = - SystemCursorPosEvent.builder().posX(posX).posY(posY).frame(frame).build(); - - Vector2f currentFirst = new Vector2f(1, 1); - Vector2f previousFirst = new Vector2f(2, 2); - - CursorPositions cursorPositions = new CursorPositions(currentFirst, previousFirst); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - Vector2f currentSecond = new Vector2f(posX, posY); - CursorPositions newCursorPosition = new CursorPositions(currentSecond, currentFirst); - - doNothing().when(mouseService).setCursorPositions(frame, newCursorPosition); - - CursorExitEvent expectedExitEvent = - CursorExitEvent.builder() - .source(frame) - .target(frame) - .intersection(currentSecond) - .cursorPosition(currentSecond) - .build(); - - doNothing().when(eventProcessor).push(expectedExitEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).getCursorPositions(frame); - verify(mouseService).setCursorPositions(frame, newCursorPosition); - - verify(eventProcessor).push(expectedExitEvent); - verify(eventProcessor, times(0)).push(any(CursorEnterEvent.class)); - verify(eventProcessor, times(0)).push(any(MouseDragEvent.class)); - } - - @Test - void process_generatesDragEvent() { - // Arrange - - int posX = 13; - int posY = 13; - - Frame frame = new Frame(); - frame.box().contentSize(100, 100); - - SystemCursorPosEvent event = - SystemCursorPosEvent.builder().posX(posX).posY(posY).frame(frame).build(); - - Element element = new Element("div"); - element.box().contentSize(10, 10); - element.box().contentPosition(10, 10); - element.focused(true); - frame.addChild(element); - - // by these positions we achieve that current mouse target and previous mouse target are the - // same elements -> no enter/exit events are generated. - Vector2f currentFirst = new Vector2f(12, 12); - Vector2f previousFirst = new Vector2f(11, 11); - - CursorPositions cursorPositions = new CursorPositions(currentFirst, previousFirst); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - when(mouseService.pressed(LEFT)).thenReturn(true); - - Vector2f currentSecond = new Vector2f(posX, posY); - CursorPositions newCursorPosition = new CursorPositions(currentSecond, currentFirst); - - doNothing().when(mouseService).setCursorPositions(frame, newCursorPosition); - - Vector2f delta = currentSecond.sub(currentFirst, new Vector2f()); - MouseDragEvent expectedDragEvent = - MouseDragEvent.builder().source(frame).target(element).delta(delta).build(); - doNothing().when(eventProcessor).push(expectedDragEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).pressed(LEFT); - verify(mouseService).getCursorPositions(frame); - verify(mouseService).setCursorPositions(frame, newCursorPosition); - - verify(eventProcessor, times(1)).push(expectedDragEvent); - verify(eventProcessor, times(0)).push(any(CursorExitEvent.class)); - verify(eventProcessor, times(0)).push(any(CursorEnterEvent.class)); - } - - @Test - void process_throwsNPE_ifFrameIsNull() { - SystemCursorPosEvent event = - SystemCursorPosEvent.builder().posX(1).posY(1).frame(frame()).build(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(event, null)); - } - - @Test - void process_throwsNPE_ifEventIsNull() { - Frame frame = frame(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(null, frame)); - } -} diff --git a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListenerTest.java b/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListenerTest.java deleted file mode 100644 index cf3754a3..00000000 --- a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemKeyEventListenerTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import static com.spinyowl.spinygui.core.input.KeyAction.PRESS; -import static com.spinyowl.spinygui.core.input.KeyAction.RELEASE; -import static com.spinyowl.spinygui.core.input.KeyAction.REPEAT; -import static com.spinyowl.spinygui.core.node.NodeBuilder.div; -import static com.spinyowl.spinygui.core.node.NodeBuilder.frame; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; -import com.google.common.collect.ImmutableSet; -import com.spinyowl.spinygui.core.event.KeyboardEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.input.KeyAction; -import com.spinyowl.spinygui.core.input.KeyCode; -import com.spinyowl.spinygui.core.input.Keyboard; -import com.spinyowl.spinygui.core.input.KeyboardKey; -import com.spinyowl.spinygui.core.input.KeyboardLayout; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemKeyEvent; -import com.spinyowl.spinygui.core.system.input.SystemKeyAction; -import com.spinyowl.spinygui.core.time.TimeService; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class SystemKeyEventListenerTest { - - @Mock private EventProcessor eventProcessor; - @Mock private TimeService timeService; - @Mock private Keyboard keyboard; - - private SystemEventListener listener; - - @BeforeEach - void setUp() { - listener = - SystemKeyEventListener.builder() - .eventProcessor(eventProcessor) - .timeService(timeService) - .keyboard(keyboard) - .build(); - } - - @Test - void process_pressGeneratesKeyboardEvent() { - test(SystemKeyAction.PRESS, PRESS); - } - - @Test - void process_repeatGeneratesKeyboardEvent() { - test(SystemKeyAction.REPEAT, REPEAT); - } - - @Test - void process_releaseGeneratesKeyboardEvent() { - test(SystemKeyAction.RELEASE, RELEASE); - } - - @Test - void process_doNothingIfNoFocusedElement() { - // Arrange - var frame = frame(div()); - SystemKeyEvent systemEvent = - SystemKeyEvent.builder() - .keyCode(7) - .scancode(7) - .action(SystemKeyAction.PRESS) - .mods(ImmutableSet.of()) - .frame(frame) - .build(); - - // Act - listener.process(systemEvent, frame); - - // Verify - verifyNoInteractions(eventProcessor); - verifyNoInteractions(timeService); - verifyNoInteractions(keyboard); - } - - private void test(SystemKeyAction systemAction, KeyAction action) { - // Arrange - - var frame = frame(); - var element = div(); - frame.addChild(element); - double timestamp = 1D; - when(timeService.currentTime()).thenReturn(timestamp); - KeyboardLayout keyboardLayout = mock(KeyboardLayout.class); - when(keyboard.layout()).thenReturn(keyboardLayout); - - int keyCode = 7; - KeyCode keyCodeObject = KeyCode.KEY_7; - int scancode = 7; - - SystemKeyEvent event = - SystemKeyEvent.builder() - .keyCode(keyCode) - .scancode(scancode) - .action(systemAction) - .mods(ImmutableSet.of()) - .frame(frame) - .build(); - - when(keyboardLayout.keyCode(keyCode)).thenReturn(keyCodeObject); - - element.focused(true); - - KeyboardEvent expectedEvent = - KeyboardEvent.builder() - .source(frame) - .target(element) - .action(action) - .timestamp(timestamp) - .mods(ImmutableSet.of()) - .key(new KeyboardKey(keyCodeObject, keyCode, scancode)) - .build(); - doNothing().when(eventProcessor).push(expectedEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(keyboard).layout(); - verify(keyboardLayout).keyCode(keyCode); - verify(timeService).currentTime(); - verify(eventProcessor).push(expectedEvent); - } - - @Test - void process_throwsNPE_ifFrameIsNull() { - SystemKeyEvent event = - SystemKeyEvent.builder() - .keyCode(1) - .scancode(1) - .action(SystemKeyAction.PRESS) - .mods(ImmutableSet.of()) - .frame(frame()) - .build(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(event, null)); - } - - @Test - void process_throwsNPE_ifEventIsNull() { - Frame frame = frame(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(null, frame)); - } -} diff --git a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemMouseClickEventListenerTest.java b/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemMouseClickEventListenerTest.java deleted file mode 100644 index 63dd6ffa..00000000 --- a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemMouseClickEventListenerTest.java +++ /dev/null @@ -1,339 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import static com.spinyowl.spinygui.core.node.NodeBuilder.div; -import static com.spinyowl.spinygui.core.node.NodeBuilder.frame; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import com.google.common.collect.ImmutableSet; -import com.spinyowl.spinygui.core.event.FocusInEvent; -import com.spinyowl.spinygui.core.event.FocusOutEvent; -import com.spinyowl.spinygui.core.event.MouseClickEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.input.KeyAction; -import com.spinyowl.spinygui.core.input.MouseButton; -import com.spinyowl.spinygui.core.input.MouseService; -import com.spinyowl.spinygui.core.input.MouseService.CursorPositions; -import com.spinyowl.spinygui.core.node.Element; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemMouseClickEvent; -import com.spinyowl.spinygui.core.system.input.SystemKeyAction; -import com.spinyowl.spinygui.core.system.input.SystemMouseButton; -import com.spinyowl.spinygui.core.time.TimeService; -import org.joml.Vector2f; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class SystemMouseClickEventListenerTest { - - @Mock private EventProcessor eventProcessor; - @Mock private TimeService timeService; - @Mock private MouseService mouseService; - - private SystemEventListener listener; - - @BeforeEach - void setUp() { - listener = - SystemMouseClickEventListener.builder() - .eventProcessor(eventProcessor) - .timeService(timeService) - .mouseService(mouseService) - .build(); - } - - @Test - void process_pressOutCurrentFrame_generatesReleaseEventForFocusedElement() { - // Arrange - Element element = div(); - element.focused(true); - element.box().contentSize(20, 20); - element.box().contentPosition(20, 20); - - Frame frame = frame(element); - frame.box().contentSize(100, 100); - - SystemMouseClickEvent event = - SystemMouseClickEvent.builder() - .action(SystemKeyAction.PRESS) - .mods(ImmutableSet.of()) - .frame(frame) - .button(SystemMouseButton.LEFT) - .build(); - - doNothing().when(mouseService).pressed(event.button().mouseButton(), true); - - Vector2f current = new Vector2f(-25, -25); // click out of frame (for example in other frame) - CursorPositions cursorPositions = new CursorPositions(current, current); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - double timestamp = 1; - when(timeService.currentTime()).thenReturn(timestamp); - - MouseClickEvent expectedReleaseEvent = - MouseClickEvent.builder() - .source(frame) - .target(element) - .action(KeyAction.RELEASE) - .mouseButton(MouseButton.LEFT) - .position(new Vector2f(element.box().contentPosition()).sub(current).negate()) - .absolutePosition(current) - .mods(ImmutableSet.of()) - .timestamp(timestamp) - .build(); - doNothing().when(eventProcessor).push(expectedReleaseEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).pressed(event.button().mouseButton(), true); - verify(mouseService).getCursorPositions(frame); - - verify(timeService).currentTime(); - - assertFalse(element.focused()); - } - - @Test - void process_pressInCurrentFrame_generatesReleaseEventForFocusedElement() { - // Arrange - Element newFocusedElement = div(); // will gain focus - newFocusedElement.box().contentSize(20, 20); - newFocusedElement.box().contentPosition(20, 20); - - Element oldFocusedElement = div(); // will lose focus - oldFocusedElement.focused(true); - oldFocusedElement.box().contentSize(20, 20); - oldFocusedElement.box().contentPosition(50, 20); - - Frame frame = frame(oldFocusedElement, newFocusedElement); - frame.box().contentSize(100, 100); - - SystemMouseClickEvent event = - SystemMouseClickEvent.builder() - .action(SystemKeyAction.PRESS) - .mods(ImmutableSet.of()) - .frame(frame) - .button(SystemMouseButton.LEFT) - .build(); - - doNothing().when(mouseService).pressed(event.button().mouseButton(), true); - - Vector2f current = new Vector2f(25, 25); // click in frame - CursorPositions cursorPositions = new CursorPositions(current, current); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - double timestamp = 1; - when(timeService.currentTime()).thenReturn(timestamp); - - FocusOutEvent expectedFocusLostEvent = - FocusOutEvent.builder() - .source(frame) - .target(oldFocusedElement) - .timestamp(timestamp) - .nextFocus(newFocusedElement) - .build(); - doNothing().when(eventProcessor).push(expectedFocusLostEvent); - - MouseClickEvent expectedPressEvent = - MouseClickEvent.builder() - .source(frame) - .target(newFocusedElement) - .action(KeyAction.PRESS) - .timestamp(timestamp) - .mouseButton(MouseButton.LEFT) - .position(new Vector2f(newFocusedElement.box().contentPosition()).sub(current).negate()) - .absolutePosition(current) - .mods(event.mappedMods()) - .build(); - doNothing().when(eventProcessor).push(expectedPressEvent); - - FocusInEvent expectedFocusGainedEvent = - FocusInEvent.builder() - .source(frame) - .target(newFocusedElement) - .timestamp(timestamp) - .prevFocus(oldFocusedElement) - .build(); - doNothing().when(eventProcessor).push(expectedFocusGainedEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).pressed(event.button().mouseButton(), true); - verify(mouseService).getCursorPositions(frame); - - verify(timeService, times(3)).currentTime(); - - verify(eventProcessor).push(expectedFocusLostEvent); - verify(eventProcessor).push(expectedPressEvent); - verify(eventProcessor).push(expectedFocusGainedEvent); - - assertFalse(oldFocusedElement.focused()); - assertFalse(oldFocusedElement.pressed()); - - assertTrue(newFocusedElement.focused()); - assertTrue(newFocusedElement.pressed()); - } - - @Test - void process_releaseInCurrentFrame_generatesReleaseEventForFocusedElement() { - // Arrange - Element otherElement = div(); - otherElement.box().contentSize(20, 20); - otherElement.box().contentPosition(20, 20); - - Element focusedElement = div(); // will lose focus - focusedElement.focused(true); - focusedElement.box().contentSize(20, 20); - focusedElement.box().contentPosition(50, 20); - - Frame frame = frame(focusedElement, otherElement); - frame.box().contentSize(100, 100); - - SystemMouseClickEvent event = - SystemMouseClickEvent.builder() - .action(SystemKeyAction.RELEASE) - .mods(ImmutableSet.of()) - .frame(frame) - .button(SystemMouseButton.LEFT) - .build(); - - doNothing().when(mouseService).pressed(event.button().mouseButton(), false); - - Vector2f current = new Vector2f(25, 25); // click in frame - CursorPositions cursorPositions = new CursorPositions(current, current); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - double timestamp = 1; - when(timeService.currentTime()).thenReturn(timestamp); - - MouseClickEvent expectedReleaseEvent = - MouseClickEvent.builder() - .source(frame) - .target(focusedElement) - .action(KeyAction.RELEASE) - .timestamp(timestamp) - .mouseButton(MouseButton.LEFT) - .position(new Vector2f(focusedElement.box().contentPosition()).sub(current).negate()) - .absolutePosition(current) - .mods(event.mappedMods()) - .build(); - doNothing().when(eventProcessor).push(expectedReleaseEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).pressed(event.button().mouseButton(), false); - verify(mouseService).getCursorPositions(frame); - - verify(timeService, times(1)).currentTime(); - - verify(eventProcessor).push(expectedReleaseEvent); - - assertTrue(focusedElement.focused()); - assertFalse(focusedElement.pressed()); - - assertFalse(otherElement.focused()); - assertFalse(otherElement.pressed()); - } - - @Test - void process_releaseInCurrentFrame_generatesClickAndReleaseEventForFocusedElement() { - // Arrange - Element focusedElement = div(); // will lose focus - focusedElement.focused(true); - focusedElement.box().contentSize(20, 20); - focusedElement.box().contentPosition(50, 20); - - Frame frame = frame(focusedElement); - frame.box().contentSize(100, 100); - - SystemMouseClickEvent event = - SystemMouseClickEvent.builder() - .action(SystemKeyAction.RELEASE) - .mods(ImmutableSet.of()) - .frame(frame) - .button(SystemMouseButton.LEFT) - .build(); - - doNothing().when(mouseService).pressed(event.button().mouseButton(), false); - - Vector2f current = new Vector2f(55, 25); // click in frame - CursorPositions cursorPositions = new CursorPositions(current, current); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - double timestamp = 1; - when(timeService.currentTime()).thenReturn(timestamp); - - MouseClickEvent expectedClickEvent = - MouseClickEvent.builder() - .source(frame) - .target(focusedElement) - .action(KeyAction.CLICK) - .timestamp(timestamp) - .mouseButton(MouseButton.LEFT) - .position(new Vector2f(focusedElement.box().contentPosition()).sub(current).negate()) - .absolutePosition(current) - .mods(event.mappedMods()) - .build(); - doNothing().when(eventProcessor).push(expectedClickEvent); - - MouseClickEvent expectedReleaseEvent = - MouseClickEvent.builder() - .source(frame) - .target(focusedElement) - .action(KeyAction.RELEASE) - .timestamp(timestamp) - .mouseButton(MouseButton.LEFT) - .position(new Vector2f(focusedElement.box().contentPosition()).sub(current).negate()) - .absolutePosition(current) - .mods(event.mappedMods()) - .build(); - doNothing().when(eventProcessor).push(expectedReleaseEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).pressed(event.button().mouseButton(), false); - verify(mouseService).getCursorPositions(frame); - - verify(timeService, times(2)).currentTime(); - - verify(eventProcessor).push(expectedClickEvent); - verify(eventProcessor).push(expectedReleaseEvent); - - assertTrue(focusedElement.focused()); - assertFalse(focusedElement.pressed()); - } - - @Test - void process_throwsNPE_ifFrameIsNull() { - SystemMouseClickEvent event = - SystemMouseClickEvent.builder() - .action(SystemKeyAction.RELEASE) - .mods(ImmutableSet.of()) - .frame(frame()) - .button(SystemMouseButton.LEFT) - .build(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(event, null)); - } - - @Test - void process_throwsNPE_ifEventIsNull() { - Frame frame = frame(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(null, frame)); - } -} diff --git a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemScrollEventListenerTest.java b/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemScrollEventListenerTest.java deleted file mode 100644 index 28c17228..00000000 --- a/core/src/test/java/com/spinyowl/spinygui/core/system/event/listener/SystemScrollEventListenerTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.spinyowl.spinygui.core.system.event.listener; - -import static com.spinyowl.spinygui.core.node.NodeBuilder.frame; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import com.spinyowl.spinygui.core.event.ScrollEvent; -import com.spinyowl.spinygui.core.event.processor.EventProcessor; -import com.spinyowl.spinygui.core.input.MouseService; -import com.spinyowl.spinygui.core.input.MouseService.CursorPositions; -import com.spinyowl.spinygui.core.node.Frame; -import com.spinyowl.spinygui.core.system.event.SystemScrollEvent; -import com.spinyowl.spinygui.core.time.TimeService; -import org.joml.Vector2f; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class SystemScrollEventListenerTest { - - public static final int OFFSET_X = 1; - public static final int OFFSET_Y = -1; - @Mock private EventProcessor eventProcessor; - @Mock private TimeService timeService; - @Mock private MouseService mouseService; - - private SystemEventListener listener; - - @BeforeEach - void setUp() { - listener = - SystemScrollEventListener.builder() - .eventProcessor(eventProcessor) - .timeService(timeService) - .mouseService(mouseService) - .build(); - } - - @Test - void process_generatesScrollEvent() { - Frame frame = new Frame(); - frame.box().contentSize(100, 100); - - Vector2f current = new Vector2f(10, 10); - CursorPositions cursorPositions = new CursorPositions(current, current); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - double timestamp = 1D; - when(timeService.currentTime()).thenReturn(timestamp); - - SystemScrollEvent event = createEvent(frame); - - ScrollEvent expectedEvent = - ScrollEvent.builder() - .source(frame) - .target(frame) - .timestamp(timestamp) - .offsetX(OFFSET_X) - .offsetY(OFFSET_Y) - .build(); - doNothing().when(eventProcessor).push(expectedEvent); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).getCursorPositions(frame); - verify(timeService).currentTime(); - verify(eventProcessor).push(expectedEvent); - } - - @Test - void process_doNotGenerateScrollEvent() { - Frame frame = new Frame(); - frame.box().contentSize(100, 100); - - Vector2f current = new Vector2f(-10, -10); - CursorPositions cursorPositions = new CursorPositions(current, current); - when(mouseService.getCursorPositions(frame)).thenReturn(cursorPositions); - - SystemScrollEvent event = createEvent(frame); - - // Act - listener.process(event, frame); - - // Verify - verify(mouseService).getCursorPositions(frame); - verifyNoInteractions(timeService); - verifyNoInteractions(eventProcessor); - } - - @Test - void process_throwsNPE_ifFrameIsNull() { - SystemScrollEvent event = createEvent(frame()); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(event, null)); - } - - @Test - void process_throwsNPE_ifEventIsNull() { - Frame frame = frame(); - Assertions.assertThrows(NullPointerException.class, () -> listener.process(null, frame)); - } - - private SystemScrollEvent createEvent(Frame frame) { - return SystemScrollEvent.builder().frame(frame).offsetX(OFFSET_X).offsetY(OFFSET_Y).build(); - } -} diff --git a/demo.complex/build.gradle b/demo.complex/build.gradle deleted file mode 100644 index 91fe98d4..00000000 --- a/demo.complex/build.gradle +++ /dev/null @@ -1,34 +0,0 @@ -plugins { - id 'java-library' - id 'application' -} - -dependencies { - api project(':core') - - api project(':core.backend') - api project(':core.backend.lwjgl.nanovg') - - api group: 'com.spinyowl', name: 'cbchain', version: '1.0.2'; - - api group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version - api group: 'ch.qos.logback', name: 'logback-classic', version: logback_version - - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl-glfw', version: lwjgl_version, classifier: 'natives-macos' - - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version, classifier: 'natives-windows' - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version, classifier: 'natives-linux' - api group: 'org.lwjgl', name: 'lwjgl-opengl', version: lwjgl_version, classifier: 'natives-macos' -} -application { - mainClass = "com.spinyowl.spinygui.demo.complex.Main" -} diff --git a/demo.simple/build.gradle b/demo.simple/build.gradle deleted file mode 100644 index 868f8801..00000000 --- a/demo.simple/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id 'java-library' - id 'application' -} - -dependencies { - implementation project(':spinygui') - - api group: 'org.slf4j', name: 'slf4j-api', version: slf4j_version - api group: 'ch.qos.logback', name: 'logback-classic', version: logback_version -} -application { - mainClass = "com.spinyowl.spinygui.demo.simple.Main" -} diff --git a/docs/drafts/spinygui-performance-findings.md b/docs/drafts/spinygui-performance-findings.md new file mode 100644 index 00000000..76d9fbd5 --- /dev/null +++ b/docs/drafts/spinygui-performance-findings.md @@ -0,0 +1,578 @@ +# SpinyGUI Performance Findings and Proposed Improvements + +## Document Status + +- Status: Draft +- Scope: SpinyGUI CPU and allocation behavior observed through the Rogue Crawler client +- Evidence source: `build/e85-diagnostics.jfr` +- Related closeout: [E8.5/M33.5/P4/T2](<../work/E8.5/M33.5/P4/T2 - Smoke Profile and Close E8.5.md>) +- Roadmap effect: None. This draft does not authorize implementation or change an epic or milestone status. + +## Purpose + +Record the exact SpinyGUI implementation hotspots identified during E8.5 profiling, explain why they +are expensive, and propose an ordered optimization strategy. The findings distinguish SpinyGUI-owned +costs from the uncapped Rogue Crawler render loop that amplifies them. + +This document is not evidence that every sampled allocation originates from one named line. JFR +allocation events are sampled and stack-search counts are indicators rather than invocation counts. +The listed implementation sites are the strongest code-level matches for the observed categories and +must be validated through focused benchmarks and matched before/after recordings. + +## Executive Summary + +The E8.5 XML fragment parser is not the performance bottleneck. The recording contains one execution +sample involving `ClientUiFragmentSource.newInstance` and no allocation sample involving that method. +Automated E8.5 tests separately prove that unchanged diagnostics updates do not parse or build XML +fragment instances. + +The dominant problem is allocation during every rendered UI frame: + +- child-list access creates read-only wrappers and filtered child lists; +- render traversal repeatedly creates positions, rectangles, affine transforms, and state scopes; +- text rendering clones fragments and creates native UTF-8 buffers; +- style recalculation scans all rules, splits class strings with regular expressions, and reconstructs + property maps; +- layout invalidation rebuilds temporary layout trees and can repeat complete layout up to four times; +- string-keyed `TreeMap` style storage adds comparison and node-allocation overhead. + +The client was rendering at approximately 2,874.4 FPS during the smoke. This is outside SpinyGUI, but +it multiplies every per-render SpinyGUI cost. Frame limiting or VSync should therefore precede or +accompany library optimization so measurements represent a realistic presentation cadence. + +## JFR Baseline + +### Recording + +| Fact | Value | +|---|---:| +| Runtime | Java 25.0.3, Windows amd64 | +| Entry point | `ClientShellMain` | +| Duration | 122 seconds | +| Start | 2026-07-25 14:03:31 UTC | +| Events | 265,035 | +| Observed frame rate | Approximately 2,874.4 FPS | + +### Allocation and Garbage Collection + +| Metric | Value | +|---|---:| +| Main-thread allocation | 198.1 GB | +| Main-thread allocation share | 99.92% | +| Approximate allocation rate | 1.62 GB/s | +| G1 young collections | 466 | +| Total GC pause | 348 ms | +| Average GC pause | 0.748 ms | +| P95 GC pause | 0.987 ms | +| P99 GC pause | 1.29 ms | +| Maximum GC pause | 1.68 ms | +| Observed live post-GC heap | Approximately 153-154 MB | + +The recording shows high transient allocation rather than retained-heap growth. No object-statistics +class exceeded one percent of the heap. Short pauses explain why the smoke remained responsive, but +they do not make the allocation rate acceptable. + +At the observed FPS, the allocation rate is roughly 565 KB per rendered frame. If the per-render +portion scaled linearly, 60 FPS would reduce it to approximately 34 MB/s and 120 FPS to approximately +68 MB/s. Those are estimates, not acceptance thresholds; matched capped recordings are required. + +### CPU and Sampled Hot Methods + +| Metric | Value | +|---|---:| +| JVM user load | 6.87% | +| JVM system load | 1.47% | +| Main-thread user load | 5.67% | +| Main-thread system load | 0.38% | +| Machine average load | 21.90% | + +| Sampled method | Samples | +|---|---:| +| `HashMap.getNode` | 8.96% | +| `TreeMap.getEntry` | 5.82% | +| Regex greedy matching | 5.42% | +| `String.compareTo` | 3.40% | +| `AffineTransform.multiply` | 3.11% | +| `Node.layoutAbsolutePosition` | 2.80% | +| `TreeMap.fixAfterInsertion` | 2.59% | +| `NvgTextRenderer.renderFragment` | 1.18% | + +Stack-search aggregation found SpinyGUI renderer methods in 1,564 of 6,785 execution samples, +`StyleManagerImpl` in 1,158, and `LayoutServiceImpl` in 62. These figures include samples where the +method appears anywhere in the stack and are not invocation counts. + +### Sampled Allocation Leaders + +| Allocation site | Pressure | +|---|---:| +| `Collections.unmodifiableList` | 14.16% | +| `AffineTransform.multiply` | 7.86% | +| `Pattern.compile()` | 6.99% | +| `StreamSupport.stream` | 4.81% | +| `Rect.position` | 3.47% | +| Stream filtering | 3.38% | +| `SpinedBuffer` construction | 3.02% | +| `Pattern.compile(String)` | 2.37% | +| `HashMap.resize` | 2.31% | +| `Pattern.matcher` | 1.62% | +| `InlineFragment.Builder.build` | 1.24% | + +## Findings + +### F1: Read-Only Child Access Allocates on Every Call + +**Evidence:** `Collections.unmodifiableList` is the largest sampled allocation site at 14.16%. + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Element.java:210-221` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Text.java:37` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/ResolvedStyle.java:158-159` + +`Element.childNodes()` and `Element.inlineFragments()` call `Collections.unmodifiableList` every time. +That method creates a new wrapper even when the backing list did not change. These accessors are used +throughout render, style, layout, input, and mutation paths. + +`Node.children()` compounds the problem: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Node.java:155-162` + +It creates a stream pipeline and collects a new mutable list of element children on every call. + +**Proposed changes:** + +- Create one retained read-only view for each mutable backing list and return that view. +- Add package-private allocation-free traversal methods for SpinyGUI internals, such as + `forEachChildElement(Consumer)` or a stable element-child view. +- Consider maintaining a child-element list alongside the node list if profiling shows repeated type + filtering remains material. +- Preserve public mutation rules; do not expose the mutable backing collection. + +**Expected effect:** Remove the largest sampled allocation category and much of the stream/list churn +visible in rendering and style traversal. + +### F2: Position and Box Accessors Allocate Temporary Geometry + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/layout/Rect.java:17-34` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/layout/Box.java:37-139` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Node.java:217-242` + +`Rect.position()` and `Rect.size()` create `Vector2f` instances. `Box` builds more vectors and expanded +`Rect` objects for content, padding, border, and margin queries. `Node.layoutAbsolutePosition()` then +recursively creates and mutates vectors while walking offset parents. + +JFR attributed 3.47% of sampled allocation to `Rect.position` and 2.80% of hot-method samples to +`Node.layoutAbsolutePosition`. + +**Proposed changes:** + +- Add primitive geometry accessors for renderer, layout, and hit-testing paths. +- Store resolved layout-space and viewport-space X/Y values as primitive fields after layout. +- Add output-parameter overloads only where a vector API remains useful. +- Avoid building expanded `Rect` values when callers need only four primitive bounds. +- Invalidate cached absolute coordinates only when layout, ancestor scroll, or presentation transform + changes. + +**Expected effect:** Remove recurring vector and rectangle allocation from every node traversal. + +### F3: Affine Transform Composition Allocates Per Element Per Frame + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/types/AffineTransform.java:23-58` +- `third_party/SpinyGUI/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgRenderer.java:116-137` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/util/PresentationCoordinates.java:78-105` + +Every translation and multiplication returns a new immutable `AffineTransform`. Rendering composes +translations around each element's border box every frame. Input coordinate mapping additionally +builds an ancestor list, reverses it, and repeatedly creates transforms and points. + +JFR attributed 7.86% of sampled allocation and 3.11% of hot-method samples to +`AffineTransform.multiply`. + +**Proposed changes:** + +- Compute and retain each element's composed presentation transform when style/layout/scroll changes. +- Cache the inverse transform used for hit testing at the same invalidation boundary. +- Use primitive matrix coefficients or an internal mutable accumulator while composing transforms. +- Preserve immutable `AffineTransform` at public API boundaries if that contract remains desirable. +- Remove the ancestor-list allocation by using cached ancestry results or a non-allocating traversal. + +**Expected effect:** Remove one of the largest per-element render allocation sources and reduce input +mapping overhead. + +### F4: NanoVG Render State Allocates Scope Objects Per Element + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgRenderer.java:106-137` +- `third_party/SpinyGUI/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgTransformState.java:18-33` +- `third_party/SpinyGUI/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgSubtreeContentState.java:18-49` + +The renderer creates `NvgTransformState` and `NvgSubtreeContentState` objects while entering every +element. Clipped elements also allocate position vectors and expanded padding/border rectangles. + +**Proposed changes:** + +- Replace allocated `AutoCloseable` state scopes with direct `nvgSave`/`nvgRestore` guarded by + `try/finally`. +- Submit cached transform coefficients directly to NanoVG. +- Compute clip bounds from primitives instead of temporary vectors and rectangles. +- Retain full-tree painting for correctness initially; optimize allocations before introducing render + caching. + +**Expected effect:** Reduce per-element per-frame allocation without changing immediate-mode rendering +semantics. + +### F5: Class Selectors Recompile Regex and Retokenize Classes + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/stylesheet/selector/simple/ClassAttributeSelector.java:23-30` + +Each class selector test runs: + +```java +var classList = classAttributes.split("\\s+"); +return Arrays.asList(classList).contains(className); +``` + +This compiles or resolves a regular expression, allocates an array, wraps it as a list, and performs a +linear search. It happens while every stylesheet rule is tested against every element. + +JFR attributed 6.99% plus 2.37% of sampled allocation to regex compilation and 5.42% of hot-method +samples to regex matching. + +**Proposed changes:** + +- Parse class tokens when the `class` attribute changes, not during selector testing. +- Store tokens in an immutable or narrowly mutable set optimized for membership checks. +- Invalidate only selector-match caches affected by the changed attribute. +- Index stylesheet rules by class so an element does not test unrelated class selectors. + +**Expected effect:** Remove a major CPU/allocation source from every style refresh. + +### F6: Text Whitespace Normalization Uses Regex During Layout + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/InlineWhitespace.java:14-24` + +Text normalization uses `replaceAll` for normal, nowrap, and pre-line handling. This contributes to +regex allocation whenever text is laid out. + +**Proposed changes:** + +- Normalize whitespace with a direct character scanner. +- Cache normalized content until text, `white-space`, or tab-size changes. +- Keep CSS whitespace behavior covered by focused Unicode, newline, tab, and repeated-space tests. + +**Expected effect:** Reduce regex work during dirty text layout. + +### F7: Style Recalculation Rebuilds the Entire Style State + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/manager/StyleManagerImpl.java:76-107` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/manager/StyleManagerImpl.java:129-207` + +One recalculation performs two full element-tree traversals. For each element it: + +- allocates rule and scrollbar collections; +- tests all user-agent and document rules; +- creates stream/sort/to-list pipelines; +- creates filtered `Ruleset` instances; +- copies the previous style map; +- clears and reapplies all declarations; +- computes every absent property; +- may copy the completed style map again for the style listener. + +`StyleManagerImpl` appeared in 1,158 of 6,785 sampled execution stacks. + +**Proposed changes:** + +- Index selector candidates by ID, class, tag, pseudo-state, and universal fallback. +- Cache matched static rules until relevant attributes, ancestry, or stylesheet identity changes. +- Separate static selector changes from hover/focus/pressed presentation changes. +- Recalculate only dirty elements and dependency-affected descendants. +- Avoid previous/new style-map copies when no transition listener consumes them. +- Reuse per-element rule buffers where ownership permits. + +**Expected effect:** Reduce style refresh from full-tree, all-rule work to affected-element work. + +### F8: Resolved Style Uses a String-Keyed TreeMap + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/ResolvedStyle.java:138-181` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/style/manager/StyleManagerImpl.java:87-107` + +Every resolved style stores properties in `TreeMap`. Rendering and layout repeatedly +look up properties by string. Recalculation clears and repopulates the tree. + +JFR showed `TreeMap.getEntry` at 5.82% of hot-method samples, `String.compareTo` at 3.40%, and +`TreeMap.fixAfterInsertion` at 2.59%. + +**Proposed changes:** + +- First test `HashMap` or `LinkedHashMap` if deterministic iteration is needed but sorted ordering is not. +- Prefer stable integer property IDs and indexed property slots for hot typed getters. +- Keep extension/custom property storage separate from built-in hot properties. +- Avoid reconstructing entries for unchanged computed properties. + +**Expected effect:** Reduce string comparisons, tree traversal, and entry insertion during both render +lookups and style recalculation. + +### F9: Text Rendering Clones Fragments and Re-encodes Text Every Frame + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgTextRenderer.java:69-103` +- `third_party/SpinyGUI/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgTextRenderer.java:130-164` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/InlineFragment.java:12-45` + +`withColor` rebuilds an entire `InlineFragment` to change only its color. Each resolved text run then +calls `memUTF8` and `memFree` during every render. + +`InlineFragment.Builder.build` accounted for 1.24% of sampled allocation. Native text buffer work is +not fully represented by ordinary heap allocation samples. + +**Proposed changes:** + +- Pass presented color and opacity separately to the text sink instead of cloning fragments. +- Cache encoded UTF-8 data for unchanged rendered runs with explicit lifetime management. +- Alternatively use a reusable frame-local native buffer sized for the largest submitted run. +- Invalidate text buffers only when text, font fallback resolution, or shaping output changes. + +**Expected effect:** Reduce heap and native allocation during every visible text render. + +### F10: Dirty Layout Rebuilds Temporary Trees and May Run Four Full Passes + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/LayoutServiceImpl.java:41-69` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/LayoutServiceImpl.java:71-124` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/LayoutServiceImpl.java:202-303` + +Each dirty layout may execute up to four complete passes while scrollbar gutters settle. Every pass +creates layout contexts, reconstructs wrapper trees, creates multiple linked lists, rewrites layout +child lists, rescans scroll bounds, and clears hidden descendants. + +`LayoutServiceImpl` appeared in only 62 sampled execution stacks, so this is lower priority than +per-render allocation for the captured workload. It remains expensive when input or content dirties the +frame. + +**Proposed changes:** + +- Retain layout-tree nodes and update membership only after structural or positioning changes. +- Track layout-dirty subtrees and affected ancestors. +- Recompute scrollbar bounds only for changed descendants and their scroll containers. +- Clear hidden subtree state only on visibility transitions. +- Reuse layout contexts and temporary buffers where thread confinement permits. +- Preserve the bounded scrollbar convergence rule and test nested scrollbar cases. + +**Expected effect:** Lower the cost of the remaining 4 Hz, pointer-driven, resize, and content-driven +layout refreshes. + +### F11: ID Lookup Traverses and Allocates Instead of Using an Index + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Frame.java:99-172` + +`getElementById` allocates a result list, recursively calls allocating `children()`, and then creates a +stream to return the first item. The `stopAtFirst` flag returns only from the current recursion frame; +the parent's `forEach` continues through sibling branches. + +E8.5 mitigates this in Rogue Crawler by caching stable bindings, but the library API remains inefficient. + +**Proposed changes:** + +- Maintain a frame-owned ID index updated by attachment, detachment, and ID-attribute changes. +- Reject or explicitly define duplicate-ID behavior. +- At minimum, replace the result-list implementation with an early-return depth-first search. + +**Expected effect:** Make bindings and dynamic lookups constant-time or allocation-free. + +### F12: Child Mutation Performs Linear and Re-entrant Work + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Element.java:153-208` +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/Node.java:87-104` + +`addChild` linearly scans existing children for identity. `removeChild` calls `node.parent(null)`, while +`Node.parent` calls back into the old parent's `removeChild`, creating avoidable re-entrant removal +work. Existing sibling unlinking also does not visibly update first/last child fields or clear detached +sibling references in this path. + +This was not a dominant stable-frame JFR hotspot, but it affects XML-created list expansion and reorder +correctness. + +**Proposed changes:** + +- Make parent reassignment a single-owner operation without recursive callbacks. +- Correct first/last and detached sibling bookkeeping atomically. +- Add explicit insert, move-before, and move-after APIs that preserve node identity. +- Avoid whole-list remove/re-add algorithms for reorder. +- Add structural invariant tests after every mutation sequence. + +**Expected effect:** Reduce dynamic list churn and eliminate a correctness risk before broader component +composition adoption. + +### F13: XML Parsing Is Not a Stable-Path Priority + +Relevant code: + +- `third_party/SpinyGUI/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/DefaultNodeParser.java` + +The parser invokes Jsoup and allocates a new mutable node tree, so it is not cheap. E8.5 intentionally +uses it only for new keyed component instances. The recording found one execution sample and no +allocation samples involving `ClientUiFragmentSource.newInstance`. + +**Proposed decision:** + +- Do not prioritize a compiled template or node-clone system based on this recording. +- Preserve source caching and new-key-only parsing. +- Revisit parser prototypes only if matched recordings with much larger dynamic lists identify initial + expansion as a user-visible problem. + +## Non-SpinyGUI Amplifier: Uncapped Rendering + +Rogue Crawler submitted UI at approximately 2,874.4 FPS during the recording. This makes any per-frame +allocation severe even when each individual frame is fast. + +**Proposed application/engine changes:** + +- Enable VSync or implement a configurable presentation frame cap. +- Keep authoritative simulation, snapshot publication, diagnostics cadence, and rendering clocks + separate. +- Compare 60 FPS, 120 FPS, VSync, and uncapped recordings using the same UI interaction script. +- Record FPS, allocation/s, allocation/frame, CPU, GC count, pause percentiles, and visible latency. + +Frame limiting is not a substitute for fixing SpinyGUI. It is the fastest way to stop multiplying the +current per-render waste and to obtain realistic optimization baselines. + +## Prioritized Change Sequence + +### Stage 0: Establish Matched Baselines + +- Capture separate collapsed-idle, expanded-idle, pointer-active, and resize recordings. +- Capture uncapped, 120 FPS, and 60 FPS variants. +- Record allocation per second and per frame rather than only total allocation. +- Add a representative large diagnostics frame fixture for engine/SpinyGUI benchmarks. + +### Stage 1: Remove Accessor and Geometry Allocation + +- Retain read-only list views. +- Add allocation-free internal child traversal. +- Add primitive box/position accessors. +- Cache layout absolute positions and composed transforms. +- Remove per-element NanoVG state-scope allocation. + +This stage addresses the largest measured per-render categories with comparatively narrow behavior +changes. + +### Stage 2: Remove Regex and Style-Property Overhead + +- Cache class tokens. +- Replace whitespace regex normalization. +- Index selector candidates. +- Replace or redesign `ResolvedStyle` property storage. +- Avoid unchanged full-map rebuilds and copies. + +### Stage 3: Reduce Text Render Allocation + +- Stop cloning `InlineFragment` for presented color. +- Reuse or cache UTF-8 run buffers. +- Verify font fallback, Unicode, opacity, and dynamic text updates. + +### Stage 4: Add Incremental Style and Layout + +- Introduce explicit dirty reasons and affected roots. +- Recalculate styles only for affected elements/subtrees. +- Relayout affected subtrees and ancestors. +- Retain layout-tree structures across unchanged frames. +- Preserve pointer pseudo-state, scrolling, nested overflow, transforms, and scrollbar convergence. + +This stage is higher risk and should follow the lower-risk allocation removals and focused tests. + +### Stage 5: Harden Lookup and Mutation APIs + +- Add ID indexing. +- Correct parent/sibling bookkeeping. +- Add identity-preserving move APIs. +- Prove structural invariants and listener/focus retention. + +## Verification Strategy + +### Automated Correctness + +- Run all SpinyGUI core and NanoVG backend tests. +- Add tests that repeated read-only access returns a stable view and cannot mutate backing collections. +- Add allocation-count or object-identity tests around child traversal, transforms, and fragment color + submission where deterministic instrumentation is practical. +- Add selector tests for multiple spaces, tabs, empty class attributes, duplicate class tokens, and + class mutation. +- Add Unicode and whitespace-mode tests before replacing regex normalization. +- Add transform/hit-test equivalence tests for nested transforms, scrolling, singular transforms, and + resize. +- Add mutation invariants for parent, first/last child, previous/next sibling, detach, move, and reattach. +- Add style/layout invalidation tests for hover, focus, pressed, content, class, inline style, resize, + scrollbars, and hidden subtrees. + +### Performance Evidence + +- Use the same Java version, window size, client state, diagnostics rows, and interaction script. +- Warm the application before starting the comparison interval. +- Separate initial expansion from stable rendering. +- Report total allocation, allocation/s, allocation/frame, main-thread CPU, GC count, pause percentiles, + and sampled hot methods/sites. +- Do not use one timing threshold as the only acceptance check. +- Confirm that optimized paths remain absent or materially reduced in JFR allocation and execution + samples. + +### Rogue Crawler Integration + +- Run `gradlew.bat :game:client:test` and `gradlew.bat :game:engine:test` when the included SpinyGUI + build changes. +- Run `gradlew.bat build` before integrating a completed optimization slice. +- Repeat desktop smoke for collapse/expand, scrolling, resize, direct-connect typing, hover/focus, + passive-text movement, F3, and shutdown. +- Keep SpinyGUI performance changes out of authoritative core/server/protocol modules. + +## Risks and Tradeoffs + +- Cached child views must remain read-only and synchronized with structural mutation. +- Cached transforms and positions require precise invalidation for ancestor scroll and animation. +- Indexed selectors must preserve CSS specificity, source order, combinators, and pseudo-state behavior. +- Replacing `TreeMap` can change deterministic iteration if callers depend on sorted property names. +- Cached native text buffers require explicit ownership and cleanup to avoid native leaks. +- Incremental layout can leave stale geometry if dirty propagation is incomplete. +- Render caching can break animation, caret, hover, scroll, and opacity behavior; remove allocation before + attempting broad paint caching. +- Frame limiting can hide but not remove per-frame inefficiency; always retain allocation/frame metrics. + +## Recommended First Implementation Slice + +The smallest high-confidence SpinyGUI slice should contain: + +1. Retained read-only views for child nodes, inline fragments, and resolved rules. +2. Allocation-free internal element-child traversal. +3. Primitive geometry accessors used by NanoVG render traversal. +4. Removal of allocated NanoVG transform/content state scopes. +5. Focused unit tests and matched 60/120/uncapped JFR recordings. + +Do not combine this first slice with selector indexing, `ResolvedStyle` redesign, incremental layout, or +text-buffer caching. Those changes have different correctness risks and should be reviewed separately. + +## Deferred Decisions + +- Whether frame limiting belongs in `game:engine` configuration or application composition. +- Whether built-in CSS properties move to indexed slots or a faster map as an intermediate step. +- Whether selector indexing should be implemented per stylesheet or as one frame-owned index. +- Whether UTF-8 text buffers are retained per fragment, per resolved run, or in a frame allocator. +- Whether incremental style/layout is upstreamed to SpinyGUI before additional Rogue Crawler UI work. +- Whether these findings become a new E9 follow-up milestone or an upstream SpinyGUI-only roadmap. diff --git a/docs/epics.md b/docs/epics.md new file mode 100644 index 00000000..db381f65 --- /dev/null +++ b/docs/epics.md @@ -0,0 +1,29 @@ +# Epic Status + +Last reviewed: 2026-08-15 + +This document tracks the current status of the top-level epic plans under +[`docs/work`](work/). Status is based on the epic's milestone documents and checked +implementation/acceptance evidence, not on the presence of a plan alone. + +| Epic | Goal | Status | Current boundary and next work | +| --- | --- | --- | --- | +| [E1: CSS Animation Support](work/E1%20-%20CSS%20animation%20support.md) | Deliver bounded CSS transforms, transitions, and keyframe animation support. | In progress | Transform and transition work has delivered bounded implementation evidence; keyframes and hardening/documentation remain open. | +| [E2: Frame runtime integration](work/E2%20-%20Frame%20runtime%20integration.md) | Provide an optional higher-level runtime for composing frame services and lifecycle ordering. | Planned | No frame-runtime implementation was found. The checked E2 child documents describe font-family resolution and need reclassification. | +| [E3: CSS Grid support](work/E3%20-%20CSS%20Grid%20support.md) | Deliver a first-class, bounded CSS Grid Level 1 formatting context. | In progress | A substantial typed Grid Level 1 subset is implemented and tested; container alignment, intrinsic sizing, edge-case grammar, broader interaction proof, and final documentation remain. | +| [E3.5: Chart.js Benchmark Charts](work/E3.5%20-%20Chart.js%20Benchmark%20Charts.md) | Provide offline benchmark reports with typed Chart.js visualizations. | Complete | Typed offline Chart.js reporting, supported artifact regeneration, structural inspection, and direct-file browser verification are complete. | +| [E4: Text Performance Benchmarks](work/E4%20-%20Text%20performance%20benchmarks.md) | Establish reproducible text measurement, layout, allocation, and rendering benchmarks. | Complete | The full benchmark suite and a fresh paired CPU/rendering report run pass; the current manifest selects complete comparable evidence. | +| [E5: Text Performance Improvements](work/E5%20-%20Text%20performance%20improvements.md) | Improve text-path measurement, font lifecycle, controls, rendering submission, caches, and orchestration. | In progress | M1 evidence repair and M2 approved-contract/linear uncached measurement are complete. M3 font identity, generations, and lifecycle is next; M4-M8 remain planned behind their documented dependencies. | +| [E6: Frame Pipeline Performance](work/E6%20-%20Frame%20pipeline%20performance.md) | Reduce non-text frame CPU cost and transient allocation while preserving ownership boundaries. | In progress | M1 and M1.5 are implemented; traversal, selector, property-storage, incremental-boundary, and mutation work remains. | +| [E7: Skija Renderer Backends](work/E7%20-%20Skija%20renderer%20backends.md) | Add opt-in Skija OpenGL and Vulkan renderers behind the backend-neutral renderer SPI. | Planned | Renderer-host and Skija backend milestones are defined but not yet started in this checkout. | + +## Status conventions + +- **Complete** means the tracked epic work has no remaining open acceptance/task items in its current plan set. +- **Complete with verification caveat** means the implementation boundary is complete, but a known + verification failure or manual check remains explicitly recorded. +- **In progress** means implementation or evidence exists, but one or more planned boundaries remain open. +- **Planned** means the epic is documented but has no completed implementation boundary recorded here. + +Update this index and the matching `**Status:**` field in each epic document when a milestone +crosses a verified boundary. Do not infer completion from a commit or an unchecked proposal alone. diff --git a/docs/features/button-element-support.md b/docs/features/button-element-support.md new file mode 100644 index 00000000..9c7b4c92 --- /dev/null +++ b/docs/features/button-element-support.md @@ -0,0 +1,196 @@ +# Button Element Support Plan + +## Goal +Add first-class support for the HTML-like `` element with current attributes and children. +- [x] Add parser tests for plain text content, nested inline content, missing type, and explicit `type="button"`. +- [x] Add a regression test proving `` still parses as `InputElement` and remains childless/value-based. + +**Acceptance Checks:** +- [x] `` parses as `ButtonElement` with a text child, not generic `Element`. +- [x] `` preserves the nested child tree. +- [x] `toHtml(...)` round-trips button child content and attributes. +- [x] Existing input and textarea parser tests still pass. + +**Dependencies:** Step 1. + +**Risks:** Jsoup may normalize whitespace around button text; tests should match current parser behavior rather than claiming full browser whitespace parity. + +### Step 3: Define Button Layout Defaults +**Purpose:** Give buttons usable geometry when authors do not specify width or height. + +**Changes:** +- [x] Extend layout handling so `ButtonElement` auto width is based on child content plus padding and border, or document why current block-width behavior is intentionally retained for v1. +- [x] Compute auto height from child inline content or font line-height plus padding and border. +- [x] Ensure explicit CSS width, height, min/max width, and min/max height still override defaults. +- [x] Add tests for text-only button sizing, nested content sizing, and styled-size overrides. +- [x] Verify button layout does not trigger text-input caret/scroll calculations. + +**Acceptance Checks:** +- [x] Layout tests prove a text-only button has non-zero content and border-box size. +- [x] Layout tests prove explicit `width` and `height` are respected. +- [x] Layout tests prove nested inline content contributes to height/width or is handled by the documented v1 fallback. + +**Dependencies:** Steps 1 and 2. + +**Risks:** Content-based auto width may require careful ordering because current block layout often resolves width before children. If that is too invasive, prefer a conservative default button width in v1 and document content-based width as deferred. + +### Step 4: Add Activation Semantics +**Purpose:** Make buttons behave like controls instead of passive elements. + +**Changes:** +- [x] Define the v1 activation contract: mouse click activates on press/release inside, keyboard activates on `Space` and/or `Enter` when focused, and disabled buttons do not activate if disabled support is included. +- [x] Reuse existing focused/pressed/click state where possible. +- [x] Add a small backend-agnostic `ButtonBehavior` only if listener logic would otherwise become scattered. +- [x] Wire `SystemMouseClickEventListener` so button activation coexists with existing focus and `MouseClickEvent` emission. +- [x] Wire `SystemKeyEventListener` so focused button activation does not invoke text-input or textarea behavior. +- [x] Decide whether activation is represented by existing `MouseClickEvent`/`KeyboardEvent`, a new GUI event, or both; add tests for the chosen contract. + +**Acceptance Checks:** +- [x] Listener tests prove mouse press/release/click on a button emits the expected activation signal. +- [x] Listener tests prove `Enter` and/or `Space` on a focused button emits the expected activation signal. +- [x] Listener tests prove non-button elements keep current click/key behavior. +- [x] Listener tests prove text input and textarea behavior is unchanged. + +**Dependencies:** Step 1. + +**Risks:** Current event dispatch is exact-class based. If a new activation event is introduced, it needs explicit processor/listener tests and should not silently rely on superclass dispatch. + +### Step 5: Add Default Button Styling and Render-State Proof +**Purpose:** Make an unstyled button visibly identifiable and show focus/hover/pressed state through existing rendering. + +**Changes:** +- [x] Locate where default/user-agent-like styles are applied, if any, and add button defaults there; if none exist, add scoped demo CSS first and defer global defaults. +- [x] Define conservative defaults for padding, border, background, foreground, and focused/pressed/hovered variants using existing supported selectors/states. +- [x] Confirm generic `NvgElementRenderer`, `NvgBorderRenderer`, and `NvgTextRenderer` render button content correctly. +- [x] Add `NvgButtonRenderer` only if default/pressed visuals require rendering behavior that cannot be expressed with style. +- [x] Add backend tests only where useful; no new renderer code was added, and the narrow proof covers button text through the existing text renderer. + +**Acceptance Checks:** +- [x] Manual or automated render proof shows text content inside the button is visible. +- [x] Hover/focus/pressed styling changes are visible if supported by current state selectors. +- [x] Existing NanoVG tests pass. + +**Implementation Notes:** +- No global user-agent stylesheet mechanism was found; button defaults were added as scoped demo CSS in `button-demo.css`. +- `:focus` and `:active` now resolve through existing `focused` and `pressed` element state, alongside existing `:hover` support. +- No `NvgButtonRenderer` was added because `ButtonElement` is content-bearing and renders through the generic element, border, and text renderer path. + +**Dependencies:** Steps 2, 3, and 4. + +**Risks:** Adding global default styles can affect every demo. Keep first implementation scoped unless the repo already has a clear default stylesheet mechanism. + +### Step 6: Update Demo Coverage +**Purpose:** Provide a real manual verification path for button behavior and the ``, a button with nested inline content, and ``. +- [x] Display activation feedback in the demo through existing event listeners or a small visible state update. +- [x] Ensure the demo key mapping supports `Enter` and `Space` for keyboard activation. + +**Acceptance Checks:** +- [x] `:spinygui.demo.complex:classes` succeeds. +- [ ] Manual demo check verifies click activation, keyboard activation, focus behavior, and visible pressed/focused state. +- [ ] Manual demo check verifies `` remains value-based and does not accept child content. + +**Implementation Notes:** +- `ButtonExample` loads dedicated XML/CSS resources and updates visible status text from `ActionEvent` listeners. +- The shared demo keyboard map now includes `Space`, matching the core activation listener contract. +- Screenshot evidence shows the plain and nested ``. + +**Implementation Notes:** +- Chose the narrow `InputElement.buttonInput()` predicate for Step 1 because the requested feature is specifically ``; `submit` and `reset` inputs remain deferred until form semantics are designed. + +**Dependencies:** None. + +**Risks:** Do not create `InputButtonElement`; this repo already chose one `InputElement` model with type-specific composed behavior. + +### Step 2: Add Button-Input Layout Defaults +**Purpose:** Give `input[type=button]` sensible geometry when no explicit CSS width or height is supplied. + +**Changes:** +- [x] Extend `BlockLayout` so button inputs use value-text measurement plus padding and border for auto width. +- [x] Compute auto height from font line height plus padding and border, matching the control sizing approach used by text inputs and buttons. +- [x] Respect explicit `width`, `height`, `min-*`, and `max-*` constraints. +- [x] Add layout tests for value-based auto size, empty-value fallback size, and explicit styled size. +- [x] Add a regression check that text-input sizing still uses the existing text-input path. + +**Acceptance Checks:** +- [x] A value-labelled input button has non-zero border-box width and height without child nodes. +- [x] Explicit width and height override auto sizing. +- [x] `input[type=text]` layout tests still pass unchanged. + +**Implementation Notes:** +- Button input auto width uses the `value` text when present and a 64px content-width fallback for an empty value so the control still has usable geometry. + +**Dependencies:** Step 1. + +**Risks:** Measuring by `value` may duplicate button text measurement logic. Prefer a small shared helper only if it removes concrete duplication without obscuring the different content sources. + +### Step 3: Render the Value Label Without Editing Chrome +**Purpose:** Display the `value` text for button inputs while avoiding caret, selection, and text-scroll affordances. + +**Changes:** +- [x] Update NanoVG input rendering to handle button inputs separately from text inputs. +- [x] Render the value label centered vertically, clipped to the content box, and styled with the resolved text color/font. +- [x] Do not render caret or selection for button inputs, even when focused. +- [x] Add backend renderer tests or sink-level tests proving text-input rendering still gates caret/selection and button-input rendering draws only the label. + +**Acceptance Checks:** +- [x] `input[type=button]` renders its `value` label. +- [x] Focused `input[type=button]` does not render a caret. +- [x] Existing `NvgInputRendererTest` text-input expectations still pass. + +**Implementation Notes:** +- `NvgInputRenderer` now accepts text and button inputs, draws button input values through the existing clipped text sink, and skips selection/caret/text-scroll affordances for button inputs. + +**Dependencies:** Steps 1 and 2. + +**Risks:** The existing renderer returns early when `textMeasurer` is missing. Keep that behavior consistent unless tests show a practical demo failure. + +### Step 4: Wire Mouse and Keyboard Activation +**Purpose:** Make button inputs interactive through the same public event contract as `` beside ``. +- [x] Add `ActionEvent` feedback for the input button in `ButtonExample`. +- [x] Add focused/active CSS for `input[type=button]` if selector support permits; otherwise use an id/class selector and document the limitation. +- [x] Verify the demo keyboard layout still maps `Enter`, `Numpad Enter`, and `Space`. + +**Acceptance Checks:** +- [x] `:spinygui.demo.complex:classes` succeeds. +- [ ] Manual demo check confirms click activation updates visible feedback for `input[type=button]`. +- [ ] Manual demo check confirms keyboard activation updates visible feedback for `input[type=button]`. +- [x] Manual demo check confirms ` diff --git a/spinygui.benchmark/src/main/jte/report.jte b/spinygui.benchmark/src/main/jte/report.jte new file mode 100644 index 00000000..87e54e34 --- /dev/null +++ b/spinygui.benchmark/src/main/jte/report.jte @@ -0,0 +1,134 @@ +@import com.spinyowl.spinygui.benchmark.report.BenchmarkReportPage +@param BenchmarkReportPage page + + + + + + SpinyGUI Local Benchmark Report + + +
+

SpinyGUI Local Benchmark Report

+

Informational local results. Compare only equivalent hardware, drivers, operating systems, and Java runtimes.

+ +
+

Overview

+

${page.report().evidenceStatus()} Structural validation: ${page.report().structuralValidationStatus()} @template.components.help("help-structural-validation", "Structural validation", "Records production text commands and verifies source-bound text, state, and ordering before timing results are reported.")

+
+
Current run${page.report().currentRunIdentifier()}Build: ${page.report().buildStatus()}
+
Eligible archive runs${page.report().archiveHealth().eligibleRunCount()}${page.report().archiveHealth().excludedTimingArtifactCount()} excluded timing artifacts
+
Slowest CPU operation${page.report().slowestCpuLatency()} us/op${page.report().slowestCpuName()}
+
Largest allocation${page.report().largestAllocation()} B/op${page.report().largestAllocationName()}
+
Largest GPU p99${page.report().largestGpuP99()} us${page.report().largestGpuBudget120()} of the 120 Hz budget; ${page.report().largestGpuFragments()} fragments
+
+
Technical evidence and fingerprints +

Comparability metadata

+

Implementation metadata

+
+
Archive status: ${page.report().archiveHealth().eligibleRunCount()} eligible runs, ${page.report().archiveHealth().excludedTimingArtifactCount()} excluded timing artifacts, ${page.report().archiveHealth().diagnosticArtifactCount()} diagnostic artifacts + @if(page.report().archiveHealth().artifacts().isEmpty()) +

This report was generated from direct inputs, so no archive scan was available.

+ @else +
@for(var artifact : page.report().archiveHealth().artifacts())@endfor
Archive artifacts and report eligibility
FileKindStatusReason
${artifact.fileName()}${artifact.kind()}${artifact.status()}${artifact.reason()}
+ @endif +
+
+
+

CPU operations @template.components.help("help-latency", "Latency", "Latency is average microseconds per operation; lower values are faster.")

+

Average latency and normalized allocation. Both charts use a logarithmic base-10 scale so smaller workloads remain distinguishable.

+
CPU operation latency (us/op)

How to read: Lower values are better.

Interactive chart unavailable. Use the precise CPU data table.

+
Normalized CPU allocation (B/op)

How to read: Lower values are better.

Interactive chart unavailable. Use the precise CPU data table.

+
@for(var row : page.report().cpuRows())@endfor
Precise CPU benchmark results
Operation / parametersLatency / uncertainty @template.components.help("help-uncertainty", "Uncertainty", "JMH score error is the reported confidence interval half-width for the average score.")Allocation @template.components.help("help-allocation", "Allocation", "Normalized allocation is bytes allocated per operation; allocation rate is megabytes allocated per second.")
${row.name()} @for(var parameter : row.parameters())${parameter.key()}: ${parameter.value()}@endfor
Identity details
${row.latency()} +/- ${row.uncertainty()} us/op${row.allocation()} B/op; ${row.allocationRate()} MB/sec
+
+
+

NanoVG rendering @template.components.help("help-percentiles", "Percentiles", "Median is the middle sample; p95 and p99 show the latency not exceeded by 95% and 99% of samples.")

+

Median, p95, and p99 latency. The dashed markers are labeled directly at the 120 Hz and 60 Hz frame budgets; values above 60 Hz are clipped and highlighted. @template.components.help("help-frame-budgets", "Frame budgets", "60 Hz permits 16,667 us per frame and 120 Hz permits 8,333 us per frame.")

+
CPU submission latency by rendering scene

How to read: Lower values are better.

Interactive chart unavailable. Use the precise rendering data table.

+
GPU-complete latency by rendering scene

How to read: Lower values are better.

Interactive chart unavailable. Use the precise rendering data table.

+
@for(var row : page.report().sceneRows())@endfor
Precise NanoVG rendering results
Declared scene / observed evidence @template.components.help("help-scene-complexity", "Scene evidence", "The scene name comes from declared-input metadata. Fragments, nodes, code points, glyphs, and runs are per-run observed evidence only; they never identify or fingerprint a series.")CPU median/p95/p99 (us)GPU median/p95/p99 (us)CPU 60/120% @template.components.help("help-cpu-budget", "CPU budget", "CPU submission percentages compare CPU time with the 60 Hz and 120 Hz frame budgets.")GPU 60/120% @template.components.help("help-gpu-budget", "GPU budget", "GPU completion percentages compare synchronized GPU time with the 60 Hz and 120 Hz frame budgets.")Samples @template.components.help("help-samples", "Samples", "Warmup frames prepare the renderer; measured frames provide the reported latency statistics.")
${row.name()}
@for(var entry : row.evidence())${entry.key()}: ${entry.value()}@endfor
Identity details
${row.cpuLatency()}${row.gpuLatency()}${row.cpuBudget()}${row.gpuBudget()}${row.samples()}
+
+
+

Performance history

Values are chronological. Signed changes appear only when identity, workload, environment, and settings fingerprints match the immediately previous complete run.

+ @if(page.report().history().size() < 2) +

No trend yet

One eligible run is available. Generate one more comparable CPU/rendering pair to see a trend; the current values remain available in the table below.

+ @else +

Trend viewport

Choose a CPU operation or rendering scene. Missing runs leave gaps; the tables below remain the precise data view.

+
+
Performance history trend

How to read: Lower values are better.

Interactive chart unavailable. Use the precise history data tables below.

+ @endif +
@for(var run : page.report().history())

${run.identifier()}

Run evidence and revisions

Comparability metadata

Implementation metadata

@for(var row : run.cpuRows())@endfor
CPU history for ${run.identifier()}
CPU operation / parametersLatency (us/op)ChangeAllocation (B/op)Change
${row.name()} @for(var parameter : row.parameters())${parameter.key()}: ${parameter.value()}@endfor${row.latency()}${row.latencyChange()}${row.allocation()}${row.allocationChange()}
@for(var row : run.sceneRows())@endfor
Rendering history for ${run.identifier()}
Declared scene / observed evidenceCPU median/p95/p99CPU changeGPU median/p95/p99GPU changeCPU/GPU 120 HzBudget change
${row.name()}
@for(var entry : row.evidence())${entry.key()}: ${entry.value()}@endfor
${row.cpuLatency()}${row.cpuChange()}${row.gpuLatency()}${row.gpuChange()}${row.cpuBudget120()} / ${row.gpuBudget120()}${row.cpuBudgetChange()} / ${row.gpuBudgetChange()}
@endfor
+
+
+

Methodology

Environment

@for(var entry : page.report().environment())${entry.key()}: ${entry.value()}@endfor
+

Measurement

CPU results are JMH average-time measurements. Rendering uses a hidden context and synchronized GPU completion. Accepted timing/allocation runs use diagnostics-disabled mode and one report-owned CPU/rendering pair.

+

Comparison rules

Identity, workload-input, environment/JVM/driver, settings, and required fingerprints qualify every signed delta. Changed profiles remain separate and never receive a cross-profile delta. Implementation revisions are traceability metadata only.

+

Evidence limits

Diagnostics-enabled counter runs are separate evidence. Glyph, run, fragment, line, command, and culling counts describe observed structure and never identify a series. All results remain hardware-, operating-system-, JVM-, GPU-, and driver-sensitive.

+

Report inputs

reports/text-calculation-<datetime>.jsonreports/nanovg-text-<datetime>.jsonreports/report-manifest.json

All CSS and chart graphics are embedded in this file; no external resources are loaded.

+
+
diff --git a/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/THIRD-PARTY-LICENSES.txt b/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/THIRD-PARTY-LICENSES.txt new file mode 100644 index 00000000..a10194f3 --- /dev/null +++ b/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/THIRD-PARTY-LICENSES.txt @@ -0,0 +1,19 @@ +Chart.js 4.5.1 +The MIT License (MIT) +Copyright (c) 2014-2024 Chart.js Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +@kurkle/color 0.3.2 +The MIT License (MIT) +Copyright (c) 2018-2021 Jukka Kurkela + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/benchmark-charts.js b/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/benchmark-charts.js new file mode 100644 index 00000000..89c3a553 --- /dev/null +++ b/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/benchmark-charts.js @@ -0,0 +1,211 @@ +(() => { + 'use strict'; + const data = JSON.parse(document.getElementById('benchmark-chart-data').textContent); + data.chartPayloadVersion; + const report = data.charts; + const colors = { text:'#e8edf5', muted:'#aebed0', grid:'#304052', blue:'#55b6e8', orange:'#e8a855', purple:'#b27ce8', warning:'#e85c55' }; + + function mountChart(canvasId, config) { + const canvas = document.getElementById(canvasId); + if (!canvas) return null; + try { + const chart = new Chart(canvas, config); + canvas.closest('.chart-shell').dataset.chartReady = 'true'; + return chart; + } catch (error) { + console.error(`Unable to initialize ${canvasId}`, error); + return null; + } + } + + function chartOptions(xScale, xTitle, yTitle) { + return { + indexAxis:'y', + responsive:true, + maintainAspectRatio:false, + animation:false, + scales:{ + x:{...xScale, title:{display:true, text:xTitle, color:colors.text}, ticks:{color:colors.muted}, grid:{color:colors.grid}}, + y:{title:{display:true, text:yTitle, color:colors.text}, ticks:{color:colors.muted}, grid:{color:colors.grid}} + }, + layout:{padding:{right:68}}, + plugins:{legend:{labels:{color:colors.text}}, tooltip:{backgroundColor:'#080c12', titleColor:colors.text, bodyColor:colors.text}} + }; + } + + function formatChartValue(value) { + return Number(value).toLocaleString(undefined, {maximumFractionDigits:3}); + } + + const valueLabels = { + id:'valueLabels', + afterDatasetsDraw(chart) { + const context = chart.ctx; + context.save(); + context.fillStyle = colors.text; + context.font = '12px system-ui, sans-serif'; + context.textBaseline = 'middle'; + chart.data.datasets.forEach((dataset, datasetIndex) => { + const meta = chart.getDatasetMeta(datasetIndex); + meta.data.forEach((bar, dataIndex) => { + const value = dataset.data[dataIndex]; + const preferredX = bar.x + 6; + const clipped = preferredX > chart.chartArea.right - 4; + context.textAlign = clipped ? 'right' : 'left'; + context.fillText(`${formatChartValue(value)} ${dataset.valueLabelUnit}`, + clipped ? chart.chartArea.right - 4 : preferredX, bar.y); + }); + }); + context.restore(); + } + }; + + function cpuConfig(xTitle, yTitle, label, color, value, tooltipLines) { + const options = chartOptions({type:'logarithmic'}, xTitle, yTitle); + options.scales.x.ticks.callback = value => Number.isInteger(Math.log10(Number(value))) + ? Number(value).toLocaleString() : ''; + options.plugins.tooltip.callbacks = {label(context) { + return tooltipLines(report.cpu[context.dataIndex]); + }}; + return { + type:'bar', + data:{labels:report.cpu.map(row => row.label), datasets:[{label, data:report.cpu.map(value), + backgroundColor:color, valueLabelUnit:xTitle.match(/\((.+)\)/)?.[1] ?? ''}]}, + options, + plugins:[valueLabels] + }; + } + + const budgetMarkers = { + id:'budgetMarkers', + afterDraw(chart) { + const context = chart.ctx; + const scale = chart.scales.x; + context.save(); + context.strokeStyle = '#f2d05c'; + context.setLineDash([5, 4]); + context.fillStyle = '#f2d05c'; + context.font = '12px system-ui, sans-serif'; + context.textBaseline = 'top'; + for (const [value, label] of [[8333, '120 Hz'], [16667, '60 Hz']]) { + const x = scale.getPixelForValue(value); + context.beginPath(); + context.moveTo(x, chart.chartArea.top); + context.lineTo(x, chart.chartArea.bottom); + context.stroke(); + context.textAlign = value === 16667 ? 'right' : 'left'; + context.fillText(label, value === 16667 ? x - 4 : x + 4, chart.chartArea.top + 4); + } + context.restore(); + } + }; + + function renderingConfig(xTitle, yTitle, label, fields) { + const options = chartOptions({type:'linear', min:0, max:16667}, xTitle, yTitle); + return { + type:'bar', + data:{ + labels:report.rendering.map(row => row.label), + datasets:fields.map(([name, field, color]) => ({ + label:name, + data:report.rendering.map(row => row[field]), + backgroundColor(context) { return Number(context.raw) > 16667 ? colors.warning : color; } + })) + }, + options, + plugins:[budgetMarkers] + }; + } + + mountChart('cpu-latency-chart', cpuConfig('Latency (us/op)', 'CPU operation', 'Latency (us/op)', colors.blue, row => row.latency, + row => [`${row.latency} us/op`, `Uncertainty: ${row.uncertainty === null ? 'not reported' : `${row.uncertainty} us/op`}`])); + mountChart('cpu-allocation-chart', cpuConfig('Allocation (B/op)', 'CPU operation', 'Allocation (B/op)', colors.orange, row => row.allocation, + row => [`${row.allocation} B/op`, `Allocation rate: ${row.allocationRate === null ? 'not reported' : `${row.allocationRate} MB/sec`}`])); + mountChart('cpu-rendering-chart', renderingConfig('Latency (us)', 'Rendering scene', 'CPU submission latency', [ + ['Median', 'cpuMedian', colors.blue], ['p95', 'cpuP95', colors.orange], ['p99', 'cpuP99', colors.purple] + ])); + mountChart('gpu-rendering-chart', renderingConfig('Latency (us)', 'Rendering scene', 'GPU-complete latency', [ + ['Median', 'gpuMedian', colors.blue], ['p95', 'gpuP95', colors.orange], ['p99', 'gpuP99', colors.purple] + ])); + + const trends = new Map(report.trends.map(trend => [trend.id, trend])); + const trendSelect = document.getElementById('trend-select'); + let historyChart = null; + let activeTrend = null; + + function historyMetricTitle(trend) { + return trend.id.startsWith('cpu-') ? 'CPU latency (us/op)' : 'GPU p99 latency (us)'; + } + + function historyConfig(trend) { + activeTrend = trend; + return { + type:'line', + data:{labels:report.historyRuns, datasets:[{label:`${trend.label} (${trend.unit})`, data:trend.values, + borderColor:colors.blue, backgroundColor:colors.blue, pointBackgroundColor:'#f2d05c', spanGaps:false, + segment:{borderColor(context) { const change = trend.changes[context.p1DataIndex]; + return change && /^[+-]/.test(change) ? colors.blue : 'transparent'; }}}]}, + options:{responsive:true, maintainAspectRatio:false, animation:false, + scales:{x:{title:{display:true, text:'Benchmark run', color:colors.text}, ticks:{color:colors.muted},grid:{color:colors.grid}}, + y:{min:trend.minimum,max:trend.maximum,title:{display:true, text:historyMetricTitle(trend), color:colors.text},ticks:{color:colors.muted},grid:{color:colors.grid}}}, + plugins:{legend:{labels:{color:colors.text}}, tooltip:{backgroundColor:'#080c12', titleColor:colors.text, bodyColor:colors.text, + callbacks:{title(context) { return report.historyRuns[context[0].dataIndex]; }, label(context) { + return `${Number(context.parsed.y).toLocaleString()} ${activeTrend.unit}`; + }, afterLabel(context) { return `Change: ${activeTrend.changes[context.dataIndex] ?? 'not available'}`; }}}}} + }; + } + + function activateTrend(trendId) { + const trend = trends.get(trendId); + if (!trend) { + console.error(`Unable to select missing history trend ${trendId}`); + return; + } + if (!historyChart) return; + const canvas = document.getElementById('history-chart'); + canvas.setAttribute('aria-label', `${trend.label} history trend in ${trend.unit}. Use the precise history tables below for values.`); + historyChart.data.datasets[0].label = `${trend.label} (${trend.unit})`; + historyChart.data.datasets[0].data = trend.values; + activeTrend = trend; + historyChart.options.scales.y.min = trend.minimum; + historyChart.options.scales.y.max = trend.maximum; + historyChart.options.scales.y.title.text = historyMetricTitle(trend); + historyChart.update(); + } + + if (trendSelect) { + const initialTrend = trends.get(trendSelect.value); + if (initialTrend) { + const canvas = document.getElementById('history-chart'); + canvas.setAttribute('aria-label', `${initialTrend.label} history trend in ${initialTrend.unit}. Use the precise history tables below for values.`); + historyChart = mountChart('history-chart', historyConfig(initialTrend)); + } + trendSelect.addEventListener('change', () => activateTrend(trendSelect.value)); + } + + const navLinks = Array.from(document.querySelectorAll('.report-nav a')); + const sections = navLinks.map(link => document.querySelector(link.getAttribute('href'))).filter(Boolean); + if (sections.length) { + let navFrame = 0; + const updateActiveNavigation = () => { + const activationLine = 90; + const active = sections.reduce((current, section) => + section.getBoundingClientRect().top <= activationLine ? section : current, sections[0]); + navLinks.forEach(link => { + if (link.getAttribute('href') === `#${active.id}`) link.setAttribute('aria-current', 'location'); + else link.removeAttribute('aria-current'); + }); + }; + const scheduleNavigationUpdate = () => { + if (navFrame) return; + navFrame = window.requestAnimationFrame(() => { + navFrame = 0; + updateActiveNavigation(); + }); + }; + window.addEventListener('scroll', scheduleNavigationUpdate, {passive:true}); + window.addEventListener('resize', scheduleNavigationUpdate); + window.addEventListener('hashchange', scheduleNavigationUpdate); + updateActiveNavigation(); + } +})(); diff --git a/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/chart.umd.min.js b/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/chart.umd.min.js new file mode 100644 index 00000000..4bd8b46a --- /dev/null +++ b/spinygui.benchmark/src/main/resources/com/spinyowl/spinygui/benchmark/report/chart.umd.min.js @@ -0,0 +1,13 @@ +/*! + * Chart.js v4.5.1 + * https://www.chartjs.org + * (c) 2025 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const n=e.length;let o=0,a=n;if(t._sorted){const{iScale:r,vScale:l,_parsed:h}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=r.axis,{min:u,max:f,minDefined:g,maxDefined:p}=r.getUserBounds();if(g){if(o=Math.min(it(h,d,u).lo,i?n:it(e,d,r.getPixelForValue(u)).lo),c){const t=h.slice(0,o+1).reverse().findIndex((t=>!s(t[l.axis])));o-=Math.max(0,t)}o=Z(o,0,n-1)}if(p){let t=Math.max(it(h,r.axis,f,!0).hi+1,i?0:it(e,d,r.getPixelForValue(f),!0).hi+1);if(c){const e=h.slice(t-1).findIndex((t=>!s(t[l.axis])));t+=Math.max(0,e)}a=Z(t,o,n)-o}else a=n-o}return{start:o,count:a}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class xt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var bt=new xt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Jt{constructor(t){if(t instanceof Jt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Jt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Zt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Zt(t)?t:new Jt(t)}function te(t){return Zt(t)?t:new Jt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function xe(t,e){return me(t).getPropertyValue(e)}const be=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=be[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Me(t.height*s),o=Me(t.width*s);t.height=Me(t.height),t.width=Me(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=xe(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Ze(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Ze(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Ze(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Je(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Ze(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Je(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const xi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,bi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(xi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(bi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:J,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hb||l(n,x,p)&&0!==r(n,x),v=()=>!b||0===r(o,p)||l(o,x,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==x&&(b=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,x=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r!s(t[e.axis])));n.lo-=Math.max(0,a);const r=i.slice(n.hi).findIndex((t=>!s(t[e.axis])));n.hi+=Math.max(0,r)}return n}if(o._sharedOptions){const t=a[0],s="function"==typeof t.getRange&&t.getRange(e);if(s){const t=r(a,e,i-s),n=r(a,e,i+s);return{lo:t.lo,hi:n.hi}}}}return{lo:0,hi:a.length-1}}function $i(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Ki={evaluateInteractionItems:$i,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tYi(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Xi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>qi(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>qi(t,ve(e,t),"y",i.intersect,s)}};const Gi=["left","top","right","bottom"];function Ji(t,e){return t.filter((t=>t.pos===e))}function Zi(t,e){return t.filter((t=>-1===Gi.indexOf(t.pos)&&t.box.axis===e))}function Qi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function ts(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Gi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function os(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Qi(Ji(e,"left"),!0),n=Qi(Ji(e,"right")),o=Qi(Ji(e,"top"),!0),a=Qi(Ji(e,"bottom")),r=Zi(e,"x"),l=Zi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ji(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);is(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=ts(l.concat(h),d);os(r.fullSize,g,d,p),os(l,g,d,p),os(h,g,d,p)&&os(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),rs(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,rs(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class hs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class cs extends hs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ds="$chartjs",us={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},fs=t=>null===t||""===t;const gs=!!Se&&{passive:!0};function ps(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,gs)}function ms(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function xs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.addedNodes,s),e=e&&!ms(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function bs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.removedNodes,s),e=e&&!ms(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const _s=new Map;let ys=0;function vs(){const t=window.devicePixelRatio;t!==ys&&(ys=t,_s.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function Ms(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){_s.size||window.addEventListener("resize",vs),_s.set(t,e)}(t,o),a}function ws(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){_s.delete(t),_s.size||window.removeEventListener("resize",vs)}(t)}function ks(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=us[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,gs)}(s,e,n),n}class Ss extends hs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[ds]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",fs(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(fs(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[ds])return!1;const i=e[ds].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[ds],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:xs,detach:bs,resize:Ms}[e]||ks;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:ws,detach:ws,resize:ws}[e]||ps)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function Ps(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?cs:Ss}var Ds=Object.freeze({__proto__:null,BasePlatform:hs,BasicPlatform:cs,DomPlatform:Ss,_detectPlatform:Ps});const Cs="transparent",Os={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Cs),n=s.valid&&Qt(e||Cs);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class As{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Os[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new As(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(bt.add(this._chart,i),!0):void 0}}function Ls(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Es(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function Vs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Ws(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ns=t=>"reset"===t||"none"===t,Hs=(t,e)=>e?t:Object.assign({},t);class js{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Is(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ws(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Bs(t,"x")),o=e.yAxisID=l(i.yAxisID,Bs(t,"y")),a=e.rAxisID=l(i.rAxisID,Bs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Ws(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Es(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Hs(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Ts(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ns(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Ns(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ns(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function Ys(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for(Us(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,qs=(t,e)=>Math.min(e||t,t);function Ks(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Js(t){return t.drawTicks?t.tickLength:0}function Zs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Qs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class tn extends $s{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Z(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Js(t.grid)-e.padding-Zs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(Z((h.highest.height+6)/o,-1,1)),Math.asin(Z(a/r,-1,1))-Math.asin(Z(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Zs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Js(n)+o):(t.height=this.maxHeight,t.width=Js(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Js(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,x=function(t){return Ae(i,t,p)};let b,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)b=x(this.bottom),w=this.bottom-u,S=b-m,D=x(t.top)+m,O=t.bottom;else if("bottom"===a)b=x(this.top),D=t.top,O=x(t.bottom)-m,w=b+m,S=this.top+u;else if("left"===a)b=x(this.right),M=this.right-u,k=b-m,P=x(t.left)+m,C=t.right;else if("right"===a)b=x(this.left),P=t.left,C=x(t.right)-m,M=b+m,k=this.left+u;else if("x"===e){if("center"===a)b=x((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=b+m,S=w+u}else if("y"===e){if("center"===a)b=x((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}M=b-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}x.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return x}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class sn{constructor(){this.controllers=new en(js,"datasets",!0),this.elements=new en($s,"elements"),this.plugins=new en(Object,"plugins"),this.scales=new en(tn,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function an(t,e){return e||!1!==t?!0===t?{}:t:null}function rn(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function ln(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function hn(t){if("x"===t||"y"===t||"r"===t)return t}function cn(t,...e){if(hn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&hn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function dn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function un(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=ln(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=cn(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return dn(t,"x",i[0])||dn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=b(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||ln(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),b(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];b(e,[ue.scales[e.type],ue.scale])})),a}function fn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=un(t,e)}function gn(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const pn=new Map,mn=new Set;function xn(t,e){let i=pn.get(t);return i||(i=e(),pn.set(t,i),mn.add(i)),i}const bn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class _n{constructor(t){this._config=function(t){return(t=t||{}).data=gn(t.data),fn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=gn(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),fn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return xn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return xn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return xn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return xn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>bn(r,t,e)))),e.forEach((t=>bn(r,s,t))),e.forEach((t=>bn(r,re[n]||{},t))),e.forEach((t=>bn(r,ue,t))),e.forEach((t=>bn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),mn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=yn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||vn(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=yn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function yn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const vn=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const Mn=["top","bottom","left","right","chartArea"];function wn(t,e){return"top"===t||"bottom"===t||-1===Mn.indexOf(t)&&"x"===e}function kn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function Sn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function Pn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Dn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Cn={},On=t=>{const e=Dn(t);return Object.values(Cn).filter((t=>t.canvas===e)).pop()};function An(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class Tn{static defaults=ue;static instances=Cn;static overrides=re;static registry=nn;static version="4.5.1";static getChart=On;static register(...t){nn.add(...t),Ln()}static unregister(...t){nn.remove(...t),Ln()}constructor(t,e){const s=this.config=new _n(e),n=Dn(t),o=On(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||Ps(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new on,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Cn[this.id]=this,r&&l?(bt.listen(this,"complete",Sn),bt.listen(this,"progress",Pn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return nn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return bt.stop(this),this}resize(t,e){bt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=cn(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=cn(o,n),r=l(n.type,e.dtype);void 0!==n.position&&wn(n.position,a)===wn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(nn.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{ls.configure(this,t,t.options),ls.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(kn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{ls.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){An(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;ls.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i={meta:t,index:t.index,cancelable:!0},s=Ni(this,t);!1!==this.notifyPlugins("beforeDatasetDraw",i)&&(s&&Ie(e,s),t.controller.draw(),s&&ze(e),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Ki.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),bt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Ln(){return u(Tn.instances,(t=>t._plugins.invalidate()))}function En(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class Rn{static override(t){Object.assign(Rn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return En()}parse(){return En()}format(){return En()}add(){return En()}diff(){return En()}startOf(){return En()}endOf(){return En()}}var In={_date:Rn};function zn(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Vn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data,{labels:{pointStyle:i,textAlign:s,color:n,useBorderRadius:o,borderRadius:a}}=t.legend.options;return e.labels.length&&e.datasets.length?e.labels.map(((e,r)=>{const l=t.getDatasetMeta(0).controller.getStyle(r);return{text:e,fillStyle:l.backgroundColor,fontColor:n,hidden:!t.getDataVisibility(r),lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:l.borderWidth,strokeStyle:l.borderColor,textAlign:s,pointStyle:i,borderRadius:o&&(a||l.borderRadius),index:r}})):[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nJ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>J(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),x=g(C,h,d),b=g(C+E,c,u);s=(p-x)/2,n=(m-b)/2,o=-(p+x)/2,a=-(m+b)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),x=(i.width-o)/f,b=(i.height-o)/g,_=Math.max(Math.min(x,b)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Un=Object.freeze({__proto__:null,BarController:class extends js{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Vn(t,e,i,s)}parseArrayData(t,e,i,s){return Vn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const t=this.chart.scales,e=this.chart.options.indexAxis;return Object.keys(t).filter((i=>t[i].axis===e)).shift()}_getAxis(){const t={},e=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)t[l("x"===this.chart.options.indexAxis?i.xAxisID:i.yAxisID,e)]=!0;return Object.keys(t)}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(x-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);x=Math.max(Math.min(x,h),o),d=x+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(x))}if(x===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;x+=t,u-=t}return{size:u,base:x,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;const c=this._getAxisCount();if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,d="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=x?g:{};if(i=b){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),x||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends $n{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:Yn,RadarController:class extends js{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>x,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),b||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Xn(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Z(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Z(n.innerStart,0,a),innerEnd:Z(n.innerEnd,0,a)}}function qn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Kn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,x=n-p-f,{outerStart:b,outerEnd:_,innerStart:y,innerEnd:v}=Xn(e,u,d,x-m),M=d-b,w=d-_,k=m+b/M,S=x-_/w,P=u+y,D=u+v,O=m+y/P,A=x-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=qn(w,S,a,r);t.arc(e.x,e.y,_,S,x+E)}const i=qn(D,x,a,r);if(t.lineTo(i.x,i.y),v>0){const e=qn(D,A,a,r);t.arc(e.x,e.y,v,x+E,A+Math.PI)}const s=(x-v/u+(m+y/u))/2;if(t.arc(a,r,u,x-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=qn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=qn(M,m,a,r);if(t.lineTo(n.x,n.y),b>0){const e=qn(M,k,a,r);t.arc(e.x,e.y,b,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Gn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u,borderRadius:f}=l,g="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,g?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let p=e.endAngle;if(o){Kn(t,e,i,s,p,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,p),l.selfJoin&&p-a>=C&&0===f&&"miter"!==c&&function(t,e,i){const{startAngle:s,x:n,y:o,outerRadius:a,innerRadius:r,options:l}=e,{borderWidth:h,borderJoinStyle:c}=l,d=Math.min(h/a,G(s-i));if(t.beginPath(),t.arc(n,o,a-h/2,s+d/2,i-d/2),r>0){const e=Math.min(h/r,G(s-i));t.arc(n,o,r+h/2,i-e/2,s+e/2,!0)}else{const e=Math.min(h/2,a*G(s-i));if("round"===c)t.arc(n,o,e,i-C/2,s+C/2,!0);else if("bevel"===c){const a=2*e*e,r=-a*Math.cos(i+C/2)+n,l=-a*Math.sin(i+C/2)+o,h=a*Math.cos(s+C/2)+n,c=a*Math.sin(s+C/2)+o;t.lineTo(r,l),t.lineTo(h,c)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}(t,e,p),o||(Kn(t,e,i,s,p,n),t.stroke())}function Jn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Qn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[b(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[b(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(x*m+e)/++x):(_(),t.lineTo(e,i),u=s,x=0,f=g=i),p=i}_()}function io(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?eo:to}const so="function"==typeof Path2D;function no(t,e,i,s){so&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Jn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=io(e);for(const r of n)Jn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class oo extends $s{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=J(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){Kn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function mo(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,x=!s(a),b=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!x&&!b)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),x&&b&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=x?a:M,w=b?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(x&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return b&&u&&w!==r?i.length&&V(i[i.length-1].value,r,xo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):b&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class _o extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const yo=t=>Math.floor(z(t)),vo=(t,e)=>Math.pow(10,yo(t)+e);function Mo(t){return 1===t/Math.pow(10,yo(t))}function wo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function ko(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=yo(e);let o=function(t,e){let i=yo(e-t);for(;wo(t,e,i)>10;)i++;for(;wo(t,e,i)<10;)i--;return Math.min(i,yo(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:Mo(g),significand:u}),s}class So extends tn{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===vo(this.min,0)?vo(this.min,-1):vo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(vo(i,-1)),o(vo(s,1)))),i<=0&&n(vo(s,-1)),s<=0&&o(vo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=ko({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function Po(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Do(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Co(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Ao(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function To(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function Lo(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Eo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(Po(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Po(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Co(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));Lo(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Eo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Io={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},zo=Object.keys(Io);function Fo(t,e){return t-e}function Vo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Bo(t,e,i,s){const n=zo.length;for(let o=zo.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function No(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class Ho extends tn{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new In._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Vo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Bo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=zo.length-1;o>=zo.indexOf(i);o--){const i=zo[o];if(Io[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return zo[i?zo.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=zo.indexOf(t)+1,i=zo.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Z(s,0,o),n=Z(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Bo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var $o=Object.freeze({__proto__:null,CategoryScale:class extends tn{static id="category";static defaults={ticks:{callback:mo}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:Z(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:po(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return mo.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:_o,LogarithmicScale:So,RadialLinearScale:Ro,TimeScale:Ho,TimeSeriesScale:class extends Ho{static id="timeseries";static defaults=Ho.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=jo(e,this.min),this._tableRange=jo(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(jo(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return jo(this._table,i*this._tableRange+this._minPos,!0)}}});const Yo=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Uo=Yo.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Xo(t){return Yo[t%Yo.length]}function qo(t){return Uo[t%Uo.length]}function Ko(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n instanceof Yn?e=function(t,e){return t.backgroundColor=t.data.map((()=>qo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Xo(e),t.backgroundColor=qo(e),++e}(i,e))}}function Go(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Jo={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Go(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Go(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=Ko(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Qo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var ta={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Qo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Z(it(e,o.axis,a).lo,0,i-1)),s=h?Z(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const x=[],b=e+i-1,_=t[e].x,y=t[b].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&x.push({...t[e],x:p}),s!==u&&s!==i&&x.push({...t[s],x:p})}o>0&&i!==u&&x.push(t[i]),x.push(a),h=e,m=0,f=g=l,c=d=u=o}}return x}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Qo(t)}};function ea(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ia(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function sa(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function na(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ia(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new oo({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function oa(t){return t&&!1!==t.fill}function aa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function ra(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function la(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&ua(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;oa(i)&&ua(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;oa(s)&&"beforeDatasetDraw"===i.drawTime&&ua(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const _a=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class ya extends $s{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=_a(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=va(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=_a(o,d),x=this.isHorizontal(),b=this._computeTitleHeight();f=x?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+b,line:0}:{x:this.left+c,y:ft(n,this.top+b+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),x?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+b+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,x?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),x)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=va(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class wa extends $s{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var ka={id:"title",_element:wa,start(t,e,i){!function(t,e){const i=new wa({ctx:t.ctx,options:e,chart:t});ls.configure(t,i,e),ls.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;ls.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa=new WeakMap;var Pa={id:"subtitle",start(t,e,i){const s=new wa({ctx:t.ctx,options:i,chart:t});ls.configure(t,s,i),ls.addBox(t,s),Sa.set(t,s)},stop(t){ls.removeBox(t,Sa.get(t)),Sa.delete(t)},beforeUpdate(t,e,i){const s=Sa.get(t);ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Da={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Aa(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Ta(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,x=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){x=Math.max(x,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),x+=p.width,{width:x,height:m}}function La(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ea(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||La(t,e,i,s),yAlign:s}}function Ra(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Z(g,0,s.width-e.width),y:Z(p,0,s.height-e.height)}}function Ia(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function za(t){return Ca([],Oa(t))}function Fa(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const Va={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Fa(i,t);Ca(e.before,Oa(Ba(n,"beforeLabel",this,t))),Ca(e.lines,Ba(n,"label",this,t)),Ca(e.after,Oa(Ba(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return za(Ba(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Ba(i,"beforeFooter",this,t),n=Ba(i,"footer",this,t),o=Ba(i,"afterFooter",this,t);let a=[];return a=Ca(a,Oa(s)),a=Ca(a,Oa(n)),a=Ca(a,Oa(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Fa(t.callbacks,e);s.push(Ba(i,"labelColor",this,e)),n.push(Ba(i,"labelPointStyle",this,e)),o.push(Ba(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Da[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Ta(this,i),a=Object.assign({},t,e),r=Ea(this.chart,i,a),l=Ra(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,x,b,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,b=_+o,y=_-o):(p=d+f,m=p+o,b=_-o,y=_+o),x=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(b=u,_=b-o,p=m-o,x=m+o):(b=u+g,_=b+o,p=m+o,x=m-o),y=b),{x1:p,x2:m,x3:x,y1:b,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ia(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let x,b,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ia(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Da[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Ta(this,t),a=Object.assign({},i,this._size),r=Ea(e,t,a),l=Ra(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Da[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Na={id:"tooltip",_element:Wa,positioners:Da,afterInit(t,e,i){i&&(t.tooltip=new Wa({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Va},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return Tn.register(Un,$o,go,t),Tn.helpers={...Hi},Tn._adapters=In,Tn.Animation=As,Tn.Animations=Ts,Tn.animator=bt,Tn.controllers=nn.controllers.items,Tn.DatasetController=js,Tn.Element=$s,Tn.elements=go,Tn.Interaction=Ki,Tn.layouts=ls,Tn.platforms=Ds,Tn.Scale=tn,Tn.Ticks=ae,Object.assign(Tn,Un,$o,go,t,Ds),Tn.Chart=Tn,"undefined"!=typeof window&&(window.Chart=Tn),Tn})); diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFontResolverOwnershipTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFontResolverOwnershipTest.java new file mode 100644 index 00000000..1fd89100 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFontResolverOwnershipTest.java @@ -0,0 +1,48 @@ +package com.spinyowl.spinygui.benchmark; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.InputStream; +import java.lang.classfile.ClassFile; +import java.lang.classfile.Opcode; +import java.lang.classfile.instruction.FieldInstruction; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BenchmarkFontResolverOwnershipTest { + private static final String RESOLVER_OWNER = + "com/spinyowl/spinygui/core/system/font/FontChainResolver"; + + @Test + void benchmarkCompositionBytecodeDoesNotReadTheCompatibilityDefaultField() throws Exception { + for (String className : + List.of( + "com.spinyowl.spinygui.benchmark.TextStyleSpecification", + "com.spinyowl.spinygui.benchmark.cpu.CpuWorkloadSpecifications", + "com.spinyowl.spinygui.benchmark.diagnostic.CounterDiagnosticsMain", + "com.spinyowl.spinygui.benchmark.frame.FrameBaselineRecorder", + "com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications")) { + assertNoDefaultFieldReference(className); + } + } + + private static void assertNoDefaultFieldReference(String className) throws Exception { + String resource = className.replace('.', '/') + ".class"; + try (InputStream stream = + Thread.currentThread().getContextClassLoader().getResourceAsStream(resource)) { + byte[] bytecode = java.util.Objects.requireNonNull(stream, resource).readAllBytes(); + boolean readsDefault = + ClassFile.of().parse(bytecode).methods().stream() + .flatMap(method -> method.code().stream()) + .flatMap(code -> code.elementList().stream()) + .filter(FieldInstruction.class::isInstance) + .map(FieldInstruction.class::cast) + .anyMatch( + field -> + field.opcode() == Opcode.GETSTATIC + && field.owner().asInternalName().equals(RESOLVER_OWNER) + && field.name().equalsString("DEFAULT")); + assertFalse(readsDefault, className + " must not read FontChainResolver.DEFAULT"); + } + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFontTestOwner.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFontTestOwner.java new file mode 100644 index 00000000..40a1d8f7 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFontTestOwner.java @@ -0,0 +1,15 @@ +package com.spinyowl.spinygui.benchmark; + +import com.spinyowl.spinygui.core.system.font.impl.FontServiceImpl; +import com.spinyowl.spinygui.core.system.font.impl.FontStorageImpl; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** Test composition helper for benchmark identity fixtures that resolve production font metadata. */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class BenchmarkFontTestOwner { + /** Explicitly installs the production owner and built-ins on the current test thread. */ + public static void install() { + new FontServiceImpl(new FontStorageImpl(), false).installSemanticOwner(); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFreshProcessInitializationTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFreshProcessInitializationTest.java new file mode 100644 index 00000000..834986b0 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFreshProcessInitializationTest.java @@ -0,0 +1,48 @@ +package com.spinyowl.spinygui.benchmark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class BenchmarkFreshProcessInitializationTest { + @ParameterizedTest + @ValueSource(strings = {"rendering-startup", "report-static", "cpu-enrichment"}) + void benchmarkOuterProcessPathsDoNotRequireAnInstalledFontOwner(String mode) throws Exception { + Process process = + new ProcessBuilder( + javaExecutable(), + "-cp", + System.getProperty("java.class.path"), + BenchmarkFreshProcessProbe.class.getName(), + mode) + .redirectErrorStream(true) + .start(); + + boolean completed = process.waitFor(30, TimeUnit.SECONDS); + if (!completed) { + process.destroyForcibly(); + } + assertTrue(completed, "fresh JVM timed out for " + mode); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + assertTrue(output.contains("BENCHMARK_FRESH_PROCESS_OK " + mode), output); + } + + private static String javaExecutable() throws IOException { + Path java = + Path.of( + System.getProperty("java.home"), + "bin", + System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"); + if (!java.toFile().isFile()) { + throw new IOException("Java executable does not exist: " + java); + } + return java.toString(); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFreshProcessProbe.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFreshProcessProbe.java new file mode 100644 index 00000000..db277ed1 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkFreshProcessProbe.java @@ -0,0 +1,104 @@ +package com.spinyowl.spinygui.benchmark; + +import com.spinyowl.spinygui.benchmark.cpu.CpuBenchmarkReport; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkRunMetadata; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata; +import com.spinyowl.spinygui.benchmark.rendering.RenderingBenchmarkMain; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications; +import com.spinyowl.spinygui.benchmark.report.BenchmarkHtmlReportGenerator; +import com.spinyowl.spinygui.core.font.Font; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Fresh-JVM probe used by {@link BenchmarkFreshProcessInitializationTest}. */ +public final class BenchmarkFreshProcessProbe { + private BenchmarkFreshProcessProbe() { + } + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + throw new IllegalArgumentException("Expected one probe mode"); + } + requireNoOwner("process start"); + switch (args[0]) { + case "rendering-startup" -> renderingStartup(); + case "report-static" -> reportStaticAccess(); + case "cpu-enrichment" -> cpuEnrichment(); + default -> throw new IllegalArgumentException("Unknown probe mode: " + args[0]); + } + requireNoOwner("probe completion"); + System.out.println("BENCHMARK_FRESH_PROCESS_OK " + args[0]); + } + + private static void renderingStartup() throws Exception { + var specification = RenderingWorkloadSpecifications.CURRENT; + var scene = specification.measurementOrder().getFirst(); + specification.identity(scene); + specification.inputManifests(scene); + Class.forName( + RenderingBenchmarkMain.class.getName(), + true, + RenderingBenchmarkMain.class.getClassLoader()); + expectArgumentFailure( + () -> RenderingBenchmarkMain.main(new String[0]), "Expected rendering report path"); + } + + private static void reportStaticAccess() throws Exception { + Class.forName( + BenchmarkHtmlReportGenerator.class.getName(), + true, + BenchmarkHtmlReportGenerator.class.getClassLoader()); + expectArgumentFailure( + () -> BenchmarkHtmlReportGenerator.main(new String[0]), "Expected benchmark archive"); + } + + private static void cpuEnrichment() throws Exception { + Path report = Files.createTempFile("spinygui-cpu-fresh-process-", ".json"); + try { + Files.writeString( + report, + """ + [{ + "benchmark": "com.spinyowl.spinygui.benchmark.cpu.TextCalculationBenchmark.layoutTextDenseInlineContent", + "jmhVersion": "1.37" + }] + """); + CpuBenchmarkReport.enrich( + report, + BenchmarkRunMetadata.paired( + "20260816-120000-000000000", + BenchmarkRunMetadata.Artifact.CPU, + ComparabilityMetadata.EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED)); + String enriched = Files.readString(report); + if (!enriched.contains("\"comparability\"") + || !enriched.contains("\"benchmarkRun\"")) { + throw new IllegalStateException("CPU report was not enriched"); + } + } finally { + Files.deleteIfExists(report); + } + } + + private static void expectArgumentFailure(ThrowingRunnable operation, String message) + throws Exception { + try { + operation.run(); + throw new IllegalStateException("Expected argument validation failure"); + } catch (IllegalArgumentException expected) { + if (!expected.getMessage().contains(message)) { + throw new IllegalStateException("Unexpected argument validation message", expected); + } + } + } + + private static void requireNoOwner(String stage) { + if (Font.hasSemanticOwner()) { + throw new IllegalStateException("Semantic font owner was installed during " + stage); + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkTaskLifecycleContractTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkTaskLifecycleContractTest.java new file mode 100644 index 00000000..5465d00d --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/BenchmarkTaskLifecycleContractTest.java @@ -0,0 +1,44 @@ +package com.spinyowl.spinygui.benchmark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** Build-script contract checks that do not execute JMH, GLFW, or report generation. */ +class BenchmarkTaskLifecycleContractTest { + @Test + void reportLifecycleSharesOneReservationRunsSequentiallyAndIsAlwaysFresh() throws Exception { + String script = Files.readString(repositoryRoot().resolve("spinygui.benchmark/build.gradle.kts")); + + assertTrue(script.contains("registerIfAbsent(\"benchmarkRunId\", BenchmarkRunIdService::class)")); + assertTrue(script.contains("benchmarkReportCpu") && script.contains("benchmarkReportRendering")); + assertTrue(script.contains("dependsOn(benchmarkReportCpu)")); + assertTrue(script.contains("mustRunAfter(benchmarkReportCpu)")); + assertTrue(script.contains("dependsOn(benchmarkReportRendering)")); + assertTrue(script.contains("inputImpactEvidence")); + assertTrue(count(script, "benchmarkRunId, \"paired-report\"") == 4); + assertTrue(count(script, "benchmarkRunId, \"unpaired-investigation\"") == 3); + assertTrue(script.contains("\"text-calculation\", \"-rff\", benchmarkRunId, \"paired-report\"")); + assertTrue(script.contains("\"nanovg-text\", null, benchmarkRunId, \"paired-report\"")); + assertTrue(script.contains("\"text-diagnostics\", null, benchmarkRunId, \"unpaired-investigation\"")); + assertEquals(8, count(script, " freshBenchmarkRun()")); + assertTrue(script.contains("doNotTrackState(")); + assertTrue(script.contains("ArchiveReportArgumentAction(benchmarkArchive.asFile, benchmarkRunId)")); + } + + private static int count(String value, String token) { + return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } + + private static Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null) { + if (Files.exists(current.resolve("settings.gradle.kts"))) return current; + current = current.getParent(); + } + throw new IllegalStateException("Unable to locate repository root"); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/RendererHostLifecycleTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/RendererHostLifecycleTest.java new file mode 100644 index 00000000..44e2e12a --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/RendererHostLifecycleTest.java @@ -0,0 +1,97 @@ +package com.spinyowl.spinygui.benchmark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RendererHostLifecycleTest { + + @DisplayName("P4 T3: init failure retains renderer and retries delete before host and core close") + @Test + void initializationFailureRetainsRendererForDeleteRetryBeforeHostAndCoreClose() { + List events = new ArrayList<>(); + Object renderer = new Object(); + AtomicInteger destroys = new AtomicInteger(); + AtomicReference destroyedRenderer = new AtomicReference<>(); + RendererHostLifecycle lifecycle = + new RendererHostLifecycle<>( + () -> { + events.add("CREATE_RENDERER"); + return renderer; + }, + retained -> { + assertSame(renderer, retained); + events.add("INITIALIZE_RENDERER"); + throw new IllegalStateException("injected initialize failure"); + }, + retained -> { + destroyedRenderer.set(retained); + int attempt = destroys.incrementAndGet(); + events.add("DESTROY_RENDERER_" + attempt); + if (attempt == 1) { + throw new IllegalStateException("injected first delete failure"); + } + }, + () -> events.add("CLOSE_GL_HOST")); + + assertThrows(IllegalStateException.class, lifecycle::initialize); + lifecycle.close(); + events.add("CLOSE_FONT_SERVICE"); + lifecycle.close(); + + assertSame(renderer, destroyedRenderer.get()); + assertEquals(2, destroys.get()); + assertEquals( + List.of( + "CREATE_RENDERER", + "INITIALIZE_RENDERER", + "DESTROY_RENDERER_1", + "DESTROY_RENDERER_2", + "CLOSE_GL_HOST", + "CLOSE_FONT_SERVICE"), + events); + } + + @DisplayName("P4 T3: two delete failures preserve the first failure and leave the host open") + @Test + void doubleDeleteFailurePreservesOriginalFailureAndLeavesHostOpen() { + Object renderer = new Object(); + IllegalStateException firstDeleteFailure = + new IllegalStateException("injected first delete failure"); + AssertionError retryDeleteFailure = new AssertionError("injected retry delete failure"); + AtomicInteger destroys = new AtomicInteger(); + AtomicInteger hostCloses = new AtomicInteger(); + RendererHostLifecycle lifecycle = + new RendererHostLifecycle<>( + () -> renderer, + retained -> { + assertSame(renderer, retained); + throw new IllegalStateException("injected initialize failure"); + }, + retained -> { + assertSame(renderer, retained); + if (destroys.incrementAndGet() == 1) { + throw firstDeleteFailure; + } + throw retryDeleteFailure; + }, + hostCloses::incrementAndGet); + + assertThrows(IllegalStateException.class, lifecycle::initialize); + IllegalStateException failure = + assertThrows(IllegalStateException.class, lifecycle::close); + + assertSame(firstDeleteFailure, failure); + assertEquals(1, failure.getSuppressed().length); + assertSame(retryDeleteFailure, failure.getSuppressed()[0]); + assertEquals(2, destroys.get()); + assertEquals(0, hostCloses.get()); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/TextWorkloadsTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/TextWorkloadsTest.java new file mode 100644 index 00000000..b6ba86d8 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/TextWorkloadsTest.java @@ -0,0 +1,18 @@ +package com.spinyowl.spinygui.benchmark; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TextWorkloadsTest { + + @Test + void providesDeterministicTextCoverage() { + assertTrue(TextWorkloads.LATIN.contains("quick brown fox")); + assertTrue(TextWorkloads.WRAPPED_PARAGRAPH.length() > TextWorkloads.LATIN.length()); + assertTrue(TextWorkloads.MIXED_CJK.codePoints().anyMatch(codePoint -> codePoint == 0x4E2D)); + assertTrue(TextWorkloads.SUPPLEMENTARY_UNICODE.codePoints().anyMatch(codePoint -> codePoint > 0xFFFF)); + assertTrue(TextWorkloads.MISSING_GLYPHS.codePoints().anyMatch(codePoint -> codePoint == 0x10FFFF)); + assertTrue(TextWorkloads.LONG_SINGLE_FONT.length() >= 5_000); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/cpu/CpuBenchmarkReportTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/cpu/CpuBenchmarkReportTest.java new file mode 100644 index 00000000..5c2f3fcf --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/cpu/CpuBenchmarkReportTest.java @@ -0,0 +1,128 @@ +package com.spinyowl.spinygui.benchmark.cpu; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkInputManifests.InputSet; +import com.spinyowl.spinygui.benchmark.BenchmarkFontTestOwner; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkRunMetadata; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity.Dimension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; + +class CpuBenchmarkReportTest { + @BeforeEach + void installFontOwner() { + BenchmarkFontTestOwner.install(); + } + @Test + void enrichesEveryActualCurrentJmhOperationWithRequiredMetadata() { + JsonArray report = new JsonArray(); + for (String operation : CpuWorkloadSpecifications.currentOperations().keySet()) { + JsonObject result = new JsonObject(); + result.addProperty( + "benchmark", "com.spinyowl.spinygui.benchmark.cpu.TextCalculationBenchmark." + operation); + result.addProperty("jmhVersion", "1.37"); + report.add(result); + } + ComparabilityMetadata.Environment environment = + new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.CPU, "Vendor", "25", "OS", "1", "x64", "CPU model", + null, null, null, null); + ComparabilityMetadata.Implementation implementation = + new ComparabilityMetadata.Implementation("impl-1", "build-1", "commit-1"); + + BenchmarkRunMetadata runMetadata = + BenchmarkRunMetadata.paired( + "20260726-120000-000000000", + BenchmarkRunMetadata.Artifact.CPU, + ComparabilityMetadata.EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED); + CpuBenchmarkReport.enrich(report, environment, implementation, runMetadata); + + assertEquals(CpuWorkloadSpecifications.currentOperations().size(), report.size()); + for (JsonElement element : report) { + JsonObject result = element.getAsJsonObject(); + String operation = result.get("benchmark").getAsString().replaceFirst("^.*\\.", ""); + WorkloadIdentity identity = + CpuWorkloadSpecifications.identity( + CpuWorkloadSpecifications.currentOperations().get(operation)); + InputSet manifests = + CpuWorkloadSpecifications.inputManifests( + CpuWorkloadSpecifications.currentOperations().get(operation)); + JsonObject metadataJson = result.getAsJsonObject("comparability"); + ComparabilityMetadata metadata = + ComparabilityMetadata.fromJson(metadataJson); + assertEquals(identity.semanticId(), metadata.semanticId()); + assertEquals("jmh-1.37", result.getAsJsonObject("comparability").get("benchmarkVersion").getAsString()); + assertEquals(CpuWorkloadSpecifications.currentExecutionSettings(), metadata.benchmarkSettings()); + assertEquals(implementation, metadata.implementation()); + assertEquals( + ComparabilityMetadata.EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, + metadata.evidenceMode()); + assertEquals(runMetadata, BenchmarkRunMetadata.fromJson(result.getAsJsonObject("benchmarkRun"))); + assertFalse(metadata.fingerprints().required().isBlank()); + assertEquals(manifests.content().sha256(), metadataJson.get("workloadContentSha256").getAsString()); + assertEquals(manifests.shape().sha256(), metadataJson.get("workloadShapeSha256").getAsString()); + assertEquals(manifests.fonts().sha256(), metadataJson.get("fontInputsSha256").getAsString()); + metadata.benchmarkSettings().forEach( + (key, value) -> assertEquals(value, identity.dimensions().get(dimension(key)), key)); + } + } + + @Test + void producerManifestsAreGoldenVersionedAndComponentScoped() { + InputSet manifests = + CpuWorkloadSpecifications.inputManifests(CpuWorkloadSpecifications.MEASURE_LATIN); + + assertEquals( + """ + spinygui-benchmark-input:workload-content:v1 + field=4:text=44:The quick brown fox jumps over the lazy dog. + """, + manifests.content().canonicalSerialization()); + assertEquals( + """ + spinygui-benchmark-input:workload-shape:v1 + field=11:line-height=3:1.2 + field=23:measurement-offset-x-px=1:0 + field=10:shape-kind=11:measurement + field=17:wrap-width-policy=9:unbounded + field=15:wrapping-policy=9:unwrapped + """, + manifests.shape().canonicalSerialization()); + assertEquals( + """ + spinygui-benchmark-input:font-inputs:v1 + field=26:configuration-font-size-px=2:16 + field=20:font-0000-descriptor=53:Roboto|normal|normal|regular|fonts/Roboto-Regular.ttf + field=23:font-0000-resource-path=24:fonts/Roboto-Regular.ttf + field=25:font-0000-resource-sha256=71:sha256:b2efabca5ea4bc56eea829713706b5cd0788b82aca153bd4adde9b1573933b4f + field=14:font-0000-role=15:cpu-measurement + """, + manifests.fonts().canonicalSerialization()); + assertEquals( + "sha256:d4622aaae4f4e8a29327bda409d25590c9617afc8a28fb62eb7e8c256ecaa834", + manifests.content().sha256()); + assertEquals( + "sha256:73fd85c5ea00d6219db21a6e472dba8004d020ae33a2b766002c129c0e9ef1ca", + manifests.shape().sha256()); + assertEquals( + "sha256:ad5d9b592114f8fc887393812dc5088dfb2df89497d5381e98a9a250f1230419", + manifests.fonts().sha256()); + assertFalse(manifests.shape().canonicalSerialization().contains("benchmark-mode")); + assertFalse(manifests.shape().canonicalSerialization().contains("workload-content")); + assertFalse(manifests.shape().canonicalSerialization().contains("font-chain")); + } + + private static Dimension dimension(String key) { + return java.util.Arrays.stream(Dimension.values()) + .filter(dimension -> dimension.key().equals(key)) + .findFirst() + .orElseThrow(); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/diagnostic/DiagnosticVocabularyTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/diagnostic/DiagnosticVocabularyTest.java new file mode 100644 index 00000000..62c2e7f6 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/diagnostic/DiagnosticVocabularyTest.java @@ -0,0 +1,133 @@ +package com.spinyowl.spinygui.benchmark.diagnostic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.diagnostic.NvgDiagnosticCounter; +import com.spinyowl.spinygui.core.diagnostic.DiagnosticCounter; +import com.spinyowl.spinygui.core.diagnostic.DiagnosticSession; +import com.spinyowl.spinygui.core.diagnostic.DiagnosticSnapshot; +import com.spinyowl.spinygui.core.diagnostic.TextDiagnosticCounter; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +class DiagnosticVocabularyTest { + @Test + void coreOnlySnapshotsRejectNanoVgReadsWhileDisabledSnapshotRemainsUniversalZero() { + DiagnosticSnapshot coreOnly = + DiagnosticSession.enabled(List.of(TextDiagnosticCounter.values())).snapshot(); + DiagnosticSnapshot disabled = DiagnosticSession.disabled().snapshot(); + + assertThrows( + IllegalArgumentException.class, + () -> coreOnly.value(NvgDiagnosticCounter.TEXT_CALLS)); + assertThrows( + IllegalArgumentException.class, + () -> coreOnly.saturated(NvgDiagnosticCounter.TEXT_CALLS)); + assertEquals(0, disabled.value(NvgDiagnosticCounter.TEXT_CALLS)); + assertFalse(disabled.saturated(NvgDiagnosticCounter.TEXT_CALLS)); + } + + @Test + void completeVocabularyHasStableUniqueIdsUnitsAndDescriptions() { + Set expected = + Set.of( + "core.control.input-complete-layouts", + "core.control.textarea-complete-layouts", + "core.text.character-builder-appends", + "core.text.character-builder-freezes", + "core.text.caret-boundary-builder-appends", + "core.text.caret-boundary-builder-freezes", + "core.text.caret-stop-search-comparisons", + "core.text.complete-measurements", + "core.text.font-chain-resolutions", + "core.text.advance-slot-builder-appends", + "core.text.advance-slot-builder-freezes", + "core.text.glyph-slot-builder-appends", + "core.text.glyph-slot-builder-freezes", + "core.text.glyph-slots-copied", + "core.text.glyph-slots-moved", + "core.text.initial-resolution.glyph-slots-copied", + "core.text.logical-glyph-resolutions", + "core.text.line-builder-appends", + "core.text.line-builder-freezes", + "core.text.native-glyph-advance-calls", + "core.text.native-glyph-index-probes", + "core.text.native-kerning-calls", + "core.text.normalization-scans", + "core.text.range-materialization.glyph-slots-copied", + "core.text.range-preparations", + "core.text.range-temporary-strings", + "core.text.result-builder-freezes", + "core.text.run-builder-appends", + "core.text.run-builder-freezes", + "core.text.source-code-points-scanned", + "core.text.wrap.primitive-visits", + "core.text-measurer.get-text-caret-metrics-font-list.entries", + "core.text-measurer.get-text-caret-metrics-font.entries", + "core.text-measurer.get-text-line-metrics-font-list.entries", + "core.text-measurer.get-text-line-metrics-font.entries", + "core.text-measurer.get-text-metrics-font.entries", + "core.text-measurer.measure-text-font-full.entries", + "core.text-measurer.measure-text-font-list-full.entries", + "core.text-measurer.measure-text-font-list.entries", + "core.text-measurer.measure-text-font.entries", + "nanovg.calls.fill-color", + "nanovg.calls.font-face", + "nanovg.calls.font-size", + "nanovg.calls.intersect-scissor", + "nanovg.calls.reset-scissor", + "nanovg.calls.restore", + "nanovg.calls.save", + "nanovg.calls.scissor", + "nanovg.calls.text", + "nanovg.calls.text-align", + "nanovg.calls.transform", + "nanovg.calls.translate", + "nanovg.input-text.cull-reason.outside-effective-clip", + "nanovg.input-text.items-considered", + "nanovg.input-text.items-culled", + "nanovg.input-text.items-face-selection-failed", + "nanovg.input-text.items-submitted", + "nanovg.normal-text.cull-reason.outside-effective-clip", + "nanovg.normal-text.items-considered", + "nanovg.normal-text.items-culled", + "nanovg.normal-text.items-face-selection-failed", + "nanovg.normal-text.items-submitted", + "nanovg.results.font-face-failures", + "nanovg.textarea.line-cull-reason.outside-effective-clip", + "nanovg.textarea.lines-considered", + "nanovg.textarea.lines-culled", + "nanovg.textarea.lines-submitted", + "nanovg.textarea.text-cull-reason.outside-effective-clip", + "nanovg.textarea.text-items-considered", + "nanovg.textarea.text-items-culled", + "nanovg.textarea.text-items-face-selection-failed", + "nanovg.textarea.text-items-submitted", + "nanovg.utf8.allocated-bytes", + "nanovg.utf8.allocation-calls", + "nanovg.utf8.payload-bytes"); + Set vocabulary = + Stream.concat( + Arrays.stream(TextDiagnosticCounter.values()), + Arrays.stream(NvgDiagnosticCounter.values())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Set actual = vocabulary.stream().map(DiagnosticCounter::id).collect(Collectors.toSet()); + + assertEquals(expected, actual); + assertEquals(expected.size(), vocabulary.size()); + assertEquals("core-text-diagnostics-7", TextDiagnosticCounter.VOCABULARY_VERSION); + assertEquals("nanovg-text-diagnostics-2", NvgDiagnosticCounter.VOCABULARY_VERSION); + vocabulary.forEach( + counter -> { + assertFalse(counter.description().isBlank(), counter.id()); + assertFalse(counter.unit().name().isBlank(), counter.id()); + }); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/diagnostic/DiagnosticWorkloadSpecificationsTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/diagnostic/DiagnosticWorkloadSpecificationsTest.java new file mode 100644 index 00000000..850648a0 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/diagnostic/DiagnosticWorkloadSpecificationsTest.java @@ -0,0 +1,763 @@ +package com.spinyowl.spinygui.benchmark.diagnostic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonPrimitive; +import com.spinyowl.spinygui.benchmark.diagnostic.CounterDiagnosticArtifact.Entry; +import com.spinyowl.spinygui.benchmark.BenchmarkFontTestOwner; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkRunMetadata; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity.Category; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity.Dimension; +import com.spinyowl.spinygui.core.font.Font; +import com.spinyowl.spinygui.core.diagnostic.DiagnosticSession; +import com.spinyowl.spinygui.core.diagnostic.TextDiagnosticCounter; +import com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.diagnostic.NvgDiagnosticCounter; +import com.spinyowl.spinygui.core.node.InputElement; +import com.spinyowl.spinygui.core.node.Text; +import com.spinyowl.spinygui.core.node.TextareaElement; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; + +class DiagnosticWorkloadSpecificationsTest { + private static final ComparabilityMetadata.Environment CPU_ENVIRONMENT = + new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.CPU, + "Vendor", "25", "OS", "1", "x64", "CPU", null, null, null, null); + private static final ComparabilityMetadata.Environment RENDERING_ENVIRONMENT = + new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.RENDERING, + "Vendor", "25", "OS", "1", "x64", "CPU", + "GL vendor", "renderer", "driver", "4.6"); + private static final ComparabilityMetadata.Implementation IMPLEMENTATION = + new ComparabilityMetadata.Implementation("impl", "build", "commit"); + + @BeforeEach + void installFontOwner() { + BenchmarkFontTestOwner.install(); + } + + @Test + void declaredInputsAreSourceBoundAndRendererFontsCoverEveryReachableFace() { + for (var scenario : DiagnosticWorkloadSpecifications.CPU_SCENARIOS) { + var dimensions = scenario.identity().dimensions(); + assertEquals(scenario.text().codePointCount(0, scenario.text().length()), + Integer.parseInt(dimensions.get(Dimension.SOURCE_CODE_POINT_COUNT))); + assertEquals(decimal(scenario.wrapWidthPx()), + dimensions.get(Dimension.WRAP_WIDTH_PX)); + assertEquals(scenario.wordWrap() ? "word-wrap" : "character-wrap", + dimensions.get(Dimension.WRAPPING_POLICY)); + assertTrue(scenario.inputManifests().content().canonicalSerialization().contains(scenario.text())); + assertFalse(scenario.inputManifests().shape().canonicalSerialization().contains(scenario.text())); + assertTrue( + scenario + .inputManifests() + .fonts() + .canonicalSerialization() + .contains( + "configuration-font-weight=" + + scenario.fonts().getFirst().weight().name().length() + + ":" + + scenario.fonts().getFirst().weight().name())); + } + + List completeLayoutFonts = + List.of( + Font.ROBOTO_REGULAR, + Font.ROBOTO_LIGHT, + Font.ROBOTO_BOLD, + Font.NOTO_SANS_CJK_SC_REGULAR); + for (var scenario : DiagnosticWorkloadSpecifications.RENDERER_SCENARIOS) { + var dimensions = scenario.identity().dimensions(); + assertEquals(completeLayoutFonts, scenario.layoutFonts()); + assertEquals(Integer.toString(scenario.itemCount()), dimensions.get(Dimension.TEXT_NODE_COUNT)); + assertEquals(decimal(scenario.container().xPx()), + dimensions.get(Dimension.CONTAINER_POSITION_X_PX)); + assertEquals(decimal(scenario.container().yPx()), + dimensions.get(Dimension.CONTAINER_POSITION_Y_PX)); + assertEquals(decimal(scenario.container().widthPx()), + dimensions.get(Dimension.CONTAINER_WIDTH_PX)); + assertEquals(decimal(scenario.container().heightPx()), + dimensions.get(Dimension.CONTAINER_HEIGHT_PX)); + assertEquals(scenario.visibility(), dimensions.get(Dimension.VISIBILITY)); + assertEquals(scenario.submissionState(), dimensions.get(Dimension.SUBMISSION_STATE)); + String contentManifest = scenario.inputManifests().content().canonicalSerialization(); + for (int index = 0; index < scenario.itemCount(); index++) { + assertTrue(contentManifest.contains(scenario.sourceContent(index)), scenario.name()); + } + String fontManifest = scenario.inputManifests().fonts().canonicalSerialization(); + for (Font font : completeLayoutFonts) { + assertTrue(fontManifest.contains(font.path()), font.path()); + } + assertTrue(fontManifest.contains("resource-sha256")); + assertFalse(scenario.inputManifests().shape().canonicalSerialization() + .contains(scenario.sourceContent(0))); + } + } + + @Test + void manifestsContainOnlyCategoryConsumedControlStateAndKeepCpuTypographyIsolated() { + var normal = scenario(Category.NORMAL_TEXT, "normal-visible-changed"); + var normalWithUnusedControlState = + copyRenderer( + normal, + normal.controlWidthPx() + 1, + normal.controlHeightPx() + 1, + 3, + 9, + 9, + 7, + 11, + normal.container()); + assertEquals(normal.identity(), normalWithUnusedControlState.identity()); + assertEquals(normal.inputManifests(), normalWithUnusedControlState.inputManifests()); + assertEquals( + DiagnosticWorkloadSpecifications.comparability( + normal, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints(), + DiagnosticWorkloadSpecifications.comparability( + normalWithUnusedControlState, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints()); + String normalShape = normal.inputManifests().shape().canonicalSerialization(); + for (String field : + Set.of( + "caret-index-utf16", + "control-height-px", + "control-width-px", + "scroll-x-px", + "scroll-y-px", + "selection-end-utf16", + "selection-start-utf16", + "wrap-width-px", + "deferred-suffix-code-point-count", + "line-start-kerning-transition-count")) { + assertFalse(normalShape.contains(field), field); + } + + var input = scenario(Category.INPUT, "input-visible-changed"); + var changedInput = + copyRenderer( + input, + input.controlWidthPx() + 1, + input.controlHeightPx() + 1, + 3, + 13, + 13, + input.scrollXPx() + 1, + input.scrollYPx() + 1, + input.container()); + assertNotEquals(input.identity(), changedInput.identity()); + assertNotEquals(input.inputManifests().shape(), changedInput.inputManifests().shape()); + assertNotEquals( + DiagnosticWorkloadSpecifications.comparability(input, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints(), + DiagnosticWorkloadSpecifications.comparability( + changedInput, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints()); + String inputShape = input.inputManifests().shape().canonicalSerialization(); + assertTrue(inputShape.contains("caret-index-utf16")); + assertTrue(inputShape.contains("control-width-px")); + assertTrue(inputShape.contains("scroll-x-px")); + assertFalse(inputShape.contains("scroll-y-px")); + assertFalse(inputShape.contains("wrap-width-px")); + var inputWithUnusedVerticalScroll = + copyRenderer( + input, + input.controlWidthPx(), + input.controlHeightPx(), + input.selectionStartUtf16(), + input.selectionEndUtf16(), + input.caretIndexUtf16(), + input.scrollXPx(), + input.scrollYPx() + 1, + input.container()); + assertEquals(input.inputManifests(), inputWithUnusedVerticalScroll.inputManifests()); + assertEquals( + DiagnosticWorkloadSpecifications.comparability(input, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints(), + DiagnosticWorkloadSpecifications.comparability( + inputWithUnusedVerticalScroll, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints()); + + var textarea = scenario(Category.TEXTAREA, "textarea-visible-changed"); + var textareaContainer = + new DiagnosticWorkloadSpecifications.Rect( + textarea.container().xPx(), + textarea.container().yPx(), + textarea.container().widthPx() + 1, + textarea.container().heightPx()); + var changedTextarea = + copyRenderer( + textarea, + textarea.controlWidthPx() + 1, + textarea.controlHeightPx() + 1, + 3, + 15, + 15, + textarea.scrollXPx() + 1, + textarea.scrollYPx() + 1, + textareaContainer); + assertNotEquals(textarea.identity(), changedTextarea.identity()); + assertNotEquals(textarea.inputManifests().shape(), changedTextarea.inputManifests().shape()); + assertNotEquals( + DiagnosticWorkloadSpecifications.comparability( + textarea, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints(), + DiagnosticWorkloadSpecifications.comparability( + changedTextarea, RENDERING_ENVIRONMENT, IMPLEMENTATION) + .fingerprints()); + String textareaShape = textarea.inputManifests().shape().canonicalSerialization(); + for (String field : + Set.of( + "caret-index-utf16", + "control-width-px", + "scroll-x-px", + "scroll-y-px", + "selection-start-utf16", + "wrap-width-px", + "deferred-suffix-code-point-count", + "line-start-kerning-transition-count")) { + assertTrue(textareaShape.contains(field), field); + } + + var cpu = DiagnosticWorkloadSpecifications.CPU_SCENARIOS.getFirst(); + var changedCpuFont = + new DiagnosticWorkloadSpecifications.CpuScenario( + cpu.name(), + cpu.workloadContent(), + cpu.text(), + List.of(Font.ROBOTO_BOLD, Font.NOTO_SANS_CJK_SC_REGULAR), + cpu.fontSizePx(), + cpu.lineHeight(), + cpu.measurementOffsetXPx(), + cpu.wrapWidthPx(), + cpu.wordWrap(), + cpu.expectedShape()); + assertNotEquals(cpu.inputManifests().fonts(), changedCpuFont.inputManifests().fonts()); + assertTrue( + cpu.inputManifests().fonts().canonicalSerialization().contains("configuration-font-weight=7:regular")); + assertTrue( + changedCpuFont + .inputManifests() + .fonts() + .canonicalSerialization() + .contains("configuration-font-weight=4:bold")); + assertEquals( + normal.inputManifests().fonts(), + normalWithUnusedControlState.inputManifests().fonts()); + } + + @Test + void corpusDriftFailsClosedAndEachDeclaredCpuShapeOutputIsExecutionChecked() { + var cpu = DiagnosticWorkloadSpecifications.CPU_SCENARIOS.getLast(); + assertThrows( + IllegalArgumentException.class, + () -> + new DiagnosticWorkloadSpecifications.CpuScenario( + cpu.name(), cpu.workloadContent(), cpu.text() + "x", cpu.fonts(), cpu.fontSizePx(), + cpu.lineHeight(), cpu.measurementOffsetXPx(), cpu.wrapWidthPx(), cpu.wordWrap(), + cpu.expectedShape())); + + var renderer = DiagnosticWorkloadSpecifications.RENDERER_SCENARIOS.getFirst(); + assertThrows( + IllegalArgumentException.class, + () -> + new DiagnosticWorkloadSpecifications.RendererScenario( + renderer.name(), renderer.category(), renderer.workloadContent(), List.of("drift"), + renderer.itemCount(), renderer.container(), renderer.controlWidthPx(), + renderer.controlHeightPx(), renderer.selectionStartUtf16(), renderer.selectionEndUtf16(), + renderer.caretIndexUtf16(), renderer.scrollXPx(), renderer.scrollYPx(), + renderer.submissionState(), renderer.expectedShape())); + + var shape = cpu.expectedShape(); + List outputDrifts = + List.of( + new DiagnosticWorkloadSpecifications.ExpectedShape( + shape.sourceCodePointCount(), shape.sourceLineCount(), shape.visualLineCount() + 1, + shape.paragraphCount(), shape.fallbackTransitionCount(), + shape.deferredSuffixCodePointCount(), shape.lineStartKerningTransitionCount()), + new DiagnosticWorkloadSpecifications.ExpectedShape( + shape.sourceCodePointCount(), shape.sourceLineCount(), shape.visualLineCount(), + shape.paragraphCount(), shape.fallbackTransitionCount() + 1, + shape.deferredSuffixCodePointCount(), shape.lineStartKerningTransitionCount()), + new DiagnosticWorkloadSpecifications.ExpectedShape( + shape.sourceCodePointCount(), shape.sourceLineCount(), shape.visualLineCount(), + shape.paragraphCount(), shape.fallbackTransitionCount(), + shape.deferredSuffixCodePointCount() + 1, shape.lineStartKerningTransitionCount()), + new DiagnosticWorkloadSpecifications.ExpectedShape( + shape.sourceCodePointCount(), shape.sourceLineCount(), shape.visualLineCount(), + shape.paragraphCount(), shape.fallbackTransitionCount(), + shape.deferredSuffixCodePointCount(), shape.lineStartKerningTransitionCount() + 1)); + for (var drift : outputDrifts) { + var changed = copyCpu(cpu, drift); + assertThrows( + IllegalStateException.class, + () -> CounterDiagnosticsMain.runCpuScenario(changed, CPU_ENVIRONMENT, IMPLEMENTATION)); + } + } + + @Test + void matrixIdentifiesEveryScaleControlVisibilitySelectionAndSubmissionVariant() { + var scenarios = new ArrayList(); + scenarios.addAll(DiagnosticWorkloadSpecifications.CPU_SCENARIOS); + scenarios.addAll(DiagnosticWorkloadSpecifications.RENDERER_SCENARIOS); + + Set semanticIds = new HashSet<>(); + Set requiredFingerprints = new HashSet<>(); + Set series = new HashSet<>(); + Set categories = new HashSet<>(); + Set visibility = new HashSet<>(); + Set submission = new HashSet<>(); + Set wrapWidths = new HashSet<>(); + Set offsets = new HashSet<>(); + Set selectionSpans = new HashSet<>(); + + for (var scenario : scenarios) { + var identity = scenario.identity(); + var environment = + scenario instanceof DiagnosticWorkloadSpecifications.CpuScenario + ? CPU_ENVIRONMENT + : RENDERING_ENVIRONMENT; + var comparability = + DiagnosticWorkloadSpecifications.comparability(scenario, environment, IMPLEMENTATION); + assertEquals( + com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity.requiredDimensions( + Category.valueOf( + identity + .dimensions() + .get(Dimension.CATEGORY) + .replace('-', '_') + .toUpperCase(Locale.ROOT)), + identity.dimensions().get(Dimension.OPERATION)), + identity.dimensions().keySet()); + assertTrue(semanticIds.add(identity.semanticId()), scenario.name()); + assertTrue(requiredFingerprints.add(comparability.fingerprints().required()), scenario.name()); + assertTrue(series.add(identity.seriesId()), scenario.name()); + assertEquals(identity.semanticId(), comparability.semanticId()); + assertEquals( + ComparabilityMetadata.EvidenceMode.COUNTER_ONLY_DIAGNOSTICS_ENABLED, + comparability.evidenceMode()); + assertEquals(scenario.executionSettings(), comparability.benchmarkSettings()); + categories.add( + Category.valueOf( + identity + .dimensions() + .get(Dimension.CATEGORY) + .replace('-', '_') + .toUpperCase(Locale.ROOT))); + add(identity, Dimension.VISIBILITY, visibility); + add(identity, Dimension.SUBMISSION_STATE, submission); + add(identity, Dimension.WRAP_WIDTH_PX, wrapWidths); + add(identity, Dimension.MEASUREMENT_OFFSET_X_PX, offsets); + if (scenario instanceof DiagnosticWorkloadSpecifications.RendererScenario renderer + && renderer.category() != Category.NORMAL_TEXT) { + selectionSpans.add(renderer.selectionEndUtf16() - renderer.selectionStartUtf16()); + } + } + + assertEquals(scenarios.size(), semanticIds.size()); + assertEquals( + Set.of(Category.CPU, Category.NORMAL_TEXT, Category.INPUT, Category.TEXTAREA), categories); + assertEquals(Set.of("visible", "offscreen"), visibility); + assertEquals(Set.of("changed", "unchanged"), submission); + assertTrue(wrapWidths.containsAll(Set.of("0", "24", "48", "100000"))); + assertTrue(offsets.containsAll(Set.of("0", "0.5"))); + assertTrue(selectionSpans.containsAll(Set.of(8, 12, 28, 35))); + assertTrue( + DiagnosticWorkloadSpecifications.RENDERER_SCENARIOS.stream() + .anyMatch( + scenario -> + scenario.category() == Category.TEXTAREA + && scenario.expectedShape().paragraphCount() == 4 + && scenario.expectedShape().lineStartKerningTransitionCount() == 4 + && scenario.expectedShape().fallbackTransitionCount() == 2)); + } + + @Test + void scaledCpuFixturesExposeCurrentLinearRunAssemblyWithoutClocks() throws Exception { + List entries = CounterDiagnosticsMain.runCpuScenarios(); + Map byName = + entries.stream() + .collect(java.util.stream.Collectors.toMap(Entry::scenarioName, entry -> entry)); + + for (int glyphCount : List.of(8, 16, 32)) { + Entry entry = byName.get("run-assembly-" + glyphCount); + assertEquals(glyphCount * 2L, counter(entry, TextDiagnosticCounter.GLYPH_SLOTS_COPIED)); + assertEquals(0, counter(entry, TextDiagnosticCounter.GLYPH_SLOTS_MOVED)); + assertEquals( + glyphCount, + counter(entry, TextDiagnosticCounter.INITIAL_RESOLUTION_GLYPH_SLOTS_COPIED)); + assertEquals( + glyphCount, + counter(entry, TextDiagnosticCounter.RANGE_MATERIALIZATION_GLYPH_SLOTS_COPIED)); + assertEquals( + glyphCount * 2L, + counter(entry, TextDiagnosticCounter.GLYPH_SLOT_BUILDER_APPENDS)); + assertEquals(2, counter(entry, TextDiagnosticCounter.GLYPH_SLOT_BUILDER_FREEZES)); + assertEquals(2, counter(entry, TextDiagnosticCounter.RUN_BUILDER_FREEZES)); + assertEquals(0, counter(entry, TextDiagnosticCounter.RANGE_TEMPORARY_STRINGS)); + } + Entry deferred = byName.get("multi-paragraph-fallback-deferred-suffix"); + assertEquals(0, observed(deferred, "observed-deferred-suffix-code-point-count")); + assertEquals(25, counter(deferred, TextDiagnosticCounter.SOURCE_CODE_POINTS_SCANNED)); + assertEquals(25, counter(deferred, TextDiagnosticCounter.WRAP_PRIMITIVE_VISITS)); + assertTrue( + observed( + deferred, "observed-fallback-transition-count") + > 0); + assertTrue( + observed( + byName.get("multi-paragraph-fallback-line-start"), + "observed-line-start-kerning-transition-count") + > 0); + assertEquals( + 26, + observed(byName.get("zero-width-boundary"), "observed-visual-line-count")); + + String runnerSource = + Files.readString( + repositoryRoot().resolve( + "spinygui.benchmark/src/main/java/com/spinyowl/spinygui/benchmark/diagnostic/CounterDiagnosticsMain.java")); + assertFalse(runnerSource.contains("System.nanoTime")); + assertFalse(runnerSource.contains("System.currentTimeMillis")); + assertFalse(runnerSource.contains("Instant.now")); + } + + @Test + void outputOnlyCounterDriftKeepsIdentityFingerprintAndSeriesFixed() { + var scenario = + DiagnosticWorkloadSpecifications.RENDERER_SCENARIOS.stream() + .filter(candidate -> candidate.category() == Category.NORMAL_TEXT) + .filter(candidate -> "changed".equals(candidate.submissionState())) + .findFirst() + .orElseThrow(); + var identity = scenario.identity(); + var comparability = + DiagnosticWorkloadSpecifications.comparability( + scenario, RENDERING_ENVIRONMENT, IMPLEMENTATION); + var prepared = + CounterDiagnosticsMain.prepareScene( + scenario, + DiagnosticSession.enabled( + java.util.stream.Stream.concat( + java.util.Arrays.stream(TextDiagnosticCounter.values()), + java.util.Arrays.stream(NvgDiagnosticCounter.values())) + .toList())); + CounterDiagnosticsMain.validatePreparedScene(scenario, prepared); + Map beforeOutputs = + new java.util.LinkedHashMap<>(CounterDiagnosticsMain.preparedEvidence(prepared)); + Map afterOutputs = new java.util.LinkedHashMap<>(beforeOutputs); + afterOutputs.compute( + "observed-resolved-glyph-count", (key, value) -> number(value.getAsLong() + 3)); + afterOutputs.compute( + "observed-resolved-run-count", (key, value) -> number(value.getAsLong() + 2)); + afterOutputs.compute( + "observed-text-fragment-count", (key, value) -> number(value.getAsLong() + 1)); + Map declared = declaredInputs(scenario); + Entry before = + new Entry( + scenario.name(), scenario.evidenceScope(), identity.semanticId(), identity.seriesId(), + declared, comparability.toJson(), + Map.of( + NvgDiagnosticCounter.SAVE_CALLS.id(), 1L, + NvgDiagnosticCounter.NORMAL_TEXT_ITEMS_CULLED.id(), 0L), + Set.of(), beforeOutputs); + Entry after = + new Entry( + scenario.name(), scenario.evidenceScope(), identity.semanticId(), identity.seriesId(), + declared, comparability.toJson(), + Map.of( + NvgDiagnosticCounter.SAVE_CALLS.id(), 2L, + NvgDiagnosticCounter.NORMAL_TEXT_ITEMS_CULLED.id(), 1L), + Set.of(), afterOutputs); + + assertNotEquals(before.counters(), after.counters()); + assertNotEquals(before.observedOutputs(), after.observedOutputs()); + assertNotEquals( + before.observedOutputs().get("observed-resolved-glyph-count"), + after.observedOutputs().get("observed-resolved-glyph-count")); + assertNotEquals( + before.observedOutputs().get("observed-resolved-run-count"), + after.observedOutputs().get("observed-resolved-run-count")); + assertNotEquals( + before.observedOutputs().get("observed-text-fragment-count"), + after.observedOutputs().get("observed-text-fragment-count")); + assertNotEquals( + before.counters().get(NvgDiagnosticCounter.SAVE_CALLS.id()), + after.counters().get(NvgDiagnosticCounter.SAVE_CALLS.id())); + assertNotEquals( + before.counters().get(NvgDiagnosticCounter.NORMAL_TEXT_ITEMS_CULLED.id()), + after.counters().get(NvgDiagnosticCounter.NORMAL_TEXT_ITEMS_CULLED.id())); + assertEquals(before.semanticId(), after.semanticId()); + assertEquals(before.seriesId(), after.seriesId()); + assertEquals( + ComparabilityMetadata.fromJson(before.comparability()).fingerprints(), + ComparabilityMetadata.fromJson(after.comparability()).fingerprints()); + } + + @Test + void preparedObjectDriftFailsClosedInsteadOfEchoingDeclarations() { + var normal = scenario(Category.NORMAL_TEXT, "normal-visible-changed"); + var normalPrepared = prepare(normal); + CounterDiagnosticsMain.validatePreparedScene(normal, normalPrepared); + ((Text) normalPrepared.nodes().getFirst()).content("same setup changed after preparation"); + assertThrows( + IllegalStateException.class, + () -> CounterDiagnosticsMain.validatePreparedScene(normal, normalPrepared)); + + var input = scenario(Category.INPUT, "input-visible-changed"); + var inputPrepared = prepare(input); + CounterDiagnosticsMain.validatePreparedScene(input, inputPrepared); + ((InputElement) inputPrepared.nodes().getFirst()).box().content().width(321); + assertThrows( + IllegalStateException.class, + () -> CounterDiagnosticsMain.validatePreparedScene(input, inputPrepared)); + + var textarea = scenario(Category.TEXTAREA, "textarea-visible-changed"); + var textareaPrepared = prepare(textarea); + CounterDiagnosticsMain.validatePreparedScene(textarea, textareaPrepared); + ((TextareaElement) textareaPrepared.nodes().getFirst()).select(0, 1); + assertThrows( + IllegalArgumentException.class, + () -> CounterDiagnosticsMain.validatePreparedScene(textarea, textareaPrepared)); + + var placement = prepare(normal); + placement.container().box().content().x(1279); + assertThrows( + IllegalArgumentException.class, + () -> CounterDiagnosticsMain.validatePreparedScene(normal, placement)); + } + + @Test + void categorySpecificObservedSchemasRejectMissingExtraAndInapplicableFields() { + var normal = scenario(Category.NORMAL_TEXT, "normal-visible-changed"); + var prepared = prepare(normal); + var identity = normal.identity(); + var comparability = + DiagnosticWorkloadSpecifications.comparability( + normal, RENDERING_ENVIRONMENT, IMPLEMENTATION); + Map valid = + new java.util.LinkedHashMap<>(CounterDiagnosticsMain.preparedEvidence(prepared)); + Map declared = declaredInputs(normal); + + Map missing = new java.util.LinkedHashMap<>(valid); + missing.remove("observed-resolved-glyph-count"); + assertThrows( + IllegalArgumentException.class, + () -> entry(normal, identity, comparability, declared, missing)); + + for (String inapplicable : + Set.of( + "observed-control-width-px", + "observed-selection-start-utf16", + "observed-wrap-width-px")) { + Map extra = new java.util.LinkedHashMap<>(valid); + extra.put(inapplicable, number(1)); + assertThrows( + IllegalArgumentException.class, + () -> entry(normal, identity, comparability, declared, extra), + inapplicable); + } + + Map extraDeclared = new java.util.LinkedHashMap<>(declared); + extraDeclared.put("selection-start-utf16", "0"); + assertThrows( + IllegalArgumentException.class, + () -> entry(normal, identity, comparability, extraDeclared, valid)); + + Map wrongType = new java.util.LinkedHashMap<>(valid); + wrongType.put("observed-resolved-glyph-count", new JsonPrimitive("152")); + assertThrows( + IllegalArgumentException.class, + () -> entry(normal, identity, comparability, declared, wrongType)); + } + + @Test + void artifactRejectsMergedVariantsAndTimedFactoriesUseTheDisabledSingleton() { + var scenario = DiagnosticWorkloadSpecifications.CPU_SCENARIOS.getFirst(); + var metadata = + BenchmarkRunMetadata.investigation( + "run-1", + BenchmarkRunMetadata.Artifact.COUNTER_DIAGNOSTICS, + ComparabilityMetadata.EvidenceMode.COUNTER_ONLY_DIAGNOSTICS_ENABLED); + var comparability = + DiagnosticWorkloadSpecifications.comparability(scenario, CPU_ENVIRONMENT, IMPLEMENTATION); + Entry recorded = CounterDiagnosticsMain.runCpuScenarios().getFirst(); + Entry entry = + new Entry( + scenario.name(), scenario.evidenceScope(), scenario.identity().semanticId(), + scenario.identity().seriesId(), recorded.declaredInputs(), comparability.toJson(), + recorded.counters(), Set.of(), recorded.observedOutputs()); + Map mismatchedDeclared = new java.util.LinkedHashMap<>(entry.declaredInputs()); + mismatchedDeclared.put("wrap-width-px", "999"); + assertThrows( + IllegalArgumentException.class, + () -> + new Entry( + entry.scenarioName(), entry.evidenceScope(), entry.semanticId(), entry.seriesId(), + mismatchedDeclared, entry.comparability(), entry.counters(), + entry.saturatedCounterIds(), entry.observedOutputs())); + Map mismatchedObserved = + new java.util.LinkedHashMap<>(entry.observedOutputs()); + mismatchedObserved.compute( + "observed-visual-line-count", (key, value) -> number(value.getAsLong() + 1)); + assertThrows( + IllegalArgumentException.class, + () -> + new Entry( + entry.scenarioName(), entry.evidenceScope(), entry.semanticId(), entry.seriesId(), + entry.declaredInputs(), entry.comparability(), entry.counters(), + entry.saturatedCounterIds(), mismatchedObserved)); + assertThrows( + IllegalArgumentException.class, + () -> + new CounterDiagnosticArtifact( + CounterDiagnosticArtifact.SCHEMA_VERSION, + metadata.toJson(), + "core-v1", + "nvg-v1", + List.of(entry, entry))); + + assertSame( + DiagnosticSession.disabled(), + com.spinyowl.spinygui.benchmark.cpu.CpuWorkloadSpecifications.TRIAL_SETUP + .createFontService() + .diagnostics()); + assertSame( + DiagnosticSession.disabled(), + com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications.CURRENT + .createFontService() + .diagnostics()); + } + + private static long counter(Entry entry, TextDiagnosticCounter counter) { + return entry.counters().get(counter.id()); + } + + private static long observed(Entry entry, String key) { + return entry.observedOutputs().get(key).getAsLong(); + } + + private static DiagnosticWorkloadSpecifications.CpuScenario copyCpu( + DiagnosticWorkloadSpecifications.CpuScenario source, + DiagnosticWorkloadSpecifications.ExpectedShape expectedShape) { + return new DiagnosticWorkloadSpecifications.CpuScenario( + source.name(), source.workloadContent(), source.text(), source.fonts(), source.fontSizePx(), + source.lineHeight(), source.measurementOffsetXPx(), source.wrapWidthPx(), source.wordWrap(), + expectedShape); + } + + private static DiagnosticWorkloadSpecifications.RendererScenario scenario( + Category category, String name) { + return DiagnosticWorkloadSpecifications.RENDERER_SCENARIOS.stream() + .filter(candidate -> candidate.category() == category && candidate.name().equals(name)) + .findFirst() + .orElseThrow(); + } + + private static DiagnosticWorkloadSpecifications.RendererScenario copyRenderer( + DiagnosticWorkloadSpecifications.RendererScenario source, + float controlWidth, + float controlHeight, + int selectionStart, + int selectionEnd, + int caret, + float scrollX, + float scrollY, + DiagnosticWorkloadSpecifications.Rect container) { + return new DiagnosticWorkloadSpecifications.RendererScenario( + source.name(), + source.category(), + source.workloadContent(), + source.sourceContents(), + source.itemCount(), + container, + controlWidth, + controlHeight, + selectionStart, + selectionEnd, + caret, + scrollX, + scrollY, + source.submissionState(), + source.expectedShape()); + } + + private static CounterDiagnosticsMain.PreparedScene prepare( + DiagnosticWorkloadSpecifications.RendererScenario scenario) { + var counters = + java.util.stream.Stream.concat( + java.util.Arrays.stream(TextDiagnosticCounter.values()), + java.util.Arrays.stream(NvgDiagnosticCounter.values())) + .map(counter -> (com.spinyowl.spinygui.core.diagnostic.DiagnosticCounter) counter) + .toList(); + return CounterDiagnosticsMain.prepareScene(scenario, DiagnosticSession.enabled(counters)); + } + + private static Map declaredInputs( + DiagnosticWorkloadSpecifications.Scenario scenario) { + Map declared = new java.util.LinkedHashMap<>(); + scenario + .identity() + .dimensions() + .forEach((dimension, value) -> declared.put(dimension.key(), value)); + return Map.copyOf(declared); + } + + private static Entry entry( + DiagnosticWorkloadSpecifications.RendererScenario scenario, + com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity identity, + ComparabilityMetadata comparability, + Map declared, + Map observed) { + return new Entry( + scenario.name(), + scenario.evidenceScope(), + identity.semanticId(), + identity.seriesId(), + declared, + comparability.toJson(), + Map.of(), + Set.of(), + observed); + } + + private static JsonPrimitive number(long value) { + return new JsonPrimitive(value); + } + + private static String decimal(float value) { + if (Float.compare(value, 0) == 0) return "0"; + return new java.math.BigDecimal(Float.toString(value)).stripTrailingZeros().toPlainString(); + } + + private static void add( + com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity identity, + Dimension dimension, + Set values) { + String value = identity.dimensions().get(dimension); + if (value != null) values.add(value); + } + + private static Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle.kts"))) { + current = current.getParent(); + } + if (current == null) throw new IllegalStateException("Repository root not found"); + return current; + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/frame/FrameBaselineRecorderTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/frame/FrameBaselineRecorderTest.java new file mode 100644 index 00000000..fc0817d3 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/frame/FrameBaselineRecorderTest.java @@ -0,0 +1,85 @@ +package com.spinyowl.spinygui.benchmark.frame; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.spinyowl.spinygui.core.diagnostic.FrameDiagnosticCounter; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class FrameBaselineRecorderTest { + @Test + void shortBaselineMatrixProducesAllMatchedRatesAndRequiredMetrics() { + FrameBaselineArtifact artifact = FrameBaselineRecorder.recordAll("test-frame-baseline", 1, false); + + assertEquals(18, artifact.recordings().size()); + assertEquals(FrameDiagnosticCounter.VOCABULARY_VERSION, artifact.frameVocabularyVersion()); + assertEquals( + Set.of( + "traversal-views", + "geometry", + "transforms", + "selectors", + "properties", + "layout", + "lookup", + "mutation", + "text-owned-work"), + artifact.review().ownership().keySet()); + assertTrue(artifact.review().separatesStableRenderingFromExpansion()); + assertTrue(artifact.review().separatesTextOwnedWork()); + + Set series = new HashSet<>(); + for (FrameBaselineArtifact.Recording recording : artifact.recordings()) { + assertTrue(series.add(recording.seriesId())); + assertTrue(recording.measuredFrames() > 0); + assertTrue(recording.elapsedNanos() > 0); + assertTrue(recording.allocationBytesPerFrame() >= 0); + assertTrue(recording.allocationBytesPerSecond() >= 0); + assertTrue(recording.cpuNanosPerFrame() >= 0); + assertTrue(recording.cpuNanosPerSecond() >= 0); + assertNotNull(recording.comparability()); + assertFalse(recording.counters().isEmpty()); + assertTrue(recording.counters().containsKey(FrameDiagnosticCounter.LAYOUT_PASSES.id())); + assertTrue(recording.counters().containsKey(FrameDiagnosticCounter.SELECTOR_TESTS.id())); + assertTrue(recording.profilerNote().contains("focused verification")); + } + } + + @Test + void disabledProfileIsExplicitlyMarkedRatherThanPresentedAsMissingEvidence() { + var scenario = FrameScenarioSpecifications.SCENARIOS.get(0); + FrameBaselineArtifact.Recording recording = + FrameBaselineRecorder.record(scenario, FrameBaselineRecorder.RatePolicy.UNCAPPED, 1, false); + + assertFalse(recording.profilerAvailable()); + assertEquals("disabled for focused verification", recording.profilerNote()); + } + + @Test + void differentRateFingerprintsAreMarkedIncomparableBeforeAnyDeltaIsPresented() { + var scenario = FrameScenarioSpecifications.SCENARIOS.get(0); + var uncapped = FrameBaselineRecorder.record(scenario, FrameBaselineRecorder.RatePolicy.UNCAPPED, 1, false); + var capped = FrameBaselineRecorder.record(scenario, FrameBaselineRecorder.RatePolicy.FPS_60, 1, false); + + var comparison = FrameBaselineArtifact.compareFingerprints(uncapped, capped); + assertFalse(comparison.comparable()); + assertTrue(comparison.reasons().stream().anyMatch(reason -> reason.contains("settings"))); + } + + @Test + void jfrProfileIsCollectedOrExplicitlyReportsRuntimeUnavailability() { + var scenario = FrameScenarioSpecifications.SCENARIOS.get(0); + var recording = + FrameBaselineRecorder.record(scenario, FrameBaselineRecorder.RatePolicy.UNCAPPED, 1, true); + + assertTrue(recording.profilerNote().startsWith("jdk.") + || recording.profilerNote().startsWith("unavailable:")); + if (recording.profilerAvailable()) { + assertFalse(recording.hotMethods().isEmpty() && recording.hotSites().isEmpty()); + } + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/frame/FrameScenarioSpecificationsTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/frame/FrameScenarioSpecificationsTest.java new file mode 100644 index 00000000..58e60611 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/frame/FrameScenarioSpecificationsTest.java @@ -0,0 +1,67 @@ +package com.spinyowl.spinygui.benchmark.frame; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class FrameScenarioSpecificationsTest { + @Test + void matrixContainsStableCollapsedExpandedAndInteractionScenarios() { + assertEquals(6, FrameScenarioSpecifications.SCENARIOS.size()); + Set semanticIds = new HashSet<>(); + Set seriesIds = new HashSet<>(); + for (FrameScenarioSpecifications.Scenario scenario : FrameScenarioSpecifications.SCENARIOS) { + assertTrue(semanticIds.add(scenario.semanticId())); + assertTrue(seriesIds.add(scenario.seriesId())); + assertEquals(scenario.nodeCount(), scenario.contentManifest().canonicalSerialization().lines().count() - 1); + assertTrue(scenario.declaredInputs().get("workload-content-sha256").startsWith("sha256:")); + assertTrue(scenario.declaredInputs().get("workload-shape-sha256").startsWith("sha256:")); + } + assertTrue( + FrameScenarioSpecifications.SCENARIOS.stream() + .anyMatch(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.COLLAPSED)); + assertTrue( + FrameScenarioSpecifications.SCENARIOS.stream() + .anyMatch(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.EXPANDED)); + assertTrue( + FrameScenarioSpecifications.SCENARIOS.stream() + .anyMatch(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.POINTER_ACTIVE)); + assertTrue( + FrameScenarioSpecifications.SCENARIOS.stream() + .anyMatch(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.SCROLL)); + assertTrue( + FrameScenarioSpecifications.SCENARIOS.stream() + .anyMatch(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.RESIZE)); + assertTrue( + FrameScenarioSpecifications.SCENARIOS.stream() + .anyMatch(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.TRANSFORM)); + } + + @Test + void contentAndShapeManifestsChangeWhenDeclaredInputsChange() { + var collapsed = + FrameScenarioSpecifications.SCENARIOS.stream() + .filter(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.COLLAPSED) + .findFirst() + .orElseThrow(); + var expanded = + FrameScenarioSpecifications.SCENARIOS.stream() + .filter(scenario -> scenario.kind() == FrameScenarioSpecifications.Kind.EXPANDED) + .findFirst() + .orElseThrow(); + + assertNotEquals(collapsed.contentManifest().sha256(), expanded.contentManifest().sha256()); + assertNotEquals(collapsed.shapeManifest().sha256(), expanded.shapeManifest().sha256()); + assertFalse(collapsed.declaredInputs().equals(expanded.declaredInputs())); + } + + @Test + void referenceFixturesAreDeterministicAndPreserveTreeScrollAndTransformContracts() { + FrameEvidenceFixtures.validateReferenceContracts(); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkInputManifestsTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkInputManifestsTest.java new file mode 100644 index 00000000..708d9eda --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkInputManifestsTest.java @@ -0,0 +1,61 @@ +package com.spinyowl.spinygui.benchmark.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import com.spinyowl.spinygui.benchmark.identity.BenchmarkInputManifests.FontInput; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BenchmarkInputManifestsTest { + @Test + void contentManifestPreservesExactUnicodeBytesWithoutNfcNormalization() { + BenchmarkInputManifests.Manifest composed = + BenchmarkInputManifests.content(Map.of("text", "Caf\u00e9")); + BenchmarkInputManifests.Manifest decomposed = + BenchmarkInputManifests.content(Map.of("text", "Cafe\u0301")); + + assertEquals( + "spinygui-benchmark-input:workload-content:v1\nfield=4:text=5:Caf\u00e9\n", + composed.canonicalSerialization()); + assertNotEquals(composed.sha256(), decomposed.sha256()); + } + + @Test + void lengthPrefixesKeepDelimiterRichFieldsUnambiguous() { + BenchmarkInputManifests.Manifest first = + BenchmarkInputManifests.shape(Map.of("shape-kind", "a\nfield=1:x=1:y")); + BenchmarkInputManifests.Manifest second = + BenchmarkInputManifests.shape(Map.of("shape-kind", "a", "x", "y")); + + assertNotEquals(first.canonicalSerialization(), second.canonicalSerialization()); + assertNotEquals(first.sha256(), second.sha256()); + } + + @Test + void orderedFontManifestChangesWhenResourceBytesChange() { + List fonts = + List.of(new FontInput("measurement", "Family|normal|regular|font.ttf", "font.ttf")); + + BenchmarkInputManifests.Manifest first = + BenchmarkInputManifests.fonts(fonts, ignored -> new byte[] {1, 2, 3}); + BenchmarkInputManifests.Manifest changedResource = + BenchmarkInputManifests.fonts(fonts, ignored -> new byte[] {1, 2, 4}); + BenchmarkInputManifests.Manifest changedOrder = + BenchmarkInputManifests.fonts( + List.of( + new FontInput("first", "A|a.ttf", "a.ttf"), + new FontInput("second", "B|b.ttf", "b.ttf")), + path -> path.equals("a.ttf") ? new byte[] {1} : new byte[] {2}); + BenchmarkInputManifests.Manifest reversedOrder = + BenchmarkInputManifests.fonts( + List.of( + new FontInput("second", "B|b.ttf", "b.ttf"), + new FontInput("first", "A|a.ttf", "a.ttf")), + path -> path.equals("a.ttf") ? new byte[] {1} : new byte[] {2}); + + assertNotEquals(first.sha256(), changedResource.sha256()); + assertNotEquals(changedOrder.sha256(), reversedOrder.sha256()); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkInvocationMetadataTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkInvocationMetadataTest.java new file mode 100644 index 00000000..5634aa30 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkInvocationMetadataTest.java @@ -0,0 +1,50 @@ +package com.spinyowl.spinygui.benchmark.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.spinyowl.spinygui.benchmark.identity.BenchmarkRunMetadata.Artifact; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkRunMetadata.Pairing; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata.EvidenceMode; +import org.junit.jupiter.api.Test; + +class BenchmarkInvocationMetadataTest { + @Test + void timedCpuAndRenderingArtifactsAlwaysDisableDiagnostics() { + for (Artifact artifact : java.util.List.of(Artifact.CPU, Artifact.RENDERING)) { + for (Pairing pairing : Pairing.values()) { + BenchmarkRunMetadata metadata = + BenchmarkInvocationMetadata.timed("20260812-120000-000000000", artifact, pairing); + + assertEquals(EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, metadata.evidenceMode()); + assertEquals(pairing, metadata.pairing()); + assertEquals( + pairing == Pairing.PAIRED_REPORT, + metadata.baselineEligible()); + } + } + } + + @Test + void counterDiagnosticsAreAlwaysUntimedUnpairedEvidence() { + BenchmarkRunMetadata metadata = + BenchmarkInvocationMetadata.diagnostics("20260812-120000-000000000"); + + assertEquals(Artifact.COUNTER_DIAGNOSTICS, metadata.artifact()); + assertEquals(Pairing.UNPAIRED_INVESTIGATION, metadata.pairing()); + assertEquals(EvidenceMode.COUNTER_ONLY_DIAGNOSTICS_ENABLED, metadata.evidenceMode()); + assertFalse(metadata.baselineEligible()); + } + + @Test + void timedMetadataRejectsCounterDiagnosticsArtifacts() { + assertThrows( + IllegalArgumentException.class, + () -> + BenchmarkInvocationMetadata.timed( + "20260812-120000-000000000", + Artifact.COUNTER_DIAGNOSTICS, + Pairing.PAIRED_REPORT)); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkRunMetadataTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkRunMetadataTest.java new file mode 100644 index 00000000..f19091b8 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/BenchmarkRunMetadataTest.java @@ -0,0 +1,69 @@ +package com.spinyowl.spinygui.benchmark.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkRunMetadata.Artifact; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata.EvidenceMode; +import org.junit.jupiter.api.Test; + +class BenchmarkRunMetadataTest { + @Test + void pairedTimedMetadataRoundTripsAndIsBaselineEligible() { + BenchmarkRunMetadata metadata = + BenchmarkRunMetadata.paired( + "20260726-120000-000000000", + Artifact.CPU, + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED); + + assertEquals(metadata, BenchmarkRunMetadata.fromJson(metadata.toJson())); + assertTrue(metadata.baselineEligible()); + } + + @Test + void counterAndStandaloneInvestigationArtifactsAreNotBaselineEligible() { + assertFalse( + BenchmarkRunMetadata.paired( + "20260726-120000-000000000", + Artifact.CPU, + EvidenceMode.COUNTER_ONLY_DIAGNOSTICS_ENABLED) + .baselineEligible()); + assertFalse( + BenchmarkRunMetadata.investigation( + "20260726-120000-000000000", + Artifact.COUNTER_DIAGNOSTICS, + EvidenceMode.COUNTER_ONLY_DIAGNOSTICS_ENABLED) + .baselineEligible()); + assertFalse( + BenchmarkRunMetadata.investigation( + "20260726-120000-000000000", + Artifact.RENDERING, + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED) + .baselineEligible()); + } + + @Test + void parserFailsClosedForMissingUnknownAndUnsupportedMetadata() { + JsonObject valid = + BenchmarkRunMetadata.paired( + "20260726-120000-000000000", + Artifact.CPU, + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED) + .toJson(); + + JsonObject missing = valid.deepCopy(); + missing.remove("runId"); + assertThrows(IllegalArgumentException.class, () -> BenchmarkRunMetadata.fromJson(missing)); + + JsonObject unknown = valid.deepCopy(); + unknown.addProperty("timestamp", "unstable"); + assertThrows(IllegalArgumentException.class, () -> BenchmarkRunMetadata.fromJson(unknown)); + + JsonObject future = valid.deepCopy(); + future.addProperty("schemaVersion", 2); + assertThrows(IllegalArgumentException.class, () -> BenchmarkRunMetadata.fromJson(future)); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/ComparabilityMetadataTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/ComparabilityMetadataTest.java new file mode 100644 index 00000000..114e360a --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/ComparabilityMetadataTest.java @@ -0,0 +1,412 @@ +package com.spinyowl.spinygui.benchmark.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata.EvidenceMode; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ComparabilityMetadataTest { + @Test + void canonicalFingerprintsIgnoreMapOrderAndEquivalentUnicodeRepresentation() { + Map firstSettings = new LinkedHashMap<>(settings("3")); + Map secondSettings = new LinkedHashMap<>(); + firstSettings.entrySet().stream().toList().reversed() + .forEach(entry -> secondSettings.put(entry.getKey(), entry.getValue())); + + ComparabilityMetadata first = metadata("behavior-1", hash('a'), "JVM e\u0301", firstSettings, "impl-1"); + ComparabilityMetadata second = metadata("behavior-1", hash('a'), "JVM \u00e9", secondSettings, "impl-1"); + + assertEquals(first.fingerprints(), second.fingerprints()); + assertTrue(first.compare(second).comparable()); + } + + @Test + void goldenCanonicalSerializationAndFingerprintsRemainStable() { + ComparabilityMetadata metadata = + metadata("behavior-1", hash('a'), "JVM", settings("3"), "impl-1"); + + assertEquals( + """ + spinygui-comparability:v2 + group=8:identity + behavior-contract-version=10:behavior-1 + benchmark-version=11:benchmark-1 + evidence-mode=37:timed-allocation-diagnostics-disabled + fingerprint-schema-version=1:2 + result-schema-version=15:result-schema-1 + semantic-id=10:semantic-1 + workload-version=10:workload-1 + """, + metadata.canonicalSerialization( + ComparabilityMetadata.FingerprintComponent.IDENTITY)); + assertEquals( + new ComparabilityMetadata.Fingerprints( + "sha256:1f00db2cc3fb784a31d069144edaa593a30131c8e557963ba1595aff4ecc8c3e", + "sha256:67890bb5f5b031921e508a03912378413e6d0f2d8a08cbe28645b1c381270658", + "sha256:14a970bcfac4a64c71bce4105d22746d24cf5b0a84f682854c0471d362334db8", + "sha256:3157419d7c484547463e41754836d38116d4f25db892f7930a58c1ce0b9ae866", + "sha256:06d539bf56476b1897d038826041c3521b6b93f93a9bf3a41299dbb75af7cda9"), + metadata.fingerprints()); + } + + @Test + void identifiesOneFieldMismatchInEveryRequiredEqualityFingerprint() { + ComparabilityMetadata baseline = metadata("behavior-1", hash('a'), "JVM", settings("3"), "impl-1"); + + assertOnlyFingerprintDiffers( + baseline, metadata("behavior-2", hash('a'), "JVM", settings("3"), "impl-1"), "identity"); + assertOnlyFingerprintDiffers( + baseline, metadata("behavior-1", hash('b'), "JVM", settings("3"), "impl-1"), "workload"); + assertOnlyFingerprintDiffers( + baseline, metadata("behavior-1", hash('a'), "Other JVM", settings("3"), "impl-1"), "environment"); + assertOnlyFingerprintDiffers( + baseline, metadata("behavior-1", hash('a'), "JVM", settings("4"), "impl-1"), "settings"); + } + + @Test + void evidenceModeIsRequiredIdentityAndFingerprintMetadata() { + ComparabilityMetadata timed = + metadata("behavior-1", hash('a'), "JVM", settings("3"), "impl-1"); + JsonObject counterJson = timed.toJson(); + counterJson.addProperty("evidenceMode", "counter-only-diagnostics-enabled"); + JsonObject counterSettings = new JsonObject(); + counterSettings.addProperty("native-access", "all-unnamed"); + counterSettings.addProperty("prewarm-operation-count", "1"); + counterSettings.addProperty("recorded-operation-count", "1"); + counterSettings.addProperty("reset-policy", "immediately-before-recorded-operation"); + counterSettings.addProperty("setup-policy", "same-exact-scenario-operation-prewarmed-once"); + counterSettings.addProperty("snapshot-policy", "immediately-after-recorded-operation"); + counterSettings.addProperty("thread-count", "1"); + counterSettings.addProperty("timing", "none"); + counterJson.add("benchmarkSettings", counterSettings); + ComparabilityMetadata counter = ComparabilityMetadata.fromJson(counterJson); + + assertEquals( + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, timed.evidenceMode()); + assertEquals(EvidenceMode.COUNTER_ONLY_DIAGNOSTICS_ENABLED, counter.evidenceMode()); + assertNotEquals(timed.fingerprints().identity(), counter.fingerprints().identity()); + assertEquals(timed.fingerprints().workload(), counter.fingerprints().workload()); + assertEquals(timed.fingerprints().environment(), counter.fingerprints().environment()); + assertNotEquals(timed.fingerprints().settings(), counter.fingerprints().settings()); + assertNotEquals(timed.fingerprints().required(), counter.fingerprints().required()); + assertTrue(timed.compare(counter).reason().startsWith("identity.evidence-mode differs")); + } + + @Test + void schemaAndBehaviorVersionsAreExplicitIdentityMismatches() { + ComparabilityMetadata baseline = metadata("behavior-1", hash('a'), "JVM", settings("3"), "impl-1"); + ComparabilityMetadata behavior = metadata("behavior-2", hash('a'), "JVM", settings("3"), "impl-1"); + ComparabilityMetadata schema = + new ComparabilityMetadata( + "benchmark-1", "workload-1", "result-schema-2", "behavior-1", + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, "semantic-1", + "CPU fixture", hash('a'), hash('c'), hash('d'), cpuEnvironment("JVM"), settings("3"), + implementation("impl-1")); + + assertEquals("identity.behavior-contract-version differs", baseline.compare(behavior).reason()); + assertEquals("identity.result-schema-version differs", baseline.compare(schema).reason()); + } + + @Test + void implementationRevisionAndDisplayLabelAreReportedButExcludedFromEquality() { + ComparabilityMetadata first = metadata("behavior-1", hash('a'), "JVM", settings("3"), "impl-1"); + ComparabilityMetadata second = + new ComparabilityMetadata( + "benchmark-1", "workload-1", "result-schema-1", "behavior-1", + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, "semantic-1", + "Renamed presentation label", hash('a'), hash('c'), hash('d'), cpuEnvironment("JVM"), + settings("3"), new ComparabilityMetadata.Implementation("impl-2", "build-2", "commit-2")); + + assertEquals(first.fingerprints(), second.fingerprints()); + assertTrue(first.compare(second).comparable()); + assertNotEquals(first.displayLabel(), second.displayLabel()); + assertNotEquals(first.implementation(), second.implementation()); + } + + @Test + void jsonEvolutionFailsClosedForUnknownEqualityFieldsAndAllowsExplicitExtensions() { + JsonObject valid = JsonParser.parseString(json()).getAsJsonObject(); + valid.add("extensions", new JsonObject()); + valid.getAsJsonObject("environment").add("extensions", new JsonObject()); + valid.getAsJsonObject("implementation").add("extensions", new JsonObject()); + assertEquals("semantic-1", ComparabilityMetadata.fromJson(valid).semanticId()); + + JsonObject unknown = JsonParser.parseString(json()).getAsJsonObject(); + unknown.addProperty("observedGlyphCount", 42); + IllegalArgumentException unknownFailure = + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(unknown)); + assertTrue(unknownFailure.getMessage().contains("Unknown comparability field")); + + JsonObject missing = JsonParser.parseString(json()).getAsJsonObject(); + missing.remove("workloadShapeSha256"); + IllegalArgumentException missingFailure = + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(missing)); + assertTrue(missingFailure.getMessage().contains("Missing required comparability field")); + + JsonObject futureSchema = JsonParser.parseString(json()).getAsJsonObject(); + futureSchema.addProperty("fingerprintSchemaVersion", 3); + IllegalArgumentException schemaFailure = + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(futureSchema)); + assertTrue(schemaFailure.getMessage().contains("Unsupported comparability fingerprint schema version")); + JsonObject withoutExtensions = JsonParser.parseString(json()).getAsJsonObject(); + assertEquals(withoutExtensions, ComparabilityMetadata.fromJson(withoutExtensions).toJson()); + } + + @Test + void renderingEnvironmentRequiresDriverIdentityAndCpuEnvironmentRejectsGlNoise() { + assertThrows( + IllegalArgumentException.class, + () -> new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.RENDERING, "Vendor", "25", "OS", "1", "x64", "CPU", + "GL vendor", "Renderer", null, "4.6")); + assertThrows( + IllegalArgumentException.class, + () -> new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.CPU, "Vendor", "25", "OS", "1", "x64", "CPU", + "irrelevant", null, null, null)); + assertThrows( + IllegalArgumentException.class, + () -> new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.CPU, "Vendor", "25", "OS", "1", "x64", null, + null, null, null, null)); + } + + @Test + void jsonRejectsWrongPrimitiveTypesAndEveryMissingRequiredField() { + for (String field : List.of( + "benchmarkVersion", "workloadVersion", "resultSchemaVersion", "behaviorContractVersion", "evidenceMode", + "semanticId", "displayLabel", "workloadContentSha256", "workloadShapeSha256", + "fontInputsSha256")) { + JsonObject wrong = JsonParser.parseString(json()).getAsJsonObject(); + wrong.addProperty(field, 1); + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(wrong), field); + } + JsonObject schemaString = JsonParser.parseString(json()).getAsJsonObject(); + schemaString.addProperty("fingerprintSchemaVersion", "2"); + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(schemaString)); + + JsonObject settingNumber = JsonParser.parseString(json()).getAsJsonObject(); + settingNumber.getAsJsonObject("benchmarkSettings").addProperty("threads", 1); + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(settingNumber)); + + JsonObject environmentBoolean = JsonParser.parseString(json()).getAsJsonObject(); + environmentBoolean.getAsJsonObject("environment").addProperty("cpuModel", true); + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(environmentBoolean)); + + JsonObject implementationNumber = JsonParser.parseString(json()).getAsJsonObject(); + implementationNumber.getAsJsonObject("implementation").addProperty("commitRevision", 1); + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(implementationNumber)); + + JsonObject uppercaseDigest = JsonParser.parseString(json()).getAsJsonObject(); + uppercaseDigest.addProperty("workloadContentSha256", hash('a').toUpperCase()); + assertThrows(IllegalArgumentException.class, () -> ComparabilityMetadata.fromJson(uppercaseDigest)); + + for (String field : List.of( + "scope", "jvmVendor", "jvmVersion", "osName", "osVersion", "osArchitecture", "cpuModel")) { + JsonObject missingCpu = JsonParser.parseString(json()).getAsJsonObject(); + missingCpu.getAsJsonObject("environment").remove(field); + assertThrows( + IllegalArgumentException.class, + () -> ComparabilityMetadata.fromJson(missingCpu), + "missing CPU environment field " + field); + } + + for (String setting : settings("3").keySet()) { + JsonObject missing = JsonParser.parseString(json()).getAsJsonObject(); + missing.getAsJsonObject("benchmarkSettings").remove(setting); + assertThrows( + IllegalArgumentException.class, + () -> ComparabilityMetadata.fromJson(missing), + "missing CPU setting " + setting); + } + Map unstableExtra = new LinkedHashMap<>(settings("3")); + unstableExtra.put("timestamp", "2026-07-26T00:00:00Z"); + assertThrows( + IllegalArgumentException.class, + () -> metadata("behavior-1", hash('a'), "JVM", unstableExtra, "impl-1")); + } + + @Test + void renderingSettingsUseAnExactCompleteScopeSpecificSchema() { + ComparabilityMetadata.Environment environment = + new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.RENDERING, "Vendor", "25", "OS", "1", "x64", "CPU", + "GL vendor", "Renderer", "driver", "4.6"); + ComparabilityMetadata metadata = + new ComparabilityMetadata( + "benchmark-1", "workload-1", "result-schema-1", "behavior-1", + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, "semantic-1", + "Rendering", hash('a'), hash('c'), hash('d'), environment, renderingSettings(), + implementation("impl-1")); + assertEquals(renderingSettings(), metadata.benchmarkSettings()); + JsonObject complete = metadata.toJson(); + for (String field : List.of( + "scope", "jvmVendor", "jvmVersion", "osName", "osVersion", "osArchitecture", "cpuModel", + "glVendor", "glRenderer", "glDriverVersion", "glVersion")) { + JsonObject missing = complete.deepCopy(); + missing.getAsJsonObject("environment").remove(field); + assertThrows( + IllegalArgumentException.class, + () -> ComparabilityMetadata.fromJson(missing), + "missing rendering environment field " + field); + } + for (String setting : renderingSettings().keySet()) { + Map incomplete = new LinkedHashMap<>(renderingSettings()); + incomplete.remove(setting); + assertThrows( + IllegalArgumentException.class, + () -> new ComparabilityMetadata( + "benchmark-1", "workload-1", "result-schema-1", "behavior-1", + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, "semantic-1", + "Rendering", hash('a'), hash('c'), hash('d'), environment, incomplete, + implementation("impl-1")), + "missing rendering setting " + setting); + JsonObject missingJson = complete.deepCopy(); + missingJson.getAsJsonObject("benchmarkSettings").remove(setting); + assertThrows( + IllegalArgumentException.class, + () -> ComparabilityMetadata.fromJson(missingJson), + "missing rendering JSON setting " + setting); + } + } + + private static void assertOnlyFingerprintDiffers( + ComparabilityMetadata baseline, ComparabilityMetadata changed, String group) { + ComparabilityMetadata.Fingerprints first = baseline.fingerprints(); + ComparabilityMetadata.Fingerprints second = changed.fingerprints(); + assertEquals(group.equals("identity"), !first.identity().equals(second.identity())); + assertEquals(group.equals("workload"), !first.workload().equals(second.workload())); + assertEquals(group.equals("environment"), !first.environment().equals(second.environment())); + assertEquals(group.equals("settings"), !first.settings().equals(second.settings())); + assertNotEquals(first.required(), second.required()); + ComparabilityMetadata.Comparison comparison = baseline.compare(changed); + assertFalse(comparison.comparable()); + assertTrue(comparison.reason().startsWith(group + ".")); + } + + private static ComparabilityMetadata metadata( + String behaviorVersion, + String contentHash, + String jvmVendor, + Map settings, + String implementationRevision) { + return new ComparabilityMetadata( + "benchmark-1", "workload-1", "result-schema-1", behaviorVersion, + EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, "semantic-1", + "CPU fixture", contentHash, hash('c'), hash('d'), cpuEnvironment(jvmVendor), settings, + implementation(implementationRevision)); + } + + private static ComparabilityMetadata.Environment cpuEnvironment(String jvmVendor) { + return new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.CPU, jvmVendor, "25.0.1", "OS", "1", "x64", "CPU model", + null, null, null, null); + } + + private static ComparabilityMetadata.Implementation implementation(String revision) { + return new ComparabilityMetadata.Implementation(revision, "build-1", "commit-1"); + } + + private static Map settings(String warmupIterations) { + return Map.ofEntries( + Map.entry("benchmark-mode", "average-time"), + Map.entry("forks", "2"), + Map.entry("measurement-batch-size", "1"), + Map.entry("measurement-iterations", "5"), + Map.entry("measurement-time", "PT0.5S"), + Map.entry("native-access", "all-unnamed"), + Map.entry("output-time-unit", "microseconds"), + Map.entry("profiler", "gc"), + Map.entry("state-scope", "benchmark"), + Map.entry("threads", "1"), + Map.entry("warmup-batch-size", "1"), + Map.entry("warmup-forks", "0"), + Map.entry("warmup-iterations", warmupIterations), + Map.entry("warmup-time", "PT0.5S")); + } + + private static Map renderingSettings() { + return Map.ofEntries( + Map.entry("alternating-warmup-frames-pair", "60"), + Map.entry("alternating-warmup-frames-scene", "30"), + Map.entry("clear-policy", "color-stencil-before-sample"), + Map.entry("context-visibility", "hidden"), + Map.entry("measured-frames", "200"), + Map.entry("measurement-order", "small-then-large"), + Map.entry("measurement-order-index", "1"), + Map.entry("native-access", "all-unnamed"), + Map.entry("premeasure-exposures-scene", "31"), + Map.entry("premeasure-sequence", "alternating-small-large-plus-small-validation"), + Map.entry("swap-interval", "0"), + Map.entry("synchronization", "gl-finish"), + Map.entry( + "validation-policy", "small-scene-production-command-recording-before-measurement"), + Map.entry("validation-exposures-scene", "1"), + Map.entry( + "validation-synchronization", + "render-and-gl-finish-then-production-command-recording"), + Map.entry("warmup-order", "alternating-small-large-starting-small"), + Map.entry("window-resizable", "false")); + } + + private static String hash(char value) { + return "sha256:" + String.valueOf(value).repeat(64); + } + + private static String json() { + return """ + { + "fingerprintSchemaVersion": 2, + "benchmarkVersion": "benchmark-1", + "workloadVersion": "workload-1", + "resultSchemaVersion": "result-schema-1", + "behaviorContractVersion": "behavior-1", + "evidenceMode": "timed-allocation-diagnostics-disabled", + "semanticId": "semantic-1", + "displayLabel": "CPU fixture", + "workloadContentSha256": "%s", + "workloadShapeSha256": "%s", + "fontInputsSha256": "%s", + "environment": { + "scope": "cpu", + "jvmVendor": "Vendor", + "jvmVersion": "25.0.1", + "osName": "OS", + "osVersion": "1", + "osArchitecture": "x64", + "cpuModel": "CPU model" + }, + "benchmarkSettings": { + "benchmark-mode": "average-time", + "forks": "2", + "measurement-batch-size": "1", + "measurement-iterations": "5", + "measurement-time": "PT0.5S", + "native-access": "all-unnamed", + "output-time-unit": "microseconds", + "profiler": "gc", + "state-scope": "benchmark", + "threads": "1", + "warmup-batch-size": "1", + "warmup-forks": "0", + "warmup-iterations": "3", + "warmup-time": "PT0.5S" + }, + "implementation": { + "implementationRevision": "impl-1", + "buildRevision": "build-1", + "commitRevision": "commit-1" + } + } + """.formatted(hash('a'), hash('c'), hash('d')); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/WorkloadIdentityTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/WorkloadIdentityTest.java new file mode 100644 index 00000000..adf555b8 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/identity/WorkloadIdentityTest.java @@ -0,0 +1,2681 @@ +package com.spinyowl.spinygui.benchmark.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.spinyowl.spinygui.benchmark.TextStyleSpecification; +import com.spinyowl.spinygui.benchmark.TextWorkloads; +import com.spinyowl.spinygui.benchmark.cpu.CpuWorkloadSpecifications; +import com.spinyowl.spinygui.benchmark.cpu.CpuWorkloadSpecifications.MeasurementSpec; +import com.spinyowl.spinygui.benchmark.cpu.CpuWorkloadSpecifications.OperationSpec; +import com.spinyowl.spinygui.benchmark.cpu.TextCalculationBenchmark; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity.Category; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity.Dimension; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications.ClearSpecification; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications.ContainerSpecification; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications.StructuralValidationSpecification; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications.SceneSpecification; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications.Specification; +import com.spinyowl.spinygui.core.font.Font; +import com.spinyowl.spinygui.core.font.FontStretch; +import com.spinyowl.spinygui.core.font.FontStyle; +import com.spinyowl.spinygui.core.font.FontWeight; +import com.spinyowl.spinygui.core.style.types.Color; +import com.spinyowl.spinygui.core.style.types.Display; +import com.spinyowl.spinygui.core.style.types.OverflowWrap; +import com.spinyowl.spinygui.core.style.types.Position; +import com.spinyowl.spinygui.core.style.types.TextAlign; +import com.spinyowl.spinygui.core.style.types.WhiteSpace; +import com.spinyowl.spinygui.core.style.types.WordBreak; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.BenchmarkParams; +import org.openjdk.jmh.runner.Defaults; + +class WorkloadIdentityTest { + + @Test + void matchesCompleteCanonicalGoldenFixturesForEveryRequiredCategory() { + JsonObject golden = golden(); + assertEquals( + WorkloadIdentity.IDENTITY_SCHEMA_VERSION, + golden.get("identitySchemaVersion").getAsInt()); + + Set categories = new HashSet<>(); + Set visibility = new HashSet<>(); + Set submissionStates = new HashSet<>(); + for (JsonElement element : golden.getAsJsonArray("fixtures")) { + JsonObject fixture = element.getAsJsonObject(); + WorkloadIdentity identity = identity(fixture, fixture.get("displayLabel").getAsString()); + assertEquals(fixture.get("expectedSemanticId").getAsString(), identity.semanticId()); + assertEquals(identity.semanticId(), identity.seriesId()); + assertEquals(requiredDimensions(fixture), identity.dimensions().keySet()); + categories.add(dimension(fixture, "category")); + if (fixture.getAsJsonObject("dimensions").has("visibility")) { + visibility.add(dimension(fixture, "visibility")); + submissionStates.add(dimension(fixture, "submission-state")); + } + } + + assertTrue(categories.containsAll(Set.of("cpu", "normal-text", "input", "textarea"))); + assertTrue(visibility.containsAll(Set.of("visible", "offscreen"))); + assertTrue(submissionStates.contains("unchanged")); + } + + @Test + void alteredLiteralGoldenSemanticIdFailsComparison() { + JsonObject fixture = fixture("cpu-wrapped-paragraph"); + WorkloadIdentity identity = identity(fixture, "Literal golden check"); + String staleGolden = fixture.get("expectedSemanticId").getAsString() + "-stale"; + + assertThrows(AssertionError.class, () -> assertEquals(staleGolden, identity.semanticId())); + } + + @Test + void rejectsEveryOmittedOrUnexpectedDimensionFromCompleteSchemas() { + for (JsonElement element : golden().getAsJsonArray("fixtures")) { + JsonObject fixture = element.getAsJsonObject(); + for (String dimension : fixture.getAsJsonObject("dimensions").keySet()) { + IllegalArgumentException failure = + assertThrows( + IllegalArgumentException.class, + () -> identity(fixture, "Missing " + dimension, Map.of(), Set.of(dimension))); + assertTrue(failure.getMessage().contains(dimension), dimension); + } + } + for (JsonElement element : golden().getAsJsonArray("currentE4CpuCases")) { + WorkloadIdentity current = currentCpuIdentity(element.getAsJsonObject()); + for (Dimension dimension : current.dimensions().keySet()) { + IllegalArgumentException failure = + assertThrows( + IllegalArgumentException.class, + () -> rebuild(current, Set.of(dimension), Map.of())); + assertTrue(failure.getMessage().contains(dimension.key()), dimension.key()); + } + } + + JsonObject normalText = fixture("normal-text-visible"); + IllegalArgumentException unexpected = + assertThrows( + IllegalArgumentException.class, + () -> + identity( + normalText, + "Unexpected control state", + Map.of("caret-state", "none"), + Set.of())); + assertTrue(unexpected.getMessage().contains("unexpected=[caret-state]")); + } + + @Test + void inventoriesEveryCurrentJmhOperationWithOperationSpecificSchemas() { + Set inventoried = new HashSet<>(); + for (JsonElement operation : golden().getAsJsonArray("currentE4CpuOperations")) { + inventoried.add(operation.getAsString()); + } + Set implemented = + jmhMethodsInHierarchy(TextCalculationBenchmark.class, Benchmark.class).stream() + .map(java.lang.reflect.Method::getName) + .collect(Collectors.toSet()); + + assertEquals(9, inventoried.size()); + assertEquals(implemented, inventoried); + assertTrue(WorkloadIdentity.supportedOperations(Category.CPU).containsAll(implemented)); + assertTrue( + WorkloadIdentity.supportedOperations(Category.CPU).contains("measureParameterizedText")); + Set executableCases = new HashSet<>(); + for (JsonElement element : golden().getAsJsonArray("currentE4CpuCases")) { + JsonObject currentCase = element.getAsJsonObject(); + String operation = currentCase.get("operation").getAsString(); + executableCases.add(operation); + WorkloadIdentity identity = currentCpuIdentity(currentCase); + assertEquals( + WorkloadIdentity.requiredDimensions(Category.CPU, operation), + identity.dimensions().keySet(), + operation); + } + assertEquals(implemented, executableCases); + for (String operation : implemented) { + Set schema = WorkloadIdentity.requiredDimensions(Category.CPU, operation); + assertTrue(schema.containsAll(commonCpuDimensions()), operation); + } + assertTrue( + WorkloadIdentity.requiredDimensions(Category.CPU, "measureWrappedParagraph") + .containsAll( + Set.of( + Dimension.MEASUREMENT_OFFSET_X_PX, + Dimension.WRAP_WIDTH_PX, + Dimension.WRAP_WIDTH_POLICY, + Dimension.WRAPPING_POLICY))); + assertTrue( + WorkloadIdentity.requiredDimensions(Category.CPU, "measureLongSingleFont") + .contains(Dimension.CONTENT_REPEAT_COUNT)); + assertTrue( + WorkloadIdentity.requiredDimensions(Category.CPU, "findCaretNearEnd") + .containsAll( + Set.of( + Dimension.CARET_OFFSET_INSET_X_PX, + Dimension.CARET_OFFSET_POLICY, + Dimension.CONTENT_REPEAT_COUNT, + Dimension.LINE_HEIGHT))); + assertTrue( + WorkloadIdentity.requiredDimensions(Category.CPU, "layoutTextDenseInlineContent") + .containsAll( + Set.of( + Dimension.CONTAINER_HEIGHT_PX, + Dimension.CONTAINER_WIDTH_PX, + Dimension.INLINE_LAYOUT_START_Y_PX, + Dimension.DISPLAY, + Dimension.POSITION, + Dimension.WHITE_SPACE, + Dimension.TEXT_ALIGN, + Dimension.OVERFLOW_WRAP, + Dimension.WORD_BREAK, + Dimension.TAB_SIZE, + Dimension.TEXT_NODE_COUNT, + Dimension.COLOR))); + assertTrue( + WorkloadIdentity.requiredDimensions(Category.CPU, "measureParameterizedText") + .containsAll( + Set.of( + Dimension.DECLARED_SOURCE_LINE_COUNT, + Dimension.DECLARED_VISUAL_LINE_COUNT, + Dimension.DEFERRED_SUFFIX_CODE_POINT_COUNT, + Dimension.FALLBACK_TRANSITION_COUNT, + Dimension.LINE_START_KERNING_TRANSITION_COUNT, + Dimension.MEASUREMENT_OFFSET_X_PX, + Dimension.PARAGRAPH_COUNT, + Dimension.SOURCE_CODE_POINT_COUNT, + Dimension.WRAP_WIDTH_PX))); + } + + @Test + void exactCorpusDeclarationsMatchCurrentSourceContentAndDerivedShapes() throws Exception { + JsonObject corpus = golden().getAsJsonObject("currentCorpusText"); + assertEquals(corpus.get("latin-v1").getAsString(), TextWorkloads.LATIN); + assertEquals( + corpus.get("wrapped-paragraph-v1").getAsString(), TextWorkloads.WRAPPED_PARAGRAPH); + assertEquals(corpus.get("mixed-cjk-v1").getAsString(), TextWorkloads.MIXED_CJK); + assertEquals( + corpus.get("supplementary-unicode-v1").getAsString(), + TextWorkloads.SUPPLEMENTARY_UNICODE); + assertEquals(corpus.get("missing-glyphs-v1").getAsString(), TextWorkloads.MISSING_GLYPHS); + + JsonObject longSingleFont = golden().getAsJsonObject("currentLongSingleFont"); + assertEquals("long-single-font-v1", longSingleFont.get("workloadContent").getAsString()); + assertEquals("latin-v1", longSingleFont.get("baseWorkloadContent").getAsString()); + assertEquals(" ", longSingleFont.get("separator").getAsString()); + assertEquals( + longSingleFont.get("repeatCount").getAsInt(), + TextWorkloads.LONG_SINGLE_FONT_REPEAT_COUNT); + assertEquals( + (TextWorkloads.LATIN + " ").repeat(TextWorkloads.LONG_SINGLE_FONT_REPEAT_COUNT), + TextWorkloads.LONG_SINGLE_FONT); + + Set declaredCorpus = new HashSet<>(corpus.keySet()); + declaredCorpus.add(longSingleFont.get("workloadContent").getAsString()); + for (JsonElement element : golden().getAsJsonArray("currentE4CpuCases")) { + assertTrue( + declaredCorpus.contains( + element + .getAsJsonObject() + .getAsJsonObject("dimensions") + .get("workload-content") + .getAsString())); + } + + JsonObject renderingCorpus = golden().getAsJsonObject("currentRenderingCorpus"); + assertEquals( + "alternating-latin-mixed-cjk-v1", + renderingCorpus.get("workloadContent").getAsString()); + assertEquals("latin-v1", renderingCorpus.get("evenSourceWorkloadContent").getAsString()); + assertEquals("mixed-cjk-v1", renderingCorpus.get("oddSourceWorkloadContent").getAsString()); + assertEquals("remove-ascii-spaces", renderingCorpus.get("transform").getAsString()); + assertEquals("even-latin", renderingCorpus.get("alternationStart").getAsString()); + assertEquals( + TextWorkloads.LATIN.replace(" ", ""), + RenderingWorkloadSpecifications.CURRENT.transformedContent(0)); + assertEquals( + TextWorkloads.MIXED_CJK.replace(" ", ""), + RenderingWorkloadSpecifications.CURRENT.transformedContent(1)); + } + + @Test + void declarativeSpecificationsMatchGoldenInventoryAndRuntimeContracts() throws Exception { + WorkloadIdentity cpu = identity(fixture("cpu-wrapped-paragraph"), "CPU source inventory"); + BenchmarkMode benchmarkMode = TextCalculationBenchmark.class.getAnnotation(BenchmarkMode.class); + OutputTimeUnit outputTimeUnit = + TextCalculationBenchmark.class.getAnnotation(OutputTimeUnit.class); + State state = TextCalculationBenchmark.class.getAnnotation(State.class); + Setup setup = + TextCalculationBenchmark.class + .getDeclaredMethod("setUp", BenchmarkParams.class) + .getAnnotation(Setup.class); + assertEquals(List.of(Mode.AverageTime), List.of(benchmarkMode.value())); + assertEquals(TimeUnit.MICROSECONDS, outputTimeUnit.value()); + assertEquals(Scope.Benchmark, state.value()); + assertEquals(Level.Trial, setup.value()); + assertDimension(cpu, Dimension.BENCHMARK_MODE, Mode.AverageTime); + assertDimension(cpu, Dimension.OUTPUT_TIME_UNIT, outputTimeUnit.value()); + assertDimension(cpu, Dimension.STATE_SCOPE, state.value()); + assertDimension(cpu, Dimension.FONT_SIZE_PX, CpuWorkloadSpecifications.FONT_SIZE_PX); + assertDimension(cpu, Dimension.LINE_HEIGHT, CpuWorkloadSpecifications.LINE_HEIGHT); + assertDimension(cpu, Dimension.WRAP_WIDTH_PX, CpuWorkloadSpecifications.WRAP_WIDTH_PX); + assertDimension(cpu, Dimension.MEASUREMENT_OFFSET_X_PX, + CpuWorkloadSpecifications.MEASUREMENT_OFFSET_X_PX); + assertDimension(cpu, Dimension.SETUP_LEVEL, setup.value()); + assertEquals(128, TextWorkloads.LONG_SINGLE_FONT_REPEAT_COUNT); + assertDimension( + currentCpuIdentity(currentCpuCase("measureLongSingleFont")), + Dimension.CONTENT_REPEAT_COUNT, + TextWorkloads.LONG_SINGLE_FONT_REPEAT_COUNT); + List fallbackFontChain = CpuWorkloadSpecifications.FALLBACK_FONT_CHAIN; + assertEquals(2, fallbackFontChain.size()); + assertSame(Font.ROBOTO_REGULAR, fallbackFontChain.get(0)); + assertSame(Font.NOTO_SANS_CJK_SC_REGULAR, fallbackFontChain.get(1)); + List fallbackIdentities = + fallbackFontChain.stream().map(TextStyleSpecification::fontObjectIdentity).toList(); + for (String operation : + List.of("measureMixedCjk", "measureSupplementaryUnicode", "measureMissingGlyphs")) { + assertDimension( + currentCpuIdentity(currentCpuCase(operation)), Dimension.FONT_CHAIN, fallbackIdentities); + } + assertSame(Font.DEFAULT, CpuWorkloadSpecifications.MEASURE_LATIN.orderedFonts().getFirst()); + assertSame( + Font.DEFAULT, + CpuWorkloadSpecifications.MEASURE_WRAPPED_PARAGRAPH.orderedFonts().getFirst()); + assertSame(Font.DEFAULT, CpuWorkloadSpecifications.FIND_CARET_NEAR_BEGINNING.font()); + assertSame(Font.DEFAULT, CpuWorkloadSpecifications.FIND_CARET_NEAR_END.font()); + assertEquals(1.0f, CpuWorkloadSpecifications.FIND_CARET_NEAR_BEGINNING.offsetOrInsetXPx()); + assertEquals(1.0f, CpuWorkloadSpecifications.FIND_CARET_NEAR_END.offsetOrInsetXPx()); + assertEquals( + "fixed", + currentCpuIdentity(currentCpuCase("findCaretNearBeginning")) + .dimensions() + .get(Dimension.CARET_OFFSET_POLICY)); + assertEquals( + "measured-width-minus-inset", + currentCpuIdentity(currentCpuCase("findCaretNearEnd")) + .dimensions() + .get(Dimension.CARET_OFFSET_POLICY)); + WorkloadIdentity layout = currentCpuIdentity(currentCpuCase("layoutTextDenseInlineContent")); + assertDimension( + layout, + Dimension.TEXT_NODE_COUNT, + CpuWorkloadSpecifications.LAYOUT_DENSE_INLINE_CONTENT.textNodeCount()); + assertDimension( + layout, + Dimension.INLINE_LAYOUT_START_Y_PX, + CpuWorkloadSpecifications.LAYOUT_DENSE_INLINE_CONTENT.layoutStartYPx()); + + assertEquals( + CpuWorkloadSpecifications.currentOperations().keySet(), + golden().getAsJsonArray("currentE4CpuOperations").asList().stream() + .map(JsonElement::getAsString) + .collect(Collectors.toSet())); + for (Map.Entry entry : + CpuWorkloadSpecifications.currentOperations().entrySet()) { + assertEquals( + currentCpuIdentity(currentCpuCase(entry.getKey())), + currentCpuIdentity(entry.getValue()), + entry.getKey()); + assertEquals( + currentCpuIdentity(currentCpuCase(entry.getKey())), + CpuWorkloadSpecifications.identity(entry.getValue()), + "producer " + entry.getKey()); + } + assertCpuBenchmarkAnnotationsAndDispatchAreAligned(TextCalculationBenchmark.class, cpu); + + Specification renderingSpecification = RenderingWorkloadSpecifications.CURRENT; + assertEquals(2, renderingSpecification.measurementOrder().size()); + assertSame(Font.ROBOTO_REGULAR, renderingSpecification.prewarmFonts().get(0)); + assertSame(Font.NOTO_SANS_CJK_SC_REGULAR, renderingSpecification.prewarmFonts().get(1)); + assertSame(Font.ROBOTO_REGULAR, renderingSpecification.style().orderedFonts().get(0)); + assertSame(Font.NOTO_SANS_CJK_SC_REGULAR, renderingSpecification.style().orderedFonts().get(1)); + assertSame(FontStretch.NORMAL, renderingSpecification.style().effectiveFontStretch()); + assertEquals(4, renderingSpecification.layoutFonts().size()); + assertSame(Font.ROBOTO_REGULAR, renderingSpecification.layoutFonts().get(0)); + assertSame(Font.ROBOTO_LIGHT, renderingSpecification.layoutFonts().get(1)); + assertSame(Font.ROBOTO_BOLD, renderingSpecification.layoutFonts().get(2)); + assertSame(Font.NOTO_SANS_CJK_SC_REGULAR, renderingSpecification.layoutFonts().get(3)); + for (SceneSpecification scene : renderingSpecification.measurementOrder()) { + assertEquals( + currentRenderingIdentity(currentRenderingCase(scene.name())), + currentRenderingIdentity(renderingSpecification, scene), + scene.name()); + assertEquals( + currentRenderingIdentity(currentRenderingCase(scene.name())), + renderingSpecification.identity(scene), + "producer " + scene.name()); + } + + WorkloadIdentity rendering = currentRenderingIdentity( + renderingSpecification, renderingSpecification.scene("small")); + assertDimension(rendering, Dimension.FRAME_WIDTH_PX, renderingSpecification.window().widthPx()); + assertDimension(rendering, Dimension.FRAME_HEIGHT_PX, renderingSpecification.window().heightPx()); + assertDimension( + rendering, Dimension.INLINE_LAYOUT_START_Y_PX, + renderingSpecification.inlineLayoutStartYPx()); + assertDimension(rendering, Dimension.WARMUP_FRAMES, renderingSpecification.warmupFrames()); + assertDimension(rendering, Dimension.MEASURED_FRAMES, renderingSpecification.measuredFrames()); + assertDimension(rendering, Dimension.FONT_CHAIN, + renderingSpecification.fontExecutionIdentities()); + } + + @Test + void rejectsJmhParamFieldsAndEveryConfigurationAnnotationOverride() { + assertThrows( + IllegalArgumentException.class, + () -> assertJmhMethodContract(ParamDriftFixture.class)); + for (Class fixture : + List.of( + BenchmarkModeOverrideFixture.class, + OutputTimeUnitOverrideFixture.class, + ThreadsOverrideFixture.class, + ForkOverrideFixture.class, + WarmupOverrideFixture.class, + MeasurementOverrideFixture.class)) { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> assertJmhMethodContract(fixture)); + assertTrue(failure.getMessage().contains("Method-level JMH override"), fixture.getName()); + } + + WorkloadIdentity cpu = identity(fixture("cpu-wrapped-paragraph"), "JMH drift baseline"); + IllegalArgumentException stateFailure = + assertThrows( + IllegalArgumentException.class, + () -> assertJmhClassAnnotationContract(StateDriftFixture.class, cpu)); + assertTrue(stateFailure.getMessage().contains("@State")); + IllegalArgumentException forkShapeFailure = + assertThrows( + IllegalArgumentException.class, + () -> assertJmhClassAnnotationContract(ForkJvmArgsDriftFixture.class, cpu)); + assertTrue(forkShapeFailure.getMessage().contains("@Fork")); + + IllegalArgumentException inheritedBenchmarkFailure = + assertThrows( + IllegalArgumentException.class, + () -> assertJmhMethodContract(InheritedBenchmarkFixture.class)); + assertTrue(inheritedBenchmarkFailure.getMessage().contains("Inherited @Benchmark")); + IllegalArgumentException inheritedSetupFailure = + assertThrows( + IllegalArgumentException.class, + () -> assertJmhMethodContract(InheritedSetupFixture.class)); + assertTrue(inheritedSetupFailure.getMessage().contains("Inherited @Setup")); + + IllegalArgumentException inheritedClassOverrideFailure = + assertThrows( + IllegalArgumentException.class, + () -> assertJmhClassAnnotationContract(InheritedWarmupClassOverrideFixture.class, cpu)); + assertTrue(inheritedClassOverrideFailure.getMessage().contains("@Warmup")); + + IllegalArgumentException inheritedUnsupportedClassFailure = + assertThrows( + IllegalArgumentException.class, + () -> + assertJmhClassAnnotationContract( + InheritedUnsupportedClassAnnotationFixture.class, cpu)); + assertTrue( + inheritedUnsupportedClassFailure + .getMessage() + .contains(OperationsPerInvocation.class.getName())); + + java.lang.reflect.Method inheritedOverride = + java.util.Arrays.stream(InheritedMethodOverrideBase.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(Benchmark.class)) + .findFirst() + .orElseThrow(); + IllegalArgumentException inheritedMethodOverrideFailure = + assertThrows( + IllegalArgumentException.class, + () -> + assertEffectiveBenchmarkAnnotations( + InheritedMethodOverrideFixture.class, inheritedOverride, cpu)); + assertTrue(inheritedMethodOverrideFailure.getMessage().contains("effective @Warmup")); + } + + @Test + void jmhCpuGradleSettingsAreParsedAndAlignedOnlyFromActualTaskArguments() throws Exception { + WorkloadIdentity cpu = identity(fixture("cpu-wrapped-paragraph"), "CPU task inventory"); + String buildScript = + Files.readString(repositoryRoot().resolve("spinygui.benchmark/build.gradle.kts")); + List cpuArguments = javaExecTaskArguments(buildScript, "jmhCpu", "args"); + assertEquals( + List.of( + "com.spinyowl.spinygui.benchmark.cpu.*", + "-wi", "3", + "-i", "5", + "-w", "500ms", + "-r", "500ms", + "-f", "2", + "-jvmArgsAppend", "--enable-native-access=ALL-UNNAMED", + "-prof", "gc", + "-rf", "json"), + cpuArguments); + assertEquals( + List.of("--enable-native-access=ALL-UNNAMED"), + javaExecTaskArguments(buildScript, "jmhRendering", "jvmArgs")); + assertEquals( + List.of("com.spinyowl.spinygui.benchmark.cpu.CpuBenchmarkMain"), + javaExecTaskArguments(buildScript, "jmhCpu", "mainClass.set")); + String negativeFixture = + "// tasks.register(\"jmhCpu\") { args(\"-wi\", \"3\") }\n" + + "tasks.register(\"jmhCpu\") {\n" + + " description = \"args(\\\"-wi\\\", \\\"3\\\") --enable-native-access=ALL-UNNAMED\"\n" + + " val unused = listOf(\"-i\", \"5\", \"--enable-native-access=ALL-UNNAMED\")\n" + + " args(\"com.spinyowl.spinygui.benchmark.cpu.*\")\n" + + "}\n" + + "tasks.register(\"jmhRendering\") {" + + " jvmArgs(\"--enable-native-access=ALL-UNNAMED\") }"; + assertEquals( + List.of("com.spinyowl.spinygui.benchmark.cpu.*"), + javaExecTaskArguments(negativeFixture, "jmhCpu", "args")); + assertFalse( + javaExecTaskArguments(negativeFixture, "jmhCpu", "args") + .contains("--enable-native-access=ALL-UNNAMED")); + + assertOptionDimension(cpuArguments, "-wi", cpu, Dimension.WARMUP_ITERATIONS); + assertOptionDimension(cpuArguments, "-w", cpu, Dimension.WARMUP_TIME); + assertOptionDimension(cpuArguments, "-i", cpu, Dimension.MEASUREMENT_ITERATIONS); + assertOptionDimension(cpuArguments, "-r", cpu, Dimension.MEASUREMENT_TIME); + assertOptionDimension(cpuArguments, "-f", cpu, Dimension.FORKS); + assertOptionDimension(cpuArguments, "-prof", cpu, Dimension.PROFILER); + assertEquals("json", option(cpuArguments, "-rf")); + assertEquals("--enable-native-access=ALL-UNNAMED", option(cpuArguments, "-jvmArgsAppend")); + assertDimension(cpu, Dimension.NATIVE_ACCESS, "all-unnamed"); + assertFalse(cpuArguments.contains("-t")); + assertFalse(cpuArguments.contains("-bs")); + assertFalse(cpuArguments.contains("-wbs")); + assertFalse(cpuArguments.contains("-wf")); + assertDimension(cpu, Dimension.THREADS, Defaults.THREADS); + assertDimension(cpu, Dimension.MEASUREMENT_BATCH_SIZE, Defaults.MEASUREMENT_BATCHSIZE); + assertDimension(cpu, Dimension.WARMUP_BATCH_SIZE, Defaults.WARMUP_BATCHSIZE); + assertDimension(cpu, Dimension.WARMUP_FORKS, Defaults.WARMUP_FORKS); + assertEquals( + "service-and-operation-fixtures-created-once-and-reused-through-trial", + cpu.dimensions().get(Dimension.FIXTURE_PREPARATION_POLICY)); + assertEquals( + "current-corpus-in-trial-setup", + cpu.dimensions().get(Dimension.FONT_FIXTURE_POLICY)); + } + + @Test + void declarativeSourceDriftChangesIdentityOrFailsClosed() { + MeasurementSpec wrapped = CpuWorkloadSpecifications.MEASURE_WRAPPED_PARAGRAPH; + WorkloadIdentity wrappedIdentity = currentCpuIdentity(wrapped); + List changedMeasurements = + List.of( + new MeasurementSpec( + wrapped.operation(), + "wrapped-paragraph-v2", + wrapped.text() + " changed", + wrapped.api(), + wrapped.orderedFonts(), + wrapped.fontSizePx(), + wrapped.lineHeight(), + wrapped.measurementOffsetXPx(), + wrapped.maximumWidthPx(), + wrapped.wordWrap(), + wrapped.contentRepeatCount()), + new MeasurementSpec( + wrapped.operation(), + wrapped.workloadContent(), + wrapped.text(), + wrapped.api(), + List.of(Font.ROBOTO_BOLD), + wrapped.fontSizePx(), + wrapped.lineHeight(), + wrapped.measurementOffsetXPx(), + wrapped.maximumWidthPx(), + wrapped.wordWrap(), + wrapped.contentRepeatCount()), + new MeasurementSpec( + wrapped.operation(), wrapped.workloadContent(), wrapped.text(), wrapped.api(), + wrapped.orderedFonts(), 17, wrapped.lineHeight(), wrapped.measurementOffsetXPx(), + wrapped.maximumWidthPx(), wrapped.wordWrap(), wrapped.contentRepeatCount()), + new MeasurementSpec( + wrapped.operation(), wrapped.workloadContent(), wrapped.text(), wrapped.api(), + wrapped.orderedFonts(), wrapped.fontSizePx(), 1.3f, + wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), wrapped.wordWrap(), + wrapped.contentRepeatCount()), + new MeasurementSpec( + wrapped.operation(), wrapped.workloadContent(), wrapped.text(), wrapped.api(), + wrapped.orderedFonts(), wrapped.fontSizePx(), wrapped.lineHeight(), 0.5f, + wrapped.maximumWidthPx(), wrapped.wordWrap(), wrapped.contentRepeatCount()), + new MeasurementSpec( + wrapped.operation(), wrapped.workloadContent(), wrapped.text(), wrapped.api(), + wrapped.orderedFonts(), wrapped.fontSizePx(), wrapped.lineHeight(), + wrapped.measurementOffsetXPx(), 241f, wrapped.wordWrap(), + wrapped.contentRepeatCount()), + new MeasurementSpec( + wrapped.operation(), wrapped.workloadContent(), wrapped.text(), wrapped.api(), + wrapped.orderedFonts(), wrapped.fontSizePx(), wrapped.lineHeight(), + wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), false, + wrapped.contentRepeatCount())); + for (MeasurementSpec changed : changedMeasurements) { + assertNotEquals(wrappedIdentity.semanticId(), currentCpuIdentity(changed).semanticId()); + } + assertThrows( + IllegalArgumentException.class, + () -> + new MeasurementSpec( + wrapped.operation(), wrapped.workloadContent(), wrapped.text() + " drift", + wrapped.api(), wrapped.orderedFonts(), wrapped.fontSizePx(), wrapped.lineHeight(), + wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), wrapped.wordWrap(), + wrapped.contentRepeatCount())); + + MeasurementSpec fallback = CpuWorkloadSpecifications.MEASURE_MIXED_CJK; + MeasurementSpec reversedFallback = + new MeasurementSpec( + fallback.operation(), fallback.workloadContent(), fallback.text(), fallback.api(), + List.of(Font.NOTO_SANS_CJK_SC_REGULAR, Font.ROBOTO_REGULAR), + fallback.fontSizePx(), fallback.lineHeight(), fallback.measurementOffsetXPx(), + fallback.maximumWidthPx(), fallback.wordWrap(), fallback.contentRepeatCount()); + assertNotEquals( + currentCpuIdentity(fallback).semanticId(), + currentCpuIdentity(reversedFallback).semanticId()); + + Specification rendering = RenderingWorkloadSpecifications.CURRENT; + WorkloadIdentity renderingIdentity = + currentRenderingIdentity(rendering, rendering.scene("small")); + Specification movedContainer = + copySpecification( + rendering, + new ContainerSpecification( + rendering.container().positionXPx() + 1, + rendering.container().positionYPx(), + rendering.container().widthPx(), + rendering.container().heightPx()), + rendering.style(), + rendering.prewarmFonts(), + rendering.sourceContent(), + rendering.workloadContent(), + rendering.measurementOrder()); + assertNotEquals( + renderingIdentity.semanticId(), + currentRenderingIdentity(movedContainer, movedContainer.scene("small")).semanticId()); + + TextStyleSpecification largerStyle = + copyStyle(rendering.style(), rendering.style().orderedFonts(), + rendering.style().fontSizePx() + 1, rendering.style().effectiveFontStretch()); + Specification largerText = + copySpecification( + rendering, + rendering.container(), + largerStyle, + rendering.prewarmFonts(), + rendering.sourceContent(), + rendering.workloadContent(), + rendering.measurementOrder()); + assertNotEquals( + renderingIdentity.semanticId(), + currentRenderingIdentity(largerText, largerText.scene("small")).semanticId()); + + List reversedFonts = List.of(Font.NOTO_SANS_CJK_SC_REGULAR, Font.ROBOTO_REGULAR); + TextStyleSpecification reversedStyle = + copyStyle( + rendering.style(), + reversedFonts, + rendering.style().fontSizePx(), + rendering.style().effectiveFontStretch()); + Specification reversedFontPaths = + copySpecification( + rendering, + rendering.container(), + reversedStyle, + reversedFonts, + rendering.sourceContent(), + rendering.workloadContent(), + rendering.measurementOrder()); + assertNotEquals( + renderingIdentity.semanticId(), + currentRenderingIdentity(reversedFontPaths, reversedFontPaths.scene("small")).semanticId()); + + List changedCompanion = + List.of(new SceneSpecification("small", 100), new SceneSpecification("large", 1_001)); + Specification companionDrift = + copySpecification( + rendering, + rendering.container(), + rendering.style(), + rendering.prewarmFonts(), + rendering.sourceContent(), + rendering.workloadContent(), + changedCompanion); + assertNotEquals( + renderingIdentity.semanticId(), + currentRenderingIdentity(companionDrift, companionDrift.scene("small")).semanticId()); + + ClearSpecification clear = rendering.clear(); + List clearDrifts = + List.of( + new ClearSpecification( + clear.enabled(), clear.red() + 0.25f, clear.green(), clear.blue(), clear.alpha(), + clear.mask()), + new ClearSpecification( + clear.enabled(), clear.red(), clear.green() + 0.25f, clear.blue(), clear.alpha(), + clear.mask()), + new ClearSpecification( + clear.enabled(), clear.red(), clear.green(), clear.blue() + 0.25f, clear.alpha(), + clear.mask()), + new ClearSpecification( + clear.enabled(), clear.red(), clear.green(), clear.blue(), clear.alpha() - 0.25f, + clear.mask()), + new ClearSpecification( + clear.enabled(), clear.red(), clear.green(), clear.blue(), clear.alpha(), + clear.mask() + 1)); + for (ClearSpecification clearDrift : clearDrifts) { + assertIdentityChangesOrFailsClosed( + renderingIdentity, + () -> + currentRenderingIdentity( + copySpecificationWithBehavior( + rendering, clearDrift, rendering.structuralValidation()), + rendering.scene("small"))); + } + + StructuralValidationSpecification validation = rendering.structuralValidation(); + List validationDrifts = + List.of( + new StructuralValidationSpecification( + validation.enabled(), + validation.sceneName(), + "wrong-command-contract"), + new StructuralValidationSpecification( + validation.enabled(), "large", validation.commandContract())); + for (StructuralValidationSpecification validationDrift : validationDrifts) { + assertIdentityChangesOrFailsClosed( + renderingIdentity, + () -> + currentRenderingIdentity( + copySpecificationWithBehavior(rendering, clear, validationDrift), + rendering.scene("small"))); + } + + assertThrows( + IllegalArgumentException.class, + () -> + copySpecification( + rendering, + rendering.container(), + rendering.style(), + List.of(Font.ROBOTO_BOLD, Font.NOTO_SANS_CJK_SC_REGULAR), + rendering.sourceContent(), + rendering.workloadContent(), + rendering.measurementOrder())); + assertThrows( + IllegalArgumentException.class, + () -> + copySpecification( + rendering, + rendering.container(), + rendering.style(), + rendering.prewarmFonts(), + rendering.sourceContent(), + rendering.workloadContent(), + List.of(rendering.scene("large"), rendering.scene("small")))); + assertThrows( + IllegalArgumentException.class, + () -> + copySpecification( + rendering, + rendering.container(), + rendering.style(), + rendering.prewarmFonts(), + List.of(TextWorkloads.LATIN + " drift", TextWorkloads.MIXED_CJK), + rendering.workloadContent(), + rendering.measurementOrder())); + assertThrows( + IllegalArgumentException.class, + () -> + new CpuWorkloadSpecifications.TrialSetupSpec( + CpuWorkloadSpecifications.TRIAL_SETUP.roundToPixel(), + List.of( + CpuWorkloadSpecifications.MEASURE_WRAPPED_PARAGRAPH, + CpuWorkloadSpecifications.MEASURE_LATIN, + CpuWorkloadSpecifications.MEASURE_MIXED_CJK, + CpuWorkloadSpecifications.MEASURE_SUPPLEMENTARY_UNICODE, + CpuWorkloadSpecifications.MEASURE_MISSING_GLYPHS), + CpuWorkloadSpecifications.FIND_CARET_NEAR_END, + CpuWorkloadSpecifications.LAYOUT_DENSE_INLINE_CONTENT, + CpuWorkloadSpecifications.TRIAL_SETUP.fixturePreparationPolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontFixturePolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontResolver())); + } + + @Test + void trialPreparedCaretAndInlineStateCannotDriftFromIdentitySpecifications() { + var inline = CpuWorkloadSpecifications.LAYOUT_DENSE_INLINE_CONTENT; + WorkloadIdentity inlineIdentity = currentCpuIdentity(inline); + List geometryAndContentDrifts = + List.of( + copyInline(inline, "wrapped-paragraph-v2", inline.text() + " changed", + inline.textNodeCount(), inline.containerWidthPx(), inline.containerHeightPx(), + inline.layoutStartYPx(), inline.style()), + copyInline(inline, inline.workloadContent(), inline.text(), inline.textNodeCount() + 1, + inline.containerWidthPx(), inline.containerHeightPx(), inline.layoutStartYPx(), + inline.style()), + copyInline(inline, inline.workloadContent(), inline.text(), inline.textNodeCount(), + inline.containerWidthPx() + 1, inline.containerHeightPx(), inline.layoutStartYPx(), + inline.style()), + copyInline(inline, inline.workloadContent(), inline.text(), inline.textNodeCount(), + inline.containerWidthPx(), inline.containerHeightPx() + 1, inline.layoutStartYPx(), + inline.style()), + copyInline(inline, inline.workloadContent(), inline.text(), inline.textNodeCount(), + inline.containerWidthPx(), inline.containerHeightPx(), inline.layoutStartYPx() + 1, + inline.style())); + for (var drift : geometryAndContentDrifts) { + assertNotEquals(inlineIdentity.semanticId(), currentCpuIdentity(drift).semanticId()); + assertTrialPreparationRejects(CpuWorkloadSpecifications.FIND_CARET_NEAR_END, drift); + } + assertThrows( + IllegalArgumentException.class, + () -> + copyInline( + inline, + inline.workloadContent(), + inline.text() + " undeclared drift", + inline.textNodeCount(), + inline.containerWidthPx(), + inline.containerHeightPx(), + inline.layoutStartYPx(), + inline.style())); + + for (InlineStyleDrift drift : InlineStyleDrift.values()) { + if (drift == InlineStyleDrift.EFFECTIVE_STRETCH) { + assertThrows(IllegalArgumentException.class, () -> driftInlineStyle(inline.style(), drift)); + continue; + } + var changed = + copyInline( + inline, + inline.workloadContent(), + inline.text(), + inline.textNodeCount(), + inline.containerWidthPx(), + inline.containerHeightPx(), + inline.layoutStartYPx(), + driftInlineStyle(inline.style(), drift)); + assertIdentityChangesOrFailsClosed(inlineIdentity, () -> currentCpuIdentity(changed)); + assertTrialPreparationRejects(CpuWorkloadSpecifications.FIND_CARET_NEAR_END, changed); + } + + var caret = CpuWorkloadSpecifications.FIND_CARET_NEAR_END; + List caretDrifts = + List.of( + new CpuWorkloadSpecifications.CaretSpec( + caret.operation(), "long-single-font-v2", caret.text() + " changed", caret.font(), + caret.fontSizePx(), caret.contentRepeatCount(), caret.offsetPolicy(), + caret.offsetOrInsetXPx(), caret.preparationLineHeight()), + new CpuWorkloadSpecifications.CaretSpec( + caret.operation(), caret.workloadContent(), caret.text(), Font.ROBOTO_BOLD, + caret.fontSizePx(), caret.contentRepeatCount(), caret.offsetPolicy(), + caret.offsetOrInsetXPx(), caret.preparationLineHeight()), + new CpuWorkloadSpecifications.CaretSpec( + caret.operation(), caret.workloadContent(), caret.text(), caret.font(), 17, + caret.contentRepeatCount(), caret.offsetPolicy(), caret.offsetOrInsetXPx(), + caret.preparationLineHeight()), + new CpuWorkloadSpecifications.CaretSpec( + caret.operation(), caret.workloadContent(), caret.text(), caret.font(), + caret.fontSizePx(), caret.contentRepeatCount() + 1, caret.offsetPolicy(), + caret.offsetOrInsetXPx(), caret.preparationLineHeight()), + new CpuWorkloadSpecifications.CaretSpec( + caret.operation(), caret.workloadContent(), caret.text(), caret.font(), + caret.fontSizePx(), caret.contentRepeatCount(), caret.offsetPolicy(), + caret.offsetOrInsetXPx() + 1, caret.preparationLineHeight()), + new CpuWorkloadSpecifications.CaretSpec( + caret.operation(), caret.workloadContent(), caret.text(), caret.font(), + caret.fontSizePx(), caret.contentRepeatCount(), caret.offsetPolicy(), + caret.offsetOrInsetXPx(), caret.preparationLineHeight() + 0.1f)); + for (var drift : caretDrifts) { + assertNotEquals(currentCpuIdentity(caret).semanticId(), currentCpuIdentity(drift).semanticId()); + assertTrialPreparationRejects(drift, inline); + } + } + + @Test + void trialFontWarmupsRequireEveryCompleteExecutionSpecificationAndSetupPolicy() { + MeasurementSpec wrapped = CpuWorkloadSpecifications.MEASURE_WRAPPED_PARAGRAPH; + Font changedPath = + new Font( + wrapped.orderedFonts().getFirst().fontFamily(), + wrapped.orderedFonts().getFirst().style(), + wrapped.orderedFonts().getFirst().stretch(), + wrapped.orderedFonts().getFirst().weight(), + "fonts/Drifted-Roboto-Regular.ttf"); + Font changedTrait = + new Font( + wrapped.orderedFonts().getFirst().fontFamily(), + FontStyle.ITALIC, + wrapped.orderedFonts().getFirst().stretch(), + wrapped.orderedFonts().getFirst().weight(), + wrapped.orderedFonts().getFirst().path()); + List sameNameDrifts = + List.of( + new MeasurementSpec( + wrapped.operation(), "wrapped-paragraph-v2", wrapped.text() + " changed", + wrapped.api(), wrapped.orderedFonts(), wrapped.fontSizePx(), wrapped.lineHeight(), + wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), wrapped.wordWrap(), + wrapped.contentRepeatCount()), + new MeasurementSpec( + wrapped.operation(), wrapped.workloadContent(), wrapped.text(), + CpuWorkloadSpecifications.MeasurementApi.DIRECT_FONT, wrapped.orderedFonts(), + wrapped.fontSizePx(), wrapped.lineHeight(), wrapped.measurementOffsetXPx(), + wrapped.maximumWidthPx(), wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, List.of(changedPath), wrapped.fontSizePx(), + wrapped.lineHeight(), wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), + wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, List.of(changedTrait), wrapped.fontSizePx(), + wrapped.lineHeight(), wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), + wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, wrapped.orderedFonts(), wrapped.fontSizePx() + 1, + wrapped.lineHeight(), wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), + wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, wrapped.orderedFonts(), wrapped.fontSizePx(), + wrapped.lineHeight() + 0.1f, wrapped.measurementOffsetXPx(), + wrapped.maximumWidthPx(), wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, wrapped.orderedFonts(), wrapped.fontSizePx(), + wrapped.lineHeight(), wrapped.measurementOffsetXPx() + 0.5f, + wrapped.maximumWidthPx(), wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, wrapped.orderedFonts(), wrapped.fontSizePx(), + wrapped.lineHeight(), wrapped.measurementOffsetXPx(), + wrapped.maximumWidthPx() + 1, wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, wrapped.orderedFonts(), wrapped.fontSizePx(), + wrapped.lineHeight(), wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), + !wrapped.wordWrap(), wrapped.contentRepeatCount()), + copyMeasurement(wrapped, wrapped.orderedFonts(), wrapped.fontSizePx(), + wrapped.lineHeight(), wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), + wrapped.wordWrap(), wrapped.contentRepeatCount() + 1)); + for (MeasurementSpec drift : sameNameDrifts) { + assertTrialWarmupRejected(1, drift); + } + + MeasurementSpec renamed = + new MeasurementSpec( + wrapped.operation() + "Drift", wrapped.workloadContent(), wrapped.text(), wrapped.api(), + wrapped.orderedFonts(), wrapped.fontSizePx(), wrapped.lineHeight(), + wrapped.measurementOffsetXPx(), wrapped.maximumWidthPx(), wrapped.wordWrap(), + wrapped.contentRepeatCount()); + assertTrialWarmupRejected(1, renamed); + + MeasurementSpec fallback = CpuWorkloadSpecifications.MEASURE_MIXED_CJK; + MeasurementSpec reversedFallback = + new MeasurementSpec( + fallback.operation(), fallback.workloadContent(), fallback.text(), fallback.api(), + List.of(Font.NOTO_SANS_CJK_SC_REGULAR, Font.ROBOTO_REGULAR), fallback.fontSizePx(), + fallback.lineHeight(), fallback.measurementOffsetXPx(), fallback.maximumWidthPx(), + fallback.wordWrap(), fallback.contentRepeatCount()); + assertTrialWarmupRejected(2, reversedFallback); + + assertTrialSetupRejected( + true, + CpuWorkloadSpecifications.TRIAL_SETUP.fixturePreparationPolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontFixturePolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontResolver()); + assertTrialSetupRejected( + CpuWorkloadSpecifications.TRIAL_SETUP.roundToPixel(), + "parameterized-fixtures-created-in-trial-setup", + CpuWorkloadSpecifications.TRIAL_SETUP.fontFixturePolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontResolver()); + assertTrialSetupRejected( + CpuWorkloadSpecifications.TRIAL_SETUP.roundToPixel(), + CpuWorkloadSpecifications.TRIAL_SETUP.fixturePreparationPolicy(), + "parameterized-corpus-in-trial-setup", + CpuWorkloadSpecifications.TRIAL_SETUP.fontResolver()); + assertTrialSetupRejected( + CpuWorkloadSpecifications.TRIAL_SETUP.roundToPixel(), + CpuWorkloadSpecifications.TRIAL_SETUP.fixturePreparationPolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontFixturePolicy(), + "changed-resolver"); + } + + @Test + void rendererPrewarmCorpusAndEveryFontChainUseExactIdentityStructure() { + Specification rendering = RenderingWorkloadSpecifications.CURRENT; + WorkloadIdentity baseline = currentRenderingIdentity(rendering, rendering.scene("small")); + assertDimension( + baseline, + Dimension.PREWARM_WORKLOAD_CONTENT, + rendering.prewarmWorkloadContent()); + assertThrows( + IllegalArgumentException.class, + () -> copySpecificationWithPrewarm( + rendering, + rendering.prewarmText() + " drift", + rendering.prewarmWorkloadContent())); + Specification changedPrewarm = + copySpecificationWithPrewarm( + rendering, + rendering.prewarmText() + " changed", + "mixed-cjk-remove-ascii-spaces-v2"); + assertNotEquals( + baseline.semanticId(), + currentRenderingIdentity(changedPrewarm, changedPrewarm.scene("small")).semanticId()); + + for (JsonElement element : golden().getAsJsonArray("fixtures")) { + JsonObject fixture = element.getAsJsonObject(); + String category = dimension(fixture, "category"); + String fontChain = dimension(fixture, "font-chain"); + for (String item : fontChain.split(",", -1)) { + String exactFont = item; + if (!"cpu".equals(category)) { + assertTrue(item.startsWith("prewarm=") || item.startsWith("layout="), item); + exactFont = item.substring(item.indexOf('=') + 1); + } + assertEquals(5, exactFont.split("\\|", -1).length, item); + } + if (!"cpu".equals(category)) { + assertTrue(fontChain.contains("prewarm="), fixture.get("name").getAsString()); + assertTrue(fontChain.contains("layout="), fixture.get("name").getAsString()); + } + } + } + + @Test + void dimensionSchemasCanonicalizeEquivalentRuntimeValuesWithoutForkingSeries() { + JsonObject normal = fixture("normal-text-visible"); + WorkloadIdentity first = identity(normal, "Canonical input"); + WorkloadIdentity second = + identity( + normal, + "Equivalent input", + Map.of( + "category", "Normal_Text", + "font-chain", + List.of(dimension(normal, "font-chain").split(",", -1)), + "font-size-px", "16.00", + "submission-state", WorkloadIdentity.SubmissionState.CHANGED, + "visibility", WorkloadIdentity.Visibility.VISIBLE, + "warmup-frames", new BigDecimal("60.0")), + Set.of()); + assertEquals(first, second); + assertEquals(first.semanticId(), second.semanticId()); + + WorkloadIdentity cpu = identity(fixture("cpu-wrapped-paragraph"), "Duration input"); + WorkloadIdentity equivalentCpu = + identity( + fixture("cpu-wrapped-paragraph"), + "Equivalent duration input", + Map.of( + "measurement-time", "PT0.5S", + "warmup-time", "500 ms", + "measurement-offset-x-px", "0.000"), + Set.of()); + assertEquals(cpu.semanticId(), equivalentCpu.semanticId()); + + WorkloadIdentity escapedFirst = + identity( + normal, + "Escaped font", + Map.of( + "font-chain", + "prewarm=Cafe\u0301 / 100%|normal|normal|regular|fonts/Cafe.ttf," + + "layout=Cafe\u0301 / 100%|normal|normal|regular|fonts/Cafe.ttf"), + Set.of()); + WorkloadIdentity escapedSecond = + identity( + normal, + "Equivalent escaped font", + Map.of( + "font-chain", + List.of( + "prewarm=Caf\u00e9 / 100%|normal|normal|regular|fonts/Cafe.ttf", + "layout=Caf\u00e9 / 100%|normal|normal|regular|fonts/Cafe.ttf")), + Set.of()); + assertEquals(escapedFirst.semanticId(), escapedSecond.semanticId()); + assertTrue( + escapedFirst.semanticId().contains("font-chain=prewarm%3DCaf%C3%A9%20%2F%20100%25")); + + for (JsonElement element : golden().getAsJsonArray("fixtures")) { + JsonObject fixture = element.getAsJsonObject(); + WorkloadIdentity canonical = identity(fixture, "Canonical runtime types"); + WorkloadIdentity equivalent = + identity( + fixture, + "Equivalent runtime types", + equivalentRepresentations(fixture), + Set.of()); + assertEquals(canonical.semanticId(), equivalent.semanticId(), fixture.get("name").getAsString()); + } + } + + @Test + void rejectsInvalidDimensionTypesValuesAndFixedSchemaAliases() { + JsonObject normal = fixture("normal-text-visible"); + assertThrows( + IllegalArgumentException.class, + () -> identity(normal, "Invalid visibility", Map.of("visibility", "nearby"), Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> identity(normal, "Invalid count", Map.of("warmup-frames", 1.5), Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> identity(normal, "Invalid font size", Map.of("font-size-px", true), Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> identity(normal, "Family-only font chain", Map.of("font-chain", "Roboto"), Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> + identity( + normal, + "Unstaged renderer font chain", + Map.of( + "font-chain", + "Roboto|normal|normal|regular|fonts/Roboto-Regular.ttf"), + Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> + identity( + fixture("cpu-wrapped-paragraph"), + "Staged CPU font chain", + Map.of( + "font-chain", + "prewarm=Roboto|normal|normal|regular|fonts/Roboto-Regular.ttf," + + "layout=Roboto|normal|normal|regular|fonts/Roboto-Regular.ttf"), + Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> identity(normal, "Invalid fixed path", Map.of("renderer-path", "input-text"), Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> identity(normal, "Invalid workload", Map.of(), Set.of(), "renderer-control")); + for (String observedOutput : + Set.of( + "command-count", + "cull-count", + "line-count", + "resolved-glyph-count", + "resolved-run-count", + "text-fragment-count")) { + assertThrows( + IllegalArgumentException.class, + () -> WorkloadIdentity.Dimension.fromKey(observedOutput), + observedOutput); + } + assertThrows( + IllegalArgumentException.class, + () -> + identity( + fixture("cpu-parameterized-zero-width"), + "Negative width", + Map.of("wrap-width-px", -0.01), + Set.of())); + assertThrows( + IllegalArgumentException.class, + () -> + identity( + fixture("cpu-parameterized-zero-width"), + "Non-finite offset", + Map.of("measurement-offset-x-px", Double.NaN), + Set.of())); + assertEquals( + "0", + identity(fixture("cpu-parameterized-zero-width"), "Zero width") + .dimensions() + .get(Dimension.WRAP_WIDTH_PX)); + assertEquals( + "character-wrap", + identity(fixture("cpu-parameterized-character-wrap"), "Character wrapping") + .dimensions() + .get(Dimension.WRAPPING_POLICY)); + assertThrows( + IllegalArgumentException.class, + () -> + identity( + fixture("cpu-parameterized-character-wrap"), + "Finite width cannot be unwrapped", + Map.of("wrapping-policy", "unwrapped"), + Set.of())); + assertEquals( + "unwrapped", + currentCpuIdentity(currentCpuCase("measureLatin")) + .dimensions() + .get(Dimension.WRAPPING_POLICY)); + assertThrows( + IllegalArgumentException.class, + () -> WorkloadIdentity.requiredDimensions(Category.INPUT, "render-textarea-scenario")); + assertThrows( + IllegalArgumentException.class, + () -> WorkloadIdentity.requiredDimensions(Category.NORMAL_TEXT, "unknown-render-operation")); + } + + @Test + void coversEveryCurrentAndPlannedOperationWithAnAuthoritativeSchema() { + Map> covered = + Map.of( + Category.CPU, new HashSet<>(), + Category.NORMAL_TEXT, new HashSet<>(), + Category.INPUT, new HashSet<>(), + Category.TEXTAREA, new HashSet<>()); + for (JsonElement element : golden().getAsJsonArray("currentE4CpuCases")) { + covered.get(Category.CPU).add(element.getAsJsonObject().get("operation").getAsString()); + } + for (JsonElement element : golden().getAsJsonArray("fixtures")) { + JsonObject fixture = element.getAsJsonObject(); + Category category = Category.fromCanonicalValue(dimension(fixture, "category")); + covered.get(category).add(dimension(fixture, "operation")); + } + Set expectedCpu = + new HashSet<>( + Set.of( + "findCaretNearBeginning", + "findCaretNearEnd", + "layoutTextDenseInlineContent", + "measureLatin", + "measureLongSingleFont", + "measureMissingGlyphs", + "measureMixedCjk", + "measureSupplementaryUnicode", + "measureWrappedParagraph")); + expectedCpu.add("measureParameterizedText"); + Map> expected = + Map.of( + Category.CPU, expectedCpu, + Category.NORMAL_TEXT, Set.of("render-text", "render-normal-text-scenario"), + Category.INPUT, Set.of("render-input-scenario"), + Category.TEXTAREA, Set.of("render-textarea-scenario")); + for (Category category : Category.values()) { + assertEquals( + expected.get(category), + WorkloadIdentity.supportedOperations(category), + category.toString()); + assertEquals(expected.get(category), covered.get(category), category.toString()); + } + + Set input = + WorkloadIdentity.requiredDimensions(Category.INPUT, "render-input-scenario"); + Set textarea = + WorkloadIdentity.requiredDimensions(Category.TEXTAREA, "render-textarea-scenario"); + assertTrue( + input.containsAll( + Set.of( + Dimension.CARET_INDEX_UTF16, + Dimension.SELECTION_START_UTF16, + Dimension.SELECTION_END_UTF16, + Dimension.SOURCE_UTF16_LENGTH))); + assertTrue( + textarea.containsAll( + Set.of( + Dimension.DECLARED_SOURCE_LINE_COUNT, + Dimension.DECLARED_VISUAL_LINE_COUNT, + Dimension.DEFERRED_SUFFIX_CODE_POINT_COUNT, + Dimension.PARAGRAPH_COUNT, + Dimension.WRAP_WIDTH_PX))); + assertTrue( + WorkloadIdentity.requiredDimensions( + Category.NORMAL_TEXT, "render-normal-text-scenario") + .containsAll(Set.of(Dimension.OFFSCREEN_RATIO, Dimension.OFFSCREEN_EXTENT_PX))); + Set currentRendering = + WorkloadIdentity.requiredDimensions(Category.NORMAL_TEXT, "render-text"); + assertTrue( + currentRendering.containsAll( + Set.of( + Dimension.COMPANION_SCENE_SHAPE, + Dimension.COMPANION_TEXT_NODE_COUNT, + Dimension.MEASUREMENT_ORDER, + Dimension.MEASUREMENT_ORDER_INDEX, + Dimension.NATIVE_ACCESS, + Dimension.SCENE_PAIR_COUNT, + Dimension.WARMUP_ORDER))); + for (Category category : Set.of(Category.NORMAL_TEXT, Category.INPUT, Category.TEXTAREA)) { + for (String operation : WorkloadIdentity.supportedOperations(category)) { + assertTrue( + WorkloadIdentity.requiredDimensions(category, operation) + .containsAll( + Set.of( + Dimension.MEASUREMENT_ORDER, + Dimension.MEASUREMENT_ORDER_INDEX, + Dimension.NATIVE_ACCESS))); + } + } + } + + @Test + void everyAcceptedDeclaredInputChangeChangesSemanticIdentity() { + Set provenSensitive = new HashSet<>(); + Set independentlyVariable = new HashSet<>(); + for (JsonElement element : golden().getAsJsonArray("fixtures")) { + JsonObject fixture = element.getAsJsonObject(); + WorkloadIdentity baseline = identity(fixture, "Input sensitivity baseline"); + for (Map.Entry entry : fixture.getAsJsonObject("dimensions").entrySet()) { + Dimension dimension = Dimension.fromKey(entry.getKey()); + Object replacement = sensitivityValue(dimension, entry.getValue()); + if (replacement == null) { + continue; + } + independentlyVariable.add(dimension); + try { + WorkloadIdentity changed = + identity( + fixture, + "Changed " + dimension.key(), + Map.of(dimension.key(), replacement), + Set.of()); + assertNotEquals(baseline.semanticId(), changed.semanticId(), dimension.key()); + provenSensitive.add(dimension); + } catch (IllegalArgumentException ignoredInvalidCombination) { + // Another fixture can prove a constrained dimension with a valid independent change. + } + } + } + + assertTrue(provenSensitive.containsAll(independentlyVariable), provenSensitive.toString()); + assertTrue( + provenSensitive.containsAll( + Set.of( + Dimension.CARET_INDEX_UTF16, + Dimension.DECLARED_SOURCE_LINE_COUNT, + Dimension.DECLARED_VISUAL_LINE_COUNT, + Dimension.DEFERRED_SUFFIX_CODE_POINT_COUNT, + Dimension.MEASUREMENT_OFFSET_X_PX, + Dimension.OFFSCREEN_EXTENT_PX, + Dimension.OFFSCREEN_RATIO, + Dimension.PARAGRAPH_COUNT, + Dimension.SELECTION_END_UTF16, + Dimension.SELECTION_START_UTF16)), + provenSensitive.toString()); + } + + @Test + void behaviorChangesCannotShareIdentityWhileLabelsRemainPresentationOnly() { + JsonObject normal = fixture("normal-text-visible"); + WorkloadIdentity baseline = identity(normal, "Baseline label"); + WorkloadIdentity relabeled = identity(normal, "Presentation-only label"); + assertEquals(baseline, relabeled); + assertEquals(baseline.hashCode(), relabeled.hashCode()); + + for (Map change : + List.of( + Map.of("measured-frames", 201), + Map.of("container-position-x-px", 21), + Map.of("font-size-px", 17), + Map.of("synchronization", "none"), + Map.of("submission-state", "unchanged"), + Map.of("visibility", "offscreen", "clip-state", "outside"))) { + WorkloadIdentity changed = identity(normal, "Changed behavior", change, Set.of()); + assertNotEquals(baseline.semanticId(), changed.semanticId(), change.toString()); + } + + for (Map.Entry> currentChange : + Map.of( + "findCaretNearBeginning", Map.of(Dimension.CARET_OFFSET_X_PX, 2), + "findCaretNearEnd", Map.of(Dimension.CARET_OFFSET_INSET_X_PX, 2), + "layoutTextDenseInlineContent", Map.of(Dimension.INLINE_LAYOUT_START_Y_PX, 1), + "measureLongSingleFont", Map.of(Dimension.CONTENT_REPEAT_COUNT, 129)) + .entrySet()) { + WorkloadIdentity current = currentCpuIdentity(currentCpuCase(currentChange.getKey())); + WorkloadIdentity changed = replaceDimensions(current, currentChange.getValue()); + assertNotEquals(current.semanticId(), changed.semanticId(), currentChange.getKey()); + } + + WorkloadIdentity currentSmall = currentRenderingIdentity(currentRenderingCase("small")); + WorkloadIdentity differentCompanion = + replaceDimensions(currentSmall, Map.of(Dimension.COMPANION_TEXT_NODE_COUNT, 2_000)); + assertNotEquals(currentSmall.semanticId(), differentCompanion.semanticId()); + WorkloadIdentity largeMeasuredFirst = + replaceDimensions( + currentSmall, + Map.of( + Dimension.MEASUREMENT_ORDER, "large-then-small", + Dimension.MEASUREMENT_ORDER_INDEX, 2)); + assertNotEquals(currentSmall.semanticId(), largeMeasuredFirst.semanticId()); + } + + @Test + void displayLabelAndObservedOutputsDoNotDefineIdentityOrSeries() { + JsonObject regression = golden().getAsJsonObject("observedOutputRegression"); + JsonObject fixture = fixture(regression.get("fixtureName").getAsString()); + WorkloadIdentity before = identity(fixture, "Before optimization"); + WorkloadIdentity after = identity(fixture, "After optimization"); + + assertNotEquals(regression.getAsJsonObject("before"), regression.getAsJsonObject("after")); + assertEquals(fixture.get("expectedSemanticId").getAsString(), before.semanticId()); + assertEquals(fixture.get("expectedSemanticId").getAsString(), after.semanticId()); + assertEquals(before.semanticId(), after.semanticId()); + assertEquals(before.seriesId(), after.seriesId()); + assertEquals(before, after); + + ComparabilityMetadata.Environment environment = + new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.RENDERING, + "Vendor", "25", "OS", "1", "x64", "CPU", + "GL vendor", "renderer", "driver", "4.6"); + ComparabilityMetadata.Implementation implementation = + new ComparabilityMetadata.Implementation("impl", "build", "commit"); + var scene = RenderingWorkloadSpecifications.CURRENT.scene("small"); + ComparabilityMetadata beforeMetadata = + RenderingWorkloadSpecifications.CURRENT.comparability( + scene, + ComparabilityMetadata.EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, + environment, + implementation); + ComparabilityMetadata afterMetadata = + RenderingWorkloadSpecifications.CURRENT.comparability( + scene, + ComparabilityMetadata.EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, + environment, + implementation); + assertEquals(beforeMetadata.semanticId(), afterMetadata.semanticId()); + assertEquals(beforeMetadata.fingerprints(), afterMetadata.fingerprints()); + } + + @Test + void legacyE4SeriesRemainAddressableAndCannotCollideWithE5() { + JsonArray legacyFixtures = golden().getAsJsonArray("legacyFixtures"); + for (JsonElement element : legacyFixtures) { + JsonObject fixture = element.getAsJsonObject(); + WorkloadIdentity legacy = + WorkloadIdentity.legacyE4( + fixture.get("historicalSeriesKey").getAsString(), + fixture.get("displayLabel").getAsString()); + assertEquals(fixture.get("expectedSemanticId").getAsString(), legacy.semanticId()); + assertEquals(WorkloadIdentity.Namespace.E4_LEGACY, legacy.namespace()); + assertFalse(legacy.semanticId().contains(":e5:")); + } + + WorkloadIdentity legacy = + WorkloadIdentity.legacyE4( + "rendering:fragments=100;nodes=100;code-points=3800;glyphs=3800;runs=300", + "E4 scene"); + WorkloadIdentity e5 = identity(fixture("normal-text-visible"), "E5 scene"); + assertNotEquals(legacy.semanticId(), e5.semanticId()); + assertNotEquals(legacy, e5); + } + + private static Set commonCpuDimensions() { + return Set.of( + Dimension.API, + Dimension.BENCHMARK_CLASS, + Dimension.BENCHMARK_MODE, + Dimension.CATEGORY, + Dimension.FONT_CHAIN, + Dimension.FONT_FIXTURE_POLICY, + Dimension.FONT_RESOLVER, + Dimension.FONT_SIZE_PX, + Dimension.FONT_STRETCH, + Dimension.FONT_STYLE, + Dimension.FONT_WEIGHT, + Dimension.FIXTURE_PREPARATION_POLICY, + Dimension.FORKS, + Dimension.HARNESS, + Dimension.MEASUREMENT_BATCH_SIZE, + Dimension.MEASUREMENT_ITERATIONS, + Dimension.MEASUREMENT_TIME, + Dimension.NATIVE_ACCESS, + Dimension.OPERATION, + Dimension.OUTPUT_TIME_UNIT, + Dimension.PROFILER, + Dimension.ROUND_TO_PIXEL, + Dimension.SETUP_LEVEL, + Dimension.STATE_SCOPE, + Dimension.THREADS, + Dimension.WARMUP_BATCH_SIZE, + Dimension.WARMUP_FORKS, + Dimension.WARMUP_ITERATIONS, + Dimension.WARMUP_TIME, + Dimension.WORKLOAD_CONTENT, + Dimension.WORKLOAD_VERSION); + } + + private static void assertCpuBenchmarkAnnotationsAndDispatchAreAligned( + Class benchmarkClass, WorkloadIdentity identity) { + assertJmhClassAnnotationContract(benchmarkClass, identity); + assertJmhMethodContract(benchmarkClass); + Map benchmarkMethods = + jmhMethodsInHierarchy(benchmarkClass, Benchmark.class).stream() + .collect(Collectors.toMap(java.lang.reflect.Method::getName, method -> method)); + assertEquals(CpuWorkloadSpecifications.currentOperations().keySet(), benchmarkMethods.keySet()); + benchmarkMethods.forEach( + (methodName, method) -> { + assertEffectiveBenchmarkAnnotations(benchmarkClass, method, identity); + assertEquals(0, method.getParameterCount(), methodName); + var dispatch = + CpuWorkloadSpecifications.dispatchForBenchmark( + benchmarkClass.getName() + "." + methodName); + assertSame(CpuWorkloadSpecifications.currentOperations().get(methodName), dispatch.specification()); + OperationSpec specification = dispatch.specification(); + if (specification instanceof CpuWorkloadSpecifications.MeasurementSpec) { + assertEquals("TextMetrics", method.getReturnType().getSimpleName(), methodName); + assertSame(specification, dispatch.measurement()); + } else if (specification instanceof CpuWorkloadSpecifications.CaretSpec) { + assertEquals("TextCaretMetrics", method.getReturnType().getSimpleName(), methodName); + assertSame(specification, dispatch.caret()); + } else { + assertEquals(float.class, method.getReturnType(), methodName); + assertSame(specification, dispatch.inlineLayout()); + } + }); + assertThrows( + IllegalArgumentException.class, + () -> CpuWorkloadSpecifications.dispatchForBenchmark(benchmarkClass.getName() + ".unknown")); + assertThrows( + IllegalArgumentException.class, + () -> + new CpuWorkloadSpecifications.BenchmarkDispatch( + "measureLatin", CpuWorkloadSpecifications.MEASURE_MIXED_CJK)); + } + + private static void assertJmhClassAnnotationContract( + Class benchmarkClass, WorkloadIdentity identity) { + Set> supported = + Set.of( + BenchmarkMode.class, + OutputTimeUnit.class, + State.class, + Threads.class, + Fork.class, + Warmup.class, + Measurement.class); + Set> effective = + supported.stream() + .filter(type -> closestClassAnnotation(benchmarkClass, type) != null) + .collect(Collectors.toSet()); + Set> unsupported = + jmhClassAnnotationsInHierarchy(benchmarkClass).stream() + .filter(type -> !supported.contains(type)) + .collect(Collectors.toSet()); + if (!effective.equals(supported) || !unsupported.isEmpty()) { + throw new IllegalArgumentException( + "Unsupported or incomplete effective class-level JMH annotation contract: effective=" + + effective + + "; unsupported=" + + unsupported); + } + + BenchmarkMode benchmarkMode = closestClassAnnotation(benchmarkClass, BenchmarkMode.class); + if (benchmarkMode.value().length != 1) { + throw new IllegalArgumentException( + "@BenchmarkMode requires exactly one identity-aligned mode"); + } + requireAnnotationDimension( + identity, Dimension.BENCHMARK_MODE, benchmarkMode.value()[0], "@BenchmarkMode"); + requireAnnotationDimension( + identity, + Dimension.OUTPUT_TIME_UNIT, + closestClassAnnotation(benchmarkClass, OutputTimeUnit.class).value(), + "@OutputTimeUnit"); + requireAnnotationDimension( + identity, + Dimension.STATE_SCOPE, + closestClassAnnotation(benchmarkClass, State.class).value(), + "@State"); + requireAnnotationDimension( + identity, + Dimension.THREADS, + closestClassAnnotation(benchmarkClass, Threads.class).value(), + "@Threads"); + + Fork fork = closestClassAnnotation(benchmarkClass, Fork.class); + requireAnnotationDimension(identity, Dimension.FORKS, fork.value(), "@Fork value"); + requireAnnotationDimension( + identity, Dimension.WARMUP_FORKS, fork.warmups(), "@Fork warmups"); + requireSupportedForkJvmShape(fork, "@Fork"); + + Warmup warmup = closestClassAnnotation(benchmarkClass, Warmup.class); + requireAnnotationDimension( + identity, Dimension.WARMUP_ITERATIONS, warmup.iterations(), "@Warmup iterations"); + requireAnnotationDimension( + identity, + Dimension.WARMUP_TIME, + annotationDuration(warmup.time(), warmup.timeUnit()), + "@Warmup time"); + requireAnnotationDimension( + identity, Dimension.WARMUP_BATCH_SIZE, warmup.batchSize(), "@Warmup batchSize"); + + Measurement measurement = closestClassAnnotation(benchmarkClass, Measurement.class); + requireAnnotationDimension( + identity, + Dimension.MEASUREMENT_ITERATIONS, + measurement.iterations(), + "@Measurement iterations"); + requireAnnotationDimension( + identity, + Dimension.MEASUREMENT_TIME, + annotationDuration(measurement.time(), measurement.timeUnit()), + "@Measurement time"); + requireAnnotationDimension( + identity, + Dimension.MEASUREMENT_BATCH_SIZE, + measurement.batchSize(), + "@Measurement batchSize"); + } + + private static void assertEffectiveBenchmarkAnnotations( + Class benchmarkClass, + java.lang.reflect.Method benchmarkMethod, + WorkloadIdentity identity) { + BenchmarkMode benchmarkMode = + closestMethodAnnotation(benchmarkMethod, benchmarkClass, BenchmarkMode.class); + if (benchmarkMode.value().length != 1) { + throw new IllegalArgumentException( + "Effective @BenchmarkMode requires exactly one identity-aligned mode"); + } + requireAnnotationDimension( + identity, Dimension.BENCHMARK_MODE, benchmarkMode.value()[0], "effective @BenchmarkMode"); + requireAnnotationDimension( + identity, + Dimension.OUTPUT_TIME_UNIT, + closestMethodAnnotation(benchmarkMethod, benchmarkClass, OutputTimeUnit.class).value(), + "effective @OutputTimeUnit"); + requireAnnotationDimension( + identity, + Dimension.THREADS, + closestMethodAnnotation(benchmarkMethod, benchmarkClass, Threads.class).value(), + "effective @Threads"); + + Fork fork = closestMethodAnnotation(benchmarkMethod, benchmarkClass, Fork.class); + requireAnnotationDimension(identity, Dimension.FORKS, fork.value(), "effective @Fork value"); + requireAnnotationDimension( + identity, Dimension.WARMUP_FORKS, fork.warmups(), "effective @Fork warmups"); + requireSupportedForkJvmShape(fork, "effective @Fork"); + + Warmup warmup = closestMethodAnnotation(benchmarkMethod, benchmarkClass, Warmup.class); + requireAnnotationDimension( + identity, + Dimension.WARMUP_ITERATIONS, + warmup.iterations(), + "effective @Warmup iterations"); + requireAnnotationDimension( + identity, + Dimension.WARMUP_TIME, + annotationDuration(warmup.time(), warmup.timeUnit()), + "effective @Warmup time"); + requireAnnotationDimension( + identity, + Dimension.WARMUP_BATCH_SIZE, + warmup.batchSize(), + "effective @Warmup batchSize"); + + Measurement measurement = + closestMethodAnnotation(benchmarkMethod, benchmarkClass, Measurement.class); + requireAnnotationDimension( + identity, + Dimension.MEASUREMENT_ITERATIONS, + measurement.iterations(), + "effective @Measurement iterations"); + requireAnnotationDimension( + identity, + Dimension.MEASUREMENT_TIME, + annotationDuration(measurement.time(), measurement.timeUnit()), + "effective @Measurement time"); + requireAnnotationDimension( + identity, + Dimension.MEASUREMENT_BATCH_SIZE, + measurement.batchSize(), + "effective @Measurement batchSize"); + } + + private static T closestMethodAnnotation( + java.lang.reflect.Method method, Class benchmarkClass, Class annotationType) { + T methodAnnotation = method.getDeclaredAnnotation(annotationType); + return methodAnnotation != null + ? methodAnnotation + : closestClassAnnotation(benchmarkClass, annotationType); + } + + private static T closestClassAnnotation( + Class benchmarkClass, Class annotationType) { + for (Class current = benchmarkClass; current != null; current = current.getSuperclass()) { + T annotation = current.getDeclaredAnnotation(annotationType); + if (annotation != null) { + return annotation; + } + } + return null; + } + + private static Set> jmhClassAnnotationsInHierarchy( + Class benchmarkClass) { + Set> annotations = new HashSet<>(); + for (Class current = benchmarkClass; current != null; current = current.getSuperclass()) { + java.util.Arrays.stream(current.getDeclaredAnnotations()) + .map(Annotation::annotationType) + .filter(type -> type.getPackageName().equals("org.openjdk.jmh.annotations")) + .forEach(annotations::add); + } + return Set.copyOf(annotations); + } + + private static void requireSupportedForkJvmShape(Fork fork, String source) { + if (!fork.jvm().equals(Fork.BLANK_ARGS) + || java.util.Arrays.stream(fork.jvmArgs()) + .anyMatch(argument -> !argument.equals(Fork.BLANK_ARGS)) + || java.util.Arrays.stream(fork.jvmArgsPrepend()) + .anyMatch(argument -> !argument.equals(Fork.BLANK_ARGS)) + || java.util.Arrays.stream(fork.jvmArgsAppend()) + .anyMatch(argument -> !argument.equals(Fork.BLANK_ARGS))) { + throw new IllegalArgumentException( + source + " JVM settings are unsupported; jmhCpu native access is aligned separately"); + } + } + + private static Duration annotationDuration(int time, TimeUnit timeUnit) { + return Duration.ofNanos(timeUnit.toNanos(time)); + } + + private static void requireAnnotationDimension( + WorkloadIdentity identity, Dimension dimension, Object value, String annotation) { + String canonical = dimension.canonicalValue(value); + if (!canonical.equals(identity.dimensions().get(dimension))) { + throw new IllegalArgumentException( + annotation + " is not aligned with identity dimension " + dimension.key()); + } + } + + private static void assertJmhMethodContract(Class benchmarkClass) { + for (Class current = benchmarkClass; current != null; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (field.isAnnotationPresent(Param.class)) { + throw new IllegalArgumentException("JMH @Param field is not identity-aligned: " + field); + } + } + for (java.lang.reflect.Method method : current.getDeclaredMethods()) { + boolean benchmark = method.isAnnotationPresent(Benchmark.class); + boolean setup = method.isAnnotationPresent(Setup.class); + if (current != benchmarkClass && benchmark) { + throw new IllegalArgumentException( + "Inherited @Benchmark method is not identity/dispatch-aligned: " + method); + } + if (current != benchmarkClass && setup) { + throw new IllegalArgumentException( + "Inherited @Setup method is not identity/fixture-aligned: " + method); + } + + Set> jmhAnnotations = + java.util.Arrays.stream(method.getDeclaredAnnotations()) + .map(Annotation::annotationType) + .filter(type -> type.getPackageName().equals("org.openjdk.jmh.annotations")) + .collect(Collectors.toSet()); + Set> expected; + if (benchmark) { + expected = Set.of(Benchmark.class); + } else if (setup) { + expected = Set.of(Setup.class); + if (method.getAnnotation(Setup.class).value() != Level.Trial) { + throw new IllegalArgumentException("Only @Setup(Level.Trial) is identity-aligned"); + } + } else { + expected = Set.of(); + } + if (!jmhAnnotations.equals(expected)) { + throw new IllegalArgumentException( + "Method-level JMH override is not identity-aligned: " + + method + + " " + + jmhAnnotations); + } + } + } + } + + private static List jmhMethodsInHierarchy( + Class benchmarkClass, Class annotationType) { + List methods = new ArrayList<>(); + for (Class current = benchmarkClass; current != null; current = current.getSuperclass()) { + java.util.Arrays.stream(current.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(annotationType)) + .forEach(methods::add); + } + return List.copyOf(methods); + } + + private static Set requiredDimensions(JsonObject fixture) { + Category category = Category.fromCanonicalValue(dimension(fixture, "category")); + return WorkloadIdentity.requiredDimensions(category, dimension(fixture, "operation")); + } + + private static WorkloadIdentity currentCpuIdentity(JsonObject currentCase) { + JsonObject base = fixture("cpu-wrapped-paragraph").getAsJsonObject("dimensions"); + JsonObject overrides = currentCase.getAsJsonObject("dimensions"); + String operation = currentCase.get("operation").getAsString(); + WorkloadIdentity.Builder builder = WorkloadIdentity.e5("cpu-text"); + for (Dimension dimension : WorkloadIdentity.requiredDimensions(Category.CPU, operation)) { + JsonElement value = + overrides.has(dimension.key()) + ? overrides.get(dimension.key()) + : base.get(dimension.key()); + if (value == null) { + throw new IllegalArgumentException( + "Missing current CPU inventory value for " + operation + ": " + dimension.key()); + } + builder.dimension(dimension, value(value)); + } + return builder.build("Current E4 " + operation); + } + + private static WorkloadIdentity currentCpuIdentity(OperationSpec specification) { + JsonObject base = fixture("cpu-wrapped-paragraph").getAsJsonObject("dimensions"); + Map declared = specification.identityDimensions(); + WorkloadIdentity.Builder builder = WorkloadIdentity.e5("cpu-text"); + for (Dimension dimension : + WorkloadIdentity.requiredDimensions(Category.CPU, specification.operation())) { + Object value = declared.get(dimension); + if (value == null) { + JsonElement common = base.get(dimension.key()); + if (common == null) { + throw new IllegalArgumentException( + "Missing CPU setting for " + specification.operation() + ": " + dimension.key()); + } + value = value(common); + } + builder.dimension(dimension, value); + } + return builder.build("Current CPU specification " + specification.operation()); + } + + private static WorkloadIdentity currentRenderingIdentity(JsonObject currentCase) { + Map overrides = new java.util.LinkedHashMap<>(); + currentCase + .getAsJsonObject("dimensions") + .entrySet() + .forEach(entry -> overrides.put(entry.getKey(), value(entry.getValue()))); + return identity( + fixture("normal-text-visible"), + "Current E4 rendering " + currentCase.get("name").getAsString(), + overrides, + Set.of()); + } + + private static WorkloadIdentity currentRenderingIdentity( + Specification specification, SceneSpecification scene) { + Map overrides = new java.util.LinkedHashMap<>(); + specification + .identityDimensions(scene) + .forEach((dimension, value) -> overrides.put(dimension.key(), value)); + return identity( + fixture("normal-text-visible"), + "Current renderer specification " + scene.name(), + overrides, + Set.of()); + } + + private static WorkloadIdentity rebuild( + WorkloadIdentity source, Set omitted, Map additions) { + WorkloadIdentity.Builder builder = WorkloadIdentity.e5(source.workload()); + source.dimensions().forEach( + (dimension, value) -> { + if (!omitted.contains(dimension)) { + builder.dimension(dimension, value); + } + }); + additions.forEach(builder::dimension); + return builder.build("Rebuilt identity"); + } + + private static WorkloadIdentity replaceDimensions( + WorkloadIdentity source, Map replacements) { + WorkloadIdentity.Builder builder = WorkloadIdentity.e5(source.workload()); + source + .dimensions() + .forEach( + (dimension, value) -> + builder.dimension( + dimension, + replacements.containsKey(dimension) ? replacements.get(dimension) : value)); + return builder.build("Changed declared inputs"); + } + + private static TextStyleSpecification copyStyle( + TextStyleSpecification source, + List orderedFonts, + float fontSizePx, + FontStretch effectiveStretch) { + return new TextStyleSpecification( + orderedFonts, + declaredResolvedFonts(orderedFonts), + source.fontStyle(), + source.fontWeight(), + effectiveStretch, + fontSizePx, + source.lineHeight(), + source.color(), + source.display(), + source.position(), + source.whiteSpace(), + source.textAlign(), + source.overflowWrap(), + source.wordBreak(), + source.tabSize()); + } + + private static TextStyleSpecification driftInlineStyle( + TextStyleSpecification source, InlineStyleDrift drift) { + return new TextStyleSpecification( + drift == InlineStyleDrift.ORDERED_FONTS + ? List.of(Font.NOTO_SANS_CJK_SC_REGULAR) + : source.orderedFonts(), + drift == InlineStyleDrift.ORDERED_FONTS + ? List.of(Font.NOTO_SANS_CJK_SC_REGULAR) + : source.resolvedFonts(), + drift == InlineStyleDrift.FONT_STYLE ? FontStyle.ITALIC : source.fontStyle(), + drift == InlineStyleDrift.FONT_WEIGHT ? FontWeight.BOLD : source.fontWeight(), + drift == InlineStyleDrift.EFFECTIVE_STRETCH + ? FontStretch.CONDENSED + : source.effectiveFontStretch(), + drift == InlineStyleDrift.FONT_SIZE ? source.fontSizePx() + 1 : source.fontSizePx(), + drift == InlineStyleDrift.LINE_HEIGHT ? source.lineHeight() + 0.1f : source.lineHeight(), + drift == InlineStyleDrift.COLOR ? Color.WHITE : source.color(), + drift == InlineStyleDrift.DISPLAY ? Display.INLINE : source.display(), + drift == InlineStyleDrift.POSITION ? Position.ABSOLUTE : source.position(), + drift == InlineStyleDrift.WHITE_SPACE ? WhiteSpace.PRE : source.whiteSpace(), + drift == InlineStyleDrift.TEXT_ALIGN ? TextAlign.RIGHT : source.textAlign(), + drift == InlineStyleDrift.OVERFLOW_WRAP ? OverflowWrap.BREAK_WORD : source.overflowWrap(), + drift == InlineStyleDrift.WORD_BREAK ? WordBreak.BREAK_ALL : source.wordBreak(), + drift == InlineStyleDrift.TAB_SIZE ? source.tabSize() + 1 : source.tabSize()); + } + + private static List declaredResolvedFonts(List orderedFonts) { + List builtIns = + List.of( + Font.ROBOTO_REGULAR, + Font.ROBOTO_LIGHT, + Font.ROBOTO_BOLD, + Font.NOTO_SANS_CJK_SC_REGULAR); + return orderedFonts.stream() + .flatMap( + requested -> + builtIns.stream() + .filter(font -> font.fontFamily().equalsIgnoreCase(requested.fontFamily()))) + .toList(); + } + + private static CpuWorkloadSpecifications.InlineLayoutSpec copyInline( + CpuWorkloadSpecifications.InlineLayoutSpec source, + String workloadContent, + String text, + int textNodeCount, + float containerWidthPx, + float containerHeightPx, + float layoutStartYPx, + TextStyleSpecification style) { + return new CpuWorkloadSpecifications.InlineLayoutSpec( + source.operation(), + workloadContent, + text, + textNodeCount, + containerWidthPx, + containerHeightPx, + layoutStartYPx, + style); + } + + private static MeasurementSpec copyMeasurement( + MeasurementSpec source, + List orderedFonts, + float fontSizePx, + float lineHeight, + float measurementOffsetXPx, + Float maximumWidthPx, + boolean wordWrap, + int contentRepeatCount) { + return new MeasurementSpec( + source.operation(), + source.workloadContent(), + source.text(), + source.api(), + orderedFonts, + fontSizePx, + lineHeight, + measurementOffsetXPx, + maximumWidthPx, + wordWrap, + contentRepeatCount); + } + + private static void assertTrialWarmupRejected(int index, MeasurementSpec changed) { + List warmups = + new ArrayList<>(CpuWorkloadSpecifications.TRIAL_SETUP.fontWarmups()); + warmups.set(index, changed); + assertThrows( + IllegalArgumentException.class, + () -> + new CpuWorkloadSpecifications.TrialSetupSpec( + CpuWorkloadSpecifications.TRIAL_SETUP.roundToPixel(), + warmups, + CpuWorkloadSpecifications.TRIAL_SETUP.preparedEndCaret(), + CpuWorkloadSpecifications.TRIAL_SETUP.preparedInlineLayout(), + CpuWorkloadSpecifications.TRIAL_SETUP.fixturePreparationPolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontFixturePolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontResolver())); + } + + private static void assertTrialSetupRejected( + boolean roundToPixel, + String fixturePreparationPolicy, + String fontFixturePolicy, + String fontResolver) { + assertThrows( + IllegalArgumentException.class, + () -> + new CpuWorkloadSpecifications.TrialSetupSpec( + roundToPixel, + CpuWorkloadSpecifications.TRIAL_SETUP.fontWarmups(), + CpuWorkloadSpecifications.TRIAL_SETUP.preparedEndCaret(), + CpuWorkloadSpecifications.TRIAL_SETUP.preparedInlineLayout(), + fixturePreparationPolicy, + fontFixturePolicy, + fontResolver)); + } + + private static void assertTrialPreparationRejects( + CpuWorkloadSpecifications.CaretSpec caret, + CpuWorkloadSpecifications.InlineLayoutSpec inline) { + assertThrows( + IllegalArgumentException.class, + () -> + new CpuWorkloadSpecifications.TrialSetupSpec( + CpuWorkloadSpecifications.TRIAL_SETUP.roundToPixel(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontWarmups(), + caret, + inline, + CpuWorkloadSpecifications.TRIAL_SETUP.fixturePreparationPolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontFixturePolicy(), + CpuWorkloadSpecifications.TRIAL_SETUP.fontResolver())); + } + + private static void assertIdentityChangesOrFailsClosed( + WorkloadIdentity baseline, Supplier changedIdentity) { + try { + assertNotEquals(baseline.semanticId(), changedIdentity.get().semanticId()); + } catch (IllegalArgumentException expectedFailClosed) { + assertTrue(expectedFailClosed.getMessage() != null); + } + } + + private static Specification copySpecification( + Specification source, + ContainerSpecification container, + TextStyleSpecification style, + List prewarmFonts, + List sourceContent, + String workloadContent, + List measurementOrder) { + return new Specification( + source.window(), + container, + style, + prewarmFonts, + source.prewarmText(), + source.prewarmWorkloadContent(), + sourceContent, + workloadContent, + source.contentTransform(), + measurementOrder, + source.inlineLayoutStartYPx(), + source.warmupFrames(), + source.measuredFrames(), + source.roundToPixel(), + source.clear(), + source.synchronizeWithGlFinish(), + source.structuralValidation()); + } + + private static Specification copySpecificationWithPrewarm( + Specification source, String prewarmText, String prewarmWorkloadContent) { + return new Specification( + source.window(), + source.container(), + source.style(), + source.prewarmFonts(), + prewarmText, + prewarmWorkloadContent, + source.sourceContent(), + source.workloadContent(), + source.contentTransform(), + source.measurementOrder(), + source.inlineLayoutStartYPx(), + source.warmupFrames(), + source.measuredFrames(), + source.roundToPixel(), + source.clear(), + source.synchronizeWithGlFinish(), + source.structuralValidation()); + } + + private static Specification copySpecificationWithBehavior( + Specification source, + ClearSpecification clear, + StructuralValidationSpecification validation) { + return new Specification( + source.window(), + source.container(), + source.style(), + source.prewarmFonts(), + source.prewarmText(), + source.prewarmWorkloadContent(), + source.sourceContent(), + source.workloadContent(), + source.contentTransform(), + source.measurementOrder(), + source.inlineLayoutStartYPx(), + source.warmupFrames(), + source.measuredFrames(), + source.roundToPixel(), + clear, + source.synchronizeWithGlFinish(), + validation); + } + + private static JsonObject currentCpuCase(String operation) { + for (JsonElement element : golden().getAsJsonArray("currentE4CpuCases")) { + JsonObject currentCase = element.getAsJsonObject(); + if (currentCase.get("operation").getAsString().equals(operation)) { + return currentCase; + } + } + throw new IllegalArgumentException("Missing current CPU case: " + operation); + } + + private static JsonObject currentRenderingCase(String name) { + for (JsonElement element : golden().getAsJsonArray("currentE4RenderingCases")) { + JsonObject currentCase = element.getAsJsonObject(); + if (currentCase.get("name").getAsString().equals(name)) { + return currentCase; + } + } + throw new IllegalArgumentException("Missing current rendering case: " + name); + } + + private static List javaExecTaskArguments( + String source, String taskName, String invocationName) { + List tokens = kotlinTokens(source); + int bodyStart = -1; + for (int index = 0; index + 10 < tokens.size(); index++) { + if (tokens.get(index).isIdentifier("tasks") + && tokens.get(index + 1).isSymbol(".") + && tokens.get(index + 2).isIdentifier("register") + && tokens.get(index + 3).isSymbol("<") + && tokens.get(index + 4).isIdentifier("JavaExec") + && tokens.get(index + 5).isSymbol(">") + && tokens.get(index + 6).isSymbol("(") + && tokens.get(index + 7).isString(taskName) + && tokens.get(index + 8).isSymbol(")") + && tokens.get(index + 9).isSymbol("{")) { + bodyStart = index + 9; + break; + } + } + if (bodyStart < 0) { + throw new IllegalArgumentException("Unable to parse JavaExec task: " + taskName); + } + + List arguments = new ArrayList<>(); + String[] invocationPath = invocationName.split("\\.", -1); + if (invocationPath.length < 1 || invocationPath.length > 2) { + throw new IllegalArgumentException("Unsupported Kotlin invocation path: " + invocationName); + } + int invocationCount = 0; + int braceDepth = 1; + for (int index = bodyStart + 1; index < tokens.size() && braceDepth > 0; index++) { + KotlinToken token = tokens.get(index); + if (token.isSymbol("{")) { + braceDepth++; + continue; + } + if (token.isSymbol("}")) { + braceDepth--; + continue; + } + int openingParenthesis = -1; + if (braceDepth == 1 && invocationPath.length == 1 + && token.isIdentifier(invocationPath[0]) + && index + 1 < tokens.size() + && tokens.get(index + 1).isSymbol("(") + && (index == 0 || !tokens.get(index - 1).isSymbol("."))) { + openingParenthesis = index + 1; + } else if (braceDepth == 1 && invocationPath.length == 2 + && token.isIdentifier(invocationPath[0]) + && index + 3 < tokens.size() + && tokens.get(index + 1).isSymbol(".") + && tokens.get(index + 2).isIdentifier(invocationPath[1]) + && tokens.get(index + 3).isSymbol("(")) { + openingParenthesis = index + 3; + } + if (openingParenthesis < 0) { + continue; + } + invocationCount++; + int parenthesisDepth = 1; + for (index = openingParenthesis + 1; + index < tokens.size() && parenthesisDepth > 0; + index++) { + KotlinToken argument = tokens.get(index); + if (argument.isSymbol("(")) { + parenthesisDepth++; + } else if (argument.isSymbol(")")) { + parenthesisDepth--; + } else if (parenthesisDepth == 1 && argument.type() == KotlinTokenType.STRING) { + arguments.add(argument.value()); + } else if (parenthesisDepth == 1 && !argument.isSymbol(",")) { + throw new IllegalArgumentException( + taskName + "." + invocationName + " must use literal string arguments"); + } + } + index--; + } + if (invocationCount != 1) { + throw new IllegalArgumentException( + taskName + " must declare exactly one direct " + invocationName + "(...) call"); + } + return List.copyOf(arguments); + } + + private static List kotlinTokens(String source) { + List tokens = new ArrayList<>(); + for (int index = 0; index < source.length(); ) { + char current = source.charAt(index); + if (Character.isWhitespace(current)) { + index++; + } else if (current == '/' && index + 1 < source.length() + && source.charAt(index + 1) == '/') { + index += 2; + while (index < source.length() && source.charAt(index) != '\n') { + index++; + } + } else if (current == '/' && index + 1 < source.length() + && source.charAt(index + 1) == '*') { + int end = source.indexOf("*/", index + 2); + if (end < 0) { + throw new IllegalArgumentException("Unclosed Kotlin block comment"); + } + index = end + 2; + } else if (current == '"') { + StringBuilder value = new StringBuilder(); + index++; + boolean closed = false; + while (index < source.length()) { + char character = source.charAt(index++); + if (character == '"') { + closed = true; + break; + } + if (character == '\\') { + if (index >= source.length()) { + throw new IllegalArgumentException("Unclosed Kotlin string escape"); + } + char escaped = source.charAt(index++); + value.append( + switch (escaped) { + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case '"' -> '"'; + case '\\' -> '\\'; + default -> escaped; + }); + } else { + value.append(character); + } + } + if (!closed) { + throw new IllegalArgumentException("Unclosed Kotlin string"); + } + tokens.add(new KotlinToken(KotlinTokenType.STRING, value.toString())); + } else if (Character.isJavaIdentifierStart(current)) { + int start = index++; + while (index < source.length() && Character.isJavaIdentifierPart(source.charAt(index))) { + index++; + } + tokens.add(new KotlinToken(KotlinTokenType.IDENTIFIER, source.substring(start, index))); + } else { + tokens.add(new KotlinToken(KotlinTokenType.SYMBOL, Character.toString(current))); + index++; + } + } + return List.copyOf(tokens); + } + + private static String option(List arguments, String name) { + int index = arguments.indexOf(name); + if (index < 0 || index + 1 >= arguments.size()) { + throw new IllegalArgumentException("Missing JMH option value: " + name); + } + if (arguments.lastIndexOf(name) != index) { + throw new IllegalArgumentException("Duplicate JMH option: " + name); + } + return arguments.get(index + 1); + } + + private static void assertOptionDimension( + List arguments, + String option, + WorkloadIdentity identity, + Dimension dimension) { + assertDimension(identity, dimension, option(arguments, option)); + } + + private static Map equivalentRepresentations(JsonObject fixture) { + Map equivalents = new java.util.LinkedHashMap<>(); + for (Map.Entry entry : fixture.getAsJsonObject("dimensions").entrySet()) { + Dimension dimension = Dimension.fromKey(entry.getKey()); + JsonPrimitive primitive = entry.getValue().getAsJsonPrimitive(); + Object equivalent; + if (primitive.isNumber()) { + BigDecimal number = primitive.getAsBigDecimal(); + equivalent = number.toPlainString() + (number.scale() <= 0 ? ".0" : "0"); + } else if (primitive.isBoolean()) { + equivalent = primitive.getAsBoolean() ? "TRUE" : "FALSE"; + } else { + String current = primitive.getAsString(); + equivalent = + switch (dimension) { + case BENCHMARK_CLASS, OPERATION, PREWARM_WORKLOAD_CONTENT, WORKLOAD_CONTENT -> current; + case CATEGORY -> + Category.valueOf( + current.toUpperCase(java.util.Locale.ROOT).replace('-', '_')); + case FONT_CHAIN -> List.of(current.split(",", -1)); + case MEASUREMENT_TIME, WARMUP_TIME -> Duration.ofMillis(500); + case OUTPUT_TIME_UNIT -> TimeUnit.valueOf(current.toUpperCase(java.util.Locale.ROOT)); + case STATE_SCOPE -> + switch (current) { + case "benchmark" -> Scope.Benchmark; + case "thread" -> Scope.Thread; + case "group" -> Scope.Group; + default -> throw new IllegalArgumentException("Unknown state scope: " + current); + }; + case SUBMISSION_STATE -> + WorkloadIdentity.SubmissionState.valueOf( + current.toUpperCase(java.util.Locale.ROOT).replace('-', '_')); + case VISIBILITY -> + WorkloadIdentity.Visibility.valueOf( + current.toUpperCase(java.util.Locale.ROOT).replace('-', '_')); + default -> current.toUpperCase(java.util.Locale.ROOT).replace('-', '_'); + }; + } + equivalents.put(entry.getKey(), equivalent); + } + return equivalents; + } + + private static Object sensitivityValue(Dimension dimension, JsonElement currentElement) { + JsonPrimitive current = currentElement.getAsJsonPrimitive(); + if (current.isNumber()) { + if (dimension == Dimension.MEASUREMENT_ORDER_INDEX + || dimension == Dimension.SCENE_PAIR_COUNT) { + return null; + } + BigDecimal number = current.getAsBigDecimal(); + if (dimension == Dimension.OFFSCREEN_RATIO) { + return number.compareTo(BigDecimal.ONE) == 0 ? new BigDecimal("0.5") : BigDecimal.ONE; + } + return number.add(BigDecimal.ONE); + } + if (current.isBoolean()) { + return !current.getAsBoolean(); + } + String value = current.getAsString(); + return switch (dimension) { + case API, + BENCHMARK_CLASS, + CATEGORY, + COMPANION_SCENE_SHAPE, + CONTROL_TYPE, + DISPLAY, + FIXTURE_PREPARATION_POLICY, + FONT_FIXTURE_POLICY, + FONT_RESOLVER, + FONT_STRETCH, + HARNESS, + NATIVE_ACCESS, + OPERATION, + POSITION, + RENDERER_PATH, + SETUP_LEVEL, + MEASUREMENT_ORDER, + WRAP_WIDTH_POLICY -> null; + case BENCHMARK_MODE -> "throughput"; + case CARET_OFFSET_POLICY -> + value.equals("fixed") ? "measured-width-minus-inset" : "fixed"; + case CARET_STATE -> value.equals("visible") ? "hidden" : "visible"; + case CLEAR_POLICY -> value.equals("none") ? "color-stencil-before-sample" : "none"; + case CLIP_STATE -> value.equals("mixed") ? "inside" : "mixed"; + case COLOR -> value.equals("black") ? "white" : "black"; + case CONTENT_ALTERNATION -> value.equals("none") ? "latin-mixed-cjk" : "none"; + case CONTENT_TRANSFORM -> value.equals("none") ? "remove-ascii-spaces" : "none"; + case CONTEXT_VISIBILITY -> value.equals("hidden") ? "visible" : "hidden"; + case CONTROL_STATE -> value.equals("focused") ? "unfocused" : "focused"; + case FONT_CHAIN -> + value + + (value.contains("prewarm=") + ? ",layout=Additional Font|normal|normal|regular|fonts/Additional.ttf" + : ",Additional Font|normal|normal|regular|fonts/Additional.ttf"); + case FONT_STYLE -> value.equals("normal") ? "italic" : "normal"; + case FONT_WEIGHT -> value.equals("bold") ? "regular" : "bold"; + case MEASUREMENT_TIME, WARMUP_TIME -> value.equals("1s") ? "2s" : "1s"; + case OUTPUT_TIME_UNIT -> value.equals("milliseconds") ? "microseconds" : "milliseconds"; + case OVERFLOW_WRAP -> value.equals("normal") ? "break-word" : "normal"; + case PREMEASURE_SEQUENCE -> value.equals("none") ? "per-scene" : "none"; + case PROFILER -> value.equals("gc") ? "none" : "gc"; + case STATE_SCOPE -> value.equals("benchmark") ? "thread" : "benchmark"; + case SUBMISSION_STATE -> value.equals("changed") ? "unchanged" : "changed"; + case SYNCHRONIZATION -> value.equals("gl-finish") ? "none" : "gl-finish"; + case TEXT_ALIGN -> value.equals("left") ? "right" : "left"; + case VALIDATION_POLICY -> + value.equals("none") + ? "small-scene-production-command-recording-before-measurement" + : "none"; + case VISIBILITY -> value.equals("mixed") ? "visible" : "mixed"; + case WHITE_SPACE -> value.equals("normal") ? "pre" : "normal"; + case WORD_BREAK -> value.equals("normal") ? "break-all" : "normal"; + case WARMUP_ORDER -> + value.equals("per-scene") ? "alternating-small-large-starting-small" : "per-scene"; + case PREWARM_WORKLOAD_CONTENT, WORKLOAD_CONTENT -> value + "-changed"; + case WRAPPING_POLICY -> value.equals("normal") ? "soft-wrap" : "normal"; + default -> null; + }; + } + + private static void assertDimension( + WorkloadIdentity identity, Dimension dimension, Object sourceValue) { + assertEquals(dimension.canonicalValue(sourceValue), identity.dimensions().get(dimension)); + } + + private static Path repositoryRoot() { + Path candidate = Path.of("").toAbsolutePath(); + while (candidate != null) { + if (Files.exists(candidate.resolve("spinygui.benchmark/build.gradle.kts"))) { + return candidate; + } + candidate = candidate.getParent(); + } + throw new IllegalStateException("Unable to locate repository root"); + } + + private static WorkloadIdentity identity(JsonObject fixture, String displayLabel) { + return identity(fixture, displayLabel, Map.of(), Set.of()); + } + + private static WorkloadIdentity identity( + JsonObject fixture, + String displayLabel, + Map overrides, + Set omitted) { + return identity( + fixture, + displayLabel, + overrides, + omitted, + fixture.get("workload").getAsString()); + } + + private static WorkloadIdentity identity( + JsonObject fixture, + String displayLabel, + Map overrides, + Set omitted, + String workload) { + WorkloadIdentity.Builder builder = WorkloadIdentity.e5(workload); + JsonObject fixtureDimensions = fixture.getAsJsonObject("dimensions"); + List> entries = new ArrayList<>(fixtureDimensions.entrySet()); + Collections.reverse(entries); + for (Map.Entry entry : entries) { + if (!omitted.contains(entry.getKey())) { + Object value = + overrides.containsKey(entry.getKey()) + ? overrides.get(entry.getKey()) + : value(entry.getValue()); + builder.dimension(Dimension.fromKey(entry.getKey()), value); + } + } + for (Map.Entry override : overrides.entrySet()) { + if (!fixtureDimensions.has(override.getKey())) { + builder.dimension(Dimension.fromKey(override.getKey()), override.getValue()); + } + } + return builder.build(displayLabel); + } + + private static Object value(JsonElement element) { + JsonPrimitive value = element.getAsJsonPrimitive(); + if (value.isBoolean()) { + return value.getAsBoolean(); + } + if (value.isNumber()) { + return value.getAsBigDecimal(); + } + return value.getAsString(); + } + + private static String dimension(JsonObject fixture, String name) { + return fixture.getAsJsonObject("dimensions").get(name).getAsString(); + } + + private static JsonObject fixture(String name) { + for (JsonElement element : golden().getAsJsonArray("fixtures")) { + JsonObject fixture = element.getAsJsonObject(); + if (fixture.get("name").getAsString().equals(name)) { + return fixture; + } + } + throw new IllegalArgumentException("Missing golden fixture: " + name); + } + + private static JsonObject golden() { + try (InputStream stream = + WorkloadIdentityTest.class.getResourceAsStream("workload-identities-v1.json"); + InputStreamReader reader = + new InputStreamReader( + java.util.Objects.requireNonNull(stream, "golden fixture"), + StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader).getAsJsonObject(); + } catch (IOException exception) { + throw new IllegalStateException("Unable to read identity golden fixtures", exception); + } + } + + private static final class ParamDriftFixture { + @Param({"latin", "mixed"}) + private String corpus; + + @Benchmark + public void operation() { + } + } + + private static final class BenchmarkModeOverrideFixture { + @Benchmark + @BenchmarkMode(Mode.Throughput) + public void operation() { + } + } + + private static final class OutputTimeUnitOverrideFixture { + @Benchmark + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public void operation() { + } + } + + private static final class ThreadsOverrideFixture { + @Benchmark + @Threads(2) + public void operation() { + } + } + + private static final class ForkOverrideFixture { + @Benchmark + @Fork(3) + public void operation() { + } + } + + private static final class WarmupOverrideFixture { + @Benchmark + @Warmup(iterations = 4) + public void operation() { + } + } + + private static final class MeasurementOverrideFixture { + @Benchmark + @Measurement(iterations = 6) + public void operation() { + } + } + + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + @State(Scope.Thread) + @Threads(1) + @Fork(value = 2, warmups = 0) + @Warmup(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + @Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + private static final class StateDriftFixture { + } + + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + @State(Scope.Benchmark) + @Threads(1) + @Fork(value = 2, warmups = 0, jvmArgsAppend = "-Xmx1g") + @Warmup(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + @Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + private static final class ForkJvmArgsDriftFixture { + } + + private static class InheritedBenchmarkBase { + @Benchmark + public void inheritedOperation() { + } + } + + private static final class InheritedBenchmarkFixture extends InheritedBenchmarkBase { + } + + private static class InheritedSetupBase { + @Setup(Level.Trial) + public void inheritedSetup() { + } + } + + private static final class InheritedSetupFixture extends InheritedSetupBase { + } + + @Warmup(iterations = 4, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + private static class InheritedWarmupClassOverrideBase { + } + + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + @State(Scope.Benchmark) + @Threads(1) + @Fork(value = 2, warmups = 0) + @Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + private static final class InheritedWarmupClassOverrideFixture + extends InheritedWarmupClassOverrideBase { + } + + @OperationsPerInvocation(2) + private static class InheritedUnsupportedClassAnnotationBase { + } + + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + @State(Scope.Benchmark) + @Threads(1) + @Fork(value = 2, warmups = 0) + @Warmup(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + @Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + private static final class InheritedUnsupportedClassAnnotationFixture + extends InheritedUnsupportedClassAnnotationBase { + } + + private static class InheritedMethodOverrideBase { + @Benchmark + @Warmup(iterations = 4, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + public void inheritedOperation() { + } + } + + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + @State(Scope.Benchmark) + @Threads(1) + @Fork(value = 2, warmups = 0) + @Warmup(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + @Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS, batchSize = 1) + private static final class InheritedMethodOverrideFixture extends InheritedMethodOverrideBase { + } + + private enum KotlinTokenType { + IDENTIFIER, + STRING, + SYMBOL + } + + private enum InlineStyleDrift { + ORDERED_FONTS, + FONT_STYLE, + FONT_WEIGHT, + EFFECTIVE_STRETCH, + FONT_SIZE, + LINE_HEIGHT, + COLOR, + DISPLAY, + POSITION, + WHITE_SPACE, + TEXT_ALIGN, + OVERFLOW_WRAP, + WORD_BREAK, + TAB_SIZE + } + + private record KotlinToken(KotlinTokenType type, String value) { + boolean isIdentifier(String expected) { + return type == KotlinTokenType.IDENTIFIER && value.equals(expected); + } + + boolean isString(String expected) { + return type == KotlinTokenType.STRING && value.equals(expected); + } + + boolean isSymbol(String expected) { + return type == KotlinTokenType.SYMBOL && value.equals(expected); + } + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/LocalImageComparisonPolicyTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/LocalImageComparisonPolicyTest.java new file mode 100644 index 00000000..f6ed7082 --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/LocalImageComparisonPolicyTest.java @@ -0,0 +1,400 @@ +package com.spinyowl.spinygui.benchmark.rendering; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata; +import com.spinyowl.spinygui.benchmark.rendering.LocalImageComparisonPolicy.EnvironmentFingerprint; +import com.spinyowl.spinygui.benchmark.rendering.LocalImageComparisonPolicy.Reference; +import com.spinyowl.spinygui.benchmark.rendering.LocalImageComparisonPolicy.Request; +import com.spinyowl.spinygui.benchmark.rendering.LocalImageComparisonPolicy.Status; +import java.nio.file.Path; +import java.nio.file.Files; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalImageComparisonPolicyTest { + @TempDir Path temporaryDirectory; + @Test + void allWrongNonBlackFrameCannotBypassStructuralEvidence() { + var evaluation = + LocalImageComparisonPolicy.evaluate( + new Request(false, true, environment("Renderer"), reference(environment("Renderer")))); + + assertEquals(Status.FAILED_STRUCTURAL, evaluation.status()); + assertFalse(evaluation.comparisonAllowed()); + } + + @Test + void missingOrIncompatibleReferenceIsUnvalidatedAndNeverCompared() { + var missing = + LocalImageComparisonPolicy.evaluate( + new Request(true, true, environment("Renderer"), null)); + var mismatch = + LocalImageComparisonPolicy.evaluate( + new Request( + true, + true, + environment("Renderer"), + reference(environment("Different renderer")))); + var optedOut = + LocalImageComparisonPolicy.evaluate( + new Request(true, false, environment("Renderer"), reference(environment("Renderer")))); + + assertEquals(Status.UNVALIDATED_REFERENCE_MISSING, missing.status()); + assertEquals(Status.UNVALIDATED_ENVIRONMENT_MISMATCH, mismatch.status()); + assertEquals(Status.UNVALIDATED_OPT_OUT, optedOut.status()); + assertFalse(missing.comparisonAllowed()); + assertFalse(mismatch.comparisonAllowed()); + assertFalse(optedOut.comparisonAllowed()); + } + + @Test + void exactOptInEnvironmentAndVersionAreRequiredBeforeComparison() { + EnvironmentFingerprint environment = environment("Renderer"); + var ready = + LocalImageComparisonPolicy.evaluate( + new Request(true, true, environment, reference(environment))); + var stale = + LocalImageComparisonPolicy.evaluate( + new Request( + true, + true, + environment, + new Reference("old-policy", "reference-v0", "fallback-overhang", environment))); + + assertEquals(Status.READY_TO_COMPARE, ready.status()); + assertTrue(ready.comparisonAllowed()); + assertEquals(Status.UNVALIDATED_REFERENCE_VERSION, stale.status()); + assertFalse(stale.comparisonAllowed()); + } + + @Test + void configuredEvaluationRequiresTheExplicitSystemProperty() { + String previous = System.getProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + try { + System.clearProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + assertEquals( + Status.UNVALIDATED_OPT_OUT, + LocalImageComparisonPolicy.evaluateConfigured( + true, environment("Renderer"), reference(environment("Renderer"))) + .status()); + System.setProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY, "true"); + assertEquals( + Status.READY_TO_COMPARE, + LocalImageComparisonPolicy.evaluateConfigured( + true, environment("Renderer"), reference(environment("Renderer"))) + .status()); + } finally { + if (previous == null) { + System.clearProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + } else { + System.setProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY, previous); + } + } + } + + @Test + void policyFreezesBoundaryCoverageToleranceNamingAndArtifactRetention() { + var policy = LocalImageComparisonPolicy.policy(); + Set evidence = + policy.boundaryScenes().stream() + .flatMap(scene -> scene.requiredEvidence().stream()) + .collect(Collectors.toSet()); + + assertEquals( + Set.of("fallback", "overhang", "clipping", "selection", "caret", "transform"), + evidence); + assertEquals(2, policy.tolerance().maxRgbChannelDelta()); + assertEquals(2, policy.tolerance().maxAlphaDelta()); + assertEquals(6, policy.tolerance().maxAntialiasFringeChannelDelta()); + assertEquals(0.005, policy.tolerance().maxDifferingPixelRatio()); + assertEquals(1, policy.tolerance().antialiasFringeRadiusPixels()); + assertTrue( + LocalImageComparisonPolicy.referenceName("fallback-overhang", environment("Renderer")) + .startsWith("reference-v1--fallback-overhang--")); + assertEquals(64, environment("Renderer").stableId().length()); + assertEquals( + Path.of("artifacts", "mismatches", "local-text-image-policy-v1", "fallback-overhang"), + LocalImageComparisonPolicy.mismatchDirectory( + Path.of("artifacts"), "fallback-overhang")); + } + + @Test + void compatibleReferenceIsDecodedComparedAndPassesEndToEnd() throws Exception { + EnvironmentFingerprint environment = environment("Renderer"); + Reference reference = reference(environment); + BufferedImage image = image(Color.BLACK, Color.WHITE); + Path references = temporaryDirectory.resolve("references"); + Path artifacts = temporaryDirectory.resolve("artifacts"); + Path actual = temporaryDirectory.resolve("actual.png"); + LocalImageComparisonPolicy.writeReference(references, reference, image); + javax.imageio.ImageIO.write(image, "png", actual.toFile()); + + String previous = System.getProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + try { + System.setProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY, "true"); + var outcome = + LocalImageComparisonPolicy.compareConfigured( + true, + "fallback-overhang", + environment, + actual, + references, + artifacts); + + assertEquals(Status.PASSED, outcome.evaluation().status()); + assertTrue(outcome.comparison().passed()); + assertEquals(null, outcome.mismatchArtifacts()); + } finally { + restoreProperty(previous); + } + } + + @Test + void mismatchRetainsRequiredArtifactsAndSummary() throws Exception { + EnvironmentFingerprint environment = environment("Renderer"); + Path references = temporaryDirectory.resolve("references"); + Path artifacts = temporaryDirectory.resolve("artifacts"); + Path actual = temporaryDirectory.resolve("actual.png"); + LocalImageComparisonPolicy.writeReference( + references, reference(environment), image(Color.BLACK, Color.WHITE)); + javax.imageio.ImageIO.write(image(Color.BLACK, Color.RED), "png", actual.toFile()); + + String previous = System.getProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + try { + System.setProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY, "true"); + var outcome = + LocalImageComparisonPolicy.compareConfigured( + true, + "fallback-overhang", + environment, + actual, + references, + artifacts); + + assertEquals(Status.FAILED_IMAGE_COMPARISON, outcome.evaluation().status()); + assertFalse(outcome.comparison().passed()); + for (String file : + List.of( + "actual.png", + "expected.png", + "amplified-diff.png", + "edge-mask.png", + "environment.json", + "summary.json")) { + assertTrue(Files.isRegularFile(outcome.mismatchArtifacts().resolve(file)), file); + } + } finally { + restoreProperty(previous); + } + } + + @Test + void incompatibleManifestIsUnvalidatedWithoutDecodingActualPixels() throws Exception { + EnvironmentFingerprint referenceEnvironment = environment("Different renderer"); + Path references = temporaryDirectory.resolve("references"); + LocalImageComparisonPolicy.writeReference( + references, reference(referenceEnvironment), image(Color.BLACK, Color.WHITE)); + Path expectedFiles = + LocalImageComparisonPolicy.referenceFiles( + references, "fallback-overhang", environment("Renderer")) + .manifest(); + Files.createDirectories(expectedFiles.getParent()); + Files.copy( + LocalImageComparisonPolicy.referenceFiles( + references, "fallback-overhang", referenceEnvironment) + .manifest(), + expectedFiles); + + String previous = System.getProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + try { + System.setProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY, "true"); + var outcome = + LocalImageComparisonPolicy.compareConfigured( + true, + "fallback-overhang", + environment("Renderer"), + temporaryDirectory.resolve("does-not-exist.png"), + references, + temporaryDirectory.resolve("artifacts")); + + assertEquals(Status.UNVALIDATED_ENVIRONMENT_MISMATCH, outcome.evaluation().status()); + assertEquals(null, outcome.comparison()); + } finally { + restoreProperty(previous); + } + } + + @Test + void referenceManifestUsesClosedExactTypedSchema() throws Exception { + Path manifest = temporaryDirectory.resolve("reference.json"); + String valid = manifestJson(); + Files.writeString(manifest, valid); + assertEquals("fallback-overhang", LocalImageComparisonPolicy.readReference(manifest).sceneId()); + + for (String invalid : + List.of( + valid.replaceFirst("\\{", "{\"unknown\":1,"), + valid.replace("\"sceneId\":\"fallback-overhang\",", ""), + valid.replace("\"sceneId\":\"fallback-overhang\"", "\"sceneId\":1"), + valid.replace("\"sceneId\":\"fallback-overhang\"", "\"sceneId\":\"unknown\""), + valid.replace("\"width\":1280", "\"width\":\"1280\""), + valid.replace("\"pixelRatio\":1.0", "\"pixelRatio\":1e400"), + valid.replace("\"antialiasing\":true", "\"antialiasing\":\"true\""), + valid.replace("\"referenceVersion\":\"reference-v1\"", "\"referenceVersion\":\"old\""))) { + Files.writeString(manifest, invalid); + assertThrows(IllegalArgumentException.class, () -> LocalImageComparisonPolicy.readReference(manifest)); + } + } + + @Test + void malformedReferenceIsUnvalidatedBeforeActualImageDecode() throws Exception { + EnvironmentFingerprint environment = environment("Renderer"); + var files = + LocalImageComparisonPolicy.referenceFiles( + temporaryDirectory.resolve("references"), "fallback-overhang", environment); + Files.createDirectories(files.manifest().getParent()); + Files.writeString(files.manifest(), manifestJson().replace("\"width\":1280", "\"width\":0")); + String previous = System.getProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + try { + System.setProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY, "true"); + var result = + LocalImageComparisonPolicy.compareConfigured( + true, + "fallback-overhang", + environment, + temporaryDirectory.resolve("missing-actual.png"), + temporaryDirectory.resolve("references"), + temporaryDirectory.resolve("artifacts")); + assertEquals(Status.UNVALIDATED_REFERENCE_MANIFEST, result.evaluation().status()); + assertEquals(null, result.comparison()); + } finally { + restoreProperty(previous); + } + } + + @Test + void toleranceHonorsEdgeRgbAndAlphaLimitsAndOnePixelExpansion() { + var policyTolerance = LocalImageComparisonPolicy.policy().tolerance(); + var tolerance = + new LocalImageComparisonPolicy.Tolerance( + policyTolerance.maxRgbChannelDelta(), + policyTolerance.maxAlphaDelta(), + policyTolerance.maxAntialiasFringeChannelDelta(), + 1, + policyTolerance.antialiasFringeRadiusPixels()); + BufferedImage expected = solid(5, 5, new Color(0, 0, 0, 100)); + expected.setRGB(2, 2, new Color(100, 100, 100, 100).getRGB()); + BufferedImage atLimit = copy(expected); + atLimit.setRGB(3, 2, new Color(6, 6, 6, 106).getRGB()); + assertTrue(LocalImageComparisonPolicy.compare(atLimit, expected, tolerance).passed()); + + BufferedImage overAlpha = copy(expected); + overAlpha.setRGB(3, 2, new Color(6, 6, 6, 107).getRGB()); + assertFalse(LocalImageComparisonPolicy.compare(overAlpha, expected, tolerance).passed()); + + BufferedImage overRgb = copy(expected); + overRgb.setRGB(3, 2, new Color(7, 6, 6, 106).getRGB()); + assertFalse(LocalImageComparisonPolicy.compare(overRgb, expected, tolerance).passed()); + + BufferedImage outside = copy(expected); + outside.setRGB(0, 0, new Color(2, 0, 0, 102).getRGB()); + assertTrue(LocalImageComparisonPolicy.compare(outside, expected, tolerance).passed()); + outside.setRGB(0, 0, new Color(3, 0, 0, 102).getRGB()); + assertFalse(LocalImageComparisonPolicy.compare(outside, expected, tolerance).passed()); + } + + @Test + void differingPixelRatioAcceptsExactlyPointFivePercentAndRejectsAbove() { + var tolerance = LocalImageComparisonPolicy.policy().tolerance(); + BufferedImage expected = solid(20, 10, Color.BLACK); + BufferedImage exact = copy(expected); + exact.setRGB(0, 0, new Color(1, 0, 0).getRGB()); + assertEquals(0.005, LocalImageComparisonPolicy.compare(exact, expected, tolerance).differingPixelRatio()); + assertTrue(LocalImageComparisonPolicy.compare(exact, expected, tolerance).passed()); + + BufferedImage above = copy(exact); + above.setRGB(1, 0, new Color(1, 0, 0).getRGB()); + assertFalse(LocalImageComparisonPolicy.compare(above, expected, tolerance).passed()); + } + + private static String manifestJson() { + return """ + {"policyVersion":"local-text-image-policy-v1","referenceVersion":"reference-v1","sceneId":"fallback-overhang","environment":{"jvmVendor":"Vendor","jvmVersion":"25","osName":"OS","osVersion":"1","osArchitecture":"x64","glVendor":"GL vendor","glRenderer":"Renderer","glDriverVersion":"driver","glVersion":"4.6","backend":"nanovg-gl3","antialiasing":true,"width":1280,"height":720,"pixelRatio":1.0}} + """; + } + + private static BufferedImage solid(int width, int height, Color color) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + var graphics = image.createGraphics(); + graphics.setColor(color); + graphics.fillRect(0, 0, width, height); + graphics.dispose(); + return image; + } + + private static BufferedImage copy(BufferedImage source) { + BufferedImage copy = + new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB); + var graphics = copy.createGraphics(); + graphics.drawImage(source, 0, 0, null); + graphics.dispose(); + return copy; + } + + private static BufferedImage image(Color background, Color foreground) { + BufferedImage image = new BufferedImage(20, 20, BufferedImage.TYPE_INT_ARGB); + var graphics = image.createGraphics(); + try { + graphics.setColor(background); + graphics.fillRect(0, 0, 20, 20); + graphics.setColor(foreground); + graphics.fillRect(5, 5, 10, 10); + } finally { + graphics.dispose(); + } + return image; + } + + private static void restoreProperty(String previous) { + if (previous == null) { + System.clearProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY); + } else { + System.setProperty(LocalImageComparisonPolicy.OPT_IN_PROPERTY, previous); + } + } + + private static Reference reference(EnvironmentFingerprint environment) { + return new Reference( + LocalImageComparisonPolicy.POLICY_VERSION, + LocalImageComparisonPolicy.REFERENCE_VERSION, + "fallback-overhang", + environment); + } + + private static EnvironmentFingerprint environment(String renderer) { + ComparabilityMetadata.Environment environment = + new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.RENDERING, + "Vendor", + "25", + "OS", + "1", + "x64", + "CPU", + "GL vendor", + renderer, + "driver", + "4.6"); + return LocalImageComparisonPolicy.environment( + environment, "nanovg-gl3", true, 1280, 720, 1f); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/RenderingBenchmarkMetadataTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/RenderingBenchmarkMetadataTest.java new file mode 100644 index 00000000..a442382b --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/RenderingBenchmarkMetadataTest.java @@ -0,0 +1,147 @@ +package com.spinyowl.spinygui.benchmark.rendering; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkInputManifests.InputSet; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkInvocationMetadata; +import com.spinyowl.spinygui.benchmark.identity.BenchmarkRunMetadata; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity; +import com.spinyowl.spinygui.benchmark.identity.WorkloadIdentity.Dimension; +import org.junit.jupiter.api.Test; + +class RenderingBenchmarkMetadataTest { + @Test + void pairedRenderingMetadataAndCurrentWarmupExposuresAgreeWithoutAContext() { + BenchmarkRunMetadata metadata = + BenchmarkInvocationMetadata.timed( + "20260812-120000-000000000", + BenchmarkRunMetadata.Artifact.RENDERING, + BenchmarkRunMetadata.Pairing.PAIRED_REPORT); + RenderingWorkloadSpecifications.Specification specification = + RenderingWorkloadSpecifications.CURRENT; + + assertTrue(metadata.baselineEligible()); + for (RenderingWorkloadSpecifications.SceneSpecification scene : specification.measurementOrder()) { + int alternating = specification.alternatingWarmupFrames(scene); + int validation = specification.validationExposures(scene); + assertEquals( + alternating + validation, + specification.preMeasureExposures(scene)); + assertEquals( + Integer.toString(alternating), + specification.executionSettings(scene).get("alternating-warmup-frames-scene")); + assertEquals( + Integer.toString(validation), + specification.executionSettings(scene).get("validation-exposures-scene")); + assertEquals( + Integer.toString(alternating + validation), + specification.executionSettings(scene).get("premeasure-exposures-scene")); + } + } + + @Test + void actualSceneReportSerializesRequiredMetadataForEveryCurrentScene() { + ComparabilityMetadata.Environment environment = + new ComparabilityMetadata.Environment( + ComparabilityMetadata.Scope.RENDERING, "Vendor", "25", "OS", "1", "x64", "CPU model", + "GL vendor", "Renderer", "driver", "4.6"); + ComparabilityMetadata.Implementation implementation = + new ComparabilityMetadata.Implementation("impl-1", "build-1", "commit-1"); + RenderingBenchmarkMain.LatencySummary latency = + new RenderingBenchmarkMain.LatencySummary(1, 2, 3, 4, 5); + + for (RenderingWorkloadSpecifications.SceneSpecification scene : + RenderingWorkloadSpecifications.CURRENT.measurementOrder()) { + ComparabilityMetadata metadata = + RenderingBenchmarkMain.sceneComparability(scene, environment, implementation); + int validation = RenderingWorkloadSpecifications.CURRENT.validationExposures(scene); + RenderingBenchmarkMain.SceneReport report = + new RenderingBenchmarkMain.SceneReport( + 10, + scene.textNodeCount(), + 20, + 20, + 2, + 30, + validation, + 30 + validation, + 200, + latency, + latency, + metadata.toJson()); + JsonObject serialized = JsonParser.parseString(new Gson().toJson(report)).getAsJsonObject(); + ComparabilityMetadata parsed = + ComparabilityMetadata.fromJson(serialized.getAsJsonObject("comparability")); + WorkloadIdentity identity = RenderingWorkloadSpecifications.CURRENT.identity(scene); + InputSet manifests = RenderingWorkloadSpecifications.CURRENT.inputManifests(scene); + + assertEquals(identity.semanticId(), parsed.semanticId()); + assertEquals(RenderingWorkloadSpecifications.CURRENT.executionSettings(scene), parsed.benchmarkSettings()); + assertEquals(implementation, parsed.implementation()); + assertEquals( + ComparabilityMetadata.EvidenceMode.TIMED_ALLOCATION_DIAGNOSTICS_DISABLED, + parsed.evidenceMode()); + assertEquals(30, serialized.get("alternatingWarmupFrameCount").getAsInt()); + assertEquals(validation, serialized.get("validationExposureCount").getAsInt()); + assertEquals(30 + validation, serialized.get("preMeasureExposureCount").getAsInt()); + assertTrue(serialized.has("comparability")); + assertEquals( + manifests.content().sha256(), + serialized.getAsJsonObject("comparability").get("workloadContentSha256").getAsString()); + assertEquals( + manifests.shape().sha256(), + serialized.getAsJsonObject("comparability").get("workloadShapeSha256").getAsString()); + assertEquals( + manifests.fonts().sha256(), + serialized.getAsJsonObject("comparability").get("fontInputsSha256").getAsString()); + parsed.benchmarkSettings().forEach( + (key, value) -> { + if (key.equals("alternating-warmup-frames-pair")) { + assertEquals(value, identity.dimensions().get(Dimension.WARMUP_FRAMES), key); + } else if (!key.equals("alternating-warmup-frames-scene") + && !key.equals("premeasure-exposures-scene") + && !key.equals("validation-exposures-scene") + && !key.equals("validation-synchronization")) { + assertEquals(value, identity.dimensions().get(dimension(key)), key); + } + }); + } + } + + @Test + void producerManifestsAreGoldenVersionedAndComponentScoped() { + InputSet manifests = + RenderingWorkloadSpecifications.CURRENT.inputManifests( + RenderingWorkloadSpecifications.CURRENT.scene("small")); + + assertEquals("workload-content-v1", manifests.content().schema()); + assertEquals("workload-shape-v1", manifests.shape().schema()); + assertEquals("font-inputs-v1", manifests.fonts().schema()); + assertEquals( + "sha256:de63a4925da0647096203a7fc0b6d0315bdf635fb62c599e78a70858e15e6a6e", + manifests.content().sha256()); + assertEquals( + "sha256:b9551a2109141a7d732c065744eb29021d92c380c7494778aee7a9a6bbd0f800", + manifests.shape().sha256()); + assertEquals( + "sha256:4bee894d9b8ba24834afb0e9923395a28cc7c0c110cddb3edbfdad88f7c66ad4", + manifests.fonts().sha256()); + assertFalse(manifests.shape().canonicalSerialization().contains("warmup-frames")); + assertFalse(manifests.shape().canonicalSerialization().contains(":workload-content=")); + assertFalse(manifests.shape().canonicalSerialization().contains(":font-chain=")); + assertFalse(manifests.shape().canonicalSerialization().contains("spinygui-benchmark:v1")); + } + + private static Dimension dimension(String key) { + return java.util.Arrays.stream(Dimension.values()) + .filter(dimension -> dimension.key().equals(key)) + .findFirst() + .orElseThrow(); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/RenderingBoundaryScenesTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/RenderingBoundaryScenesTest.java new file mode 100644 index 00000000..4c9fccca --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/rendering/RenderingBoundaryScenesTest.java @@ -0,0 +1,89 @@ +package com.spinyowl.spinygui.benchmark.rendering; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.spinyowl.spinygui.core.diagnostic.DiagnosticSession; +import com.spinyowl.spinygui.core.layout.impl.InlineFormattingContext; +import com.spinyowl.spinygui.core.node.Element; +import com.spinyowl.spinygui.core.node.Frame; +import com.spinyowl.spinygui.core.node.Node; +import com.spinyowl.spinygui.core.node.NodeBuilder; +import com.spinyowl.spinygui.core.node.Text; +import com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.NvgStructuralValidation; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RenderingBoundaryScenesTest { + @Test + void approvedScenesExecuteSourceBoundProductionRecordingPaths() { + var specification = RenderingWorkloadSpecifications.CURRENT; + var evidence = + RenderingBoundaryScenes.validateAll( + specification.createFontService(DiagnosticSession.disabled())); + + assertEquals(StructuralValidationReport.APPROVED_SCENE_ORDER, + evidence.stream().map(item -> item.sceneId()).toList()); + var fallback = evidence.get(0); + assertEquals(List.of("A", "雪", "�"), fallback.submittedText()); + assertEquals(2, fallback.selectedFaceIds().stream().distinct().count()); + assertTrue(fallback.replacementSubmitted()); + assertTrue(fallback.overhangSubmitted()); + assertTrue( + evidence.stream() + .filter(item -> item.sceneId().equals("nested-clipping")) + .allMatch(item -> item.clipCommands() > 0)); + assertTrue( + evidence.stream() + .filter(item -> item.sceneId().equals("selection-caret")) + .allMatch(item -> item.selectionCommands() > 0 && item.caretCommands() > 0)); + assertTrue( + evidence.stream() + .filter(item -> item.sceneId().equals("transformed-text")) + .allMatch(item -> item.nonIdentityTransform())); + evidence.forEach( + item -> { + assertTrue(item.sourceExpectationSha256().startsWith("sha256:")); + assertTrue(item.commandDigestSha256().startsWith("sha256:")); + assertTrue(item.evidenceDigestSha256().startsWith("sha256:")); + assertTrue( + com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.NvgStructuralValidation + .evidenceDigestValid(item)); + }); + } + + @Test + void currentProductionSmallSceneMatchesItsSourceBoundSynchronizedFixture() { + var specification = RenderingWorkloadSpecifications.CURRENT; + var fontService = specification.createFontService(DiagnosticSession.disabled()); + Frame frame = new Frame(); + specification.style().apply(frame); + frame.frameSize(specification.window().widthPx(), specification.window().heightPx()); + frame.box().contentSize(specification.window().widthPx(), specification.window().heightPx()); + Element container = NodeBuilder.div(); + specification.style().apply(container); + container.box().contentPosition( + specification.container().positionXPx(), specification.container().positionYPx()); + container.box().contentSize( + specification.container().widthPx(), specification.container().heightPx()); + container.offsetParent(frame); + frame.addChild(container); + List textNodes = new java.util.ArrayList<>(); + for (int index = 0; index < specification.scene("small").textNodeCount(); index++) { + Text text = NodeBuilder.text(specification.transformedContent(index)); + text.offsetParent(container); + container.addChild(text); + textNodes.add(text); + } + new InlineFormattingContext(fontService).layout( + container, textNodes, specification.inlineLayoutStartYPx()); + frame.layoutChildNodes(List.of(container)); + container.layoutChildNodes(textNodes); + + var production = NvgStructuralValidation.validate( + frame, fontService, RenderingBoundaryScenes.synchronizedSmallRequirements(frame)); + var fixture = RenderingBoundaryScenes.synchronizedSmallFixtureEvidence(fontService); + + assertEquals(fixture, production); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/report/BenchmarkChartAssetsTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/report/BenchmarkChartAssetsTest.java new file mode 100644 index 00000000..9ac254af --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/report/BenchmarkChartAssetsTest.java @@ -0,0 +1,41 @@ +package com.spinyowl.spinygui.benchmark.report; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; + +class BenchmarkChartAssetsTest { + @Test + void pinsChartJsAndItsLicenseNotices() throws IOException, NoSuchAlgorithmException { + String chartJs = resource("chart.umd.min.js"); + String licenses = resource("THIRD-PARTY-LICENSES.txt"); + + assertTrue(chartJs.contains("Chart.js v4.5.1")); + assertTrue(chartJs.contains("@kurkle/color v0.3.2")); + assertFalse(chartJs.contains("sourceMappingURL")); + assertTrue(licenses.contains("Copyright (c) 2014-2024 Chart.js Contributors")); + assertTrue(licenses.contains("Copyright (c) 2018-2021 Jukka Kurkela")); + assertTrue(licenses.split("Permission is hereby granted", -1).length - 1 == 2); + assertTrue(sha256(chartJs).equals("84d0e233daba702b8f77d669d8c137cad36d441a10f200b6f2d3ab553bdfcf6b")); + } + + private static String resource(String name) throws IOException { + InputStream stream = BenchmarkChartAssetsTest.class.getResourceAsStream("/com/spinyowl/spinygui/benchmark/report/" + name); + assertNotNull(stream); + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String sha256(String value) throws NoSuchAlgorithmException { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/report/BenchmarkHtmlReportGeneratorTest.java b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/report/BenchmarkHtmlReportGeneratorTest.java new file mode 100644 index 00000000..ac068a1c --- /dev/null +++ b/spinygui.benchmark/src/test/java/com/spinyowl/spinygui/benchmark/report/BenchmarkHtmlReportGeneratorTest.java @@ -0,0 +1,1374 @@ +package com.spinyowl.spinygui.benchmark.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata; +import com.spinyowl.spinygui.benchmark.identity.ComparabilityMetadata.EvidenceMode; +import com.spinyowl.spinygui.benchmark.rendering.RenderingWorkloadSpecifications; +import com.spinyowl.spinygui.benchmark.rendering.RenderingBoundaryScenes; +import com.spinyowl.spinygui.benchmark.rendering.StructuralValidationReport; +import com.spinyowl.spinygui.core.diagnostic.DiagnosticSession; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BenchmarkHtmlReportGeneratorTest { + @Test + void generatesSelfContainedEscapedReportFromBothJsonFormats(@TempDir Path archive) throws IOException { + writePair(archive, "20260724-090000-000000000", cpuJson(), renderingJson()); + String html = BenchmarkHtmlReportGenerator.generateArchive(archive); + + assertTrue(html.contains("measureLatin")); + assertTrue(html.contains("Rendering small")); + assertTrue(html.contains("Rendering large")); + assertTrue(html.contains("fragments: 100")); + assertTrue(html.contains("fragments: 1,000")); + assertTrue(html.contains("<GPU>")); + assertTrue(html.contains("Latency (us/op)")); + assertTrue(count(html, "id=\"cpu-latency-chart\"") == 1); + assertTrue(count(html, "id=\"cpu-allocation-chart\"") == 1); + assertTrue(count(html, "id=\"cpu-rendering-chart\"") == 1); + assertTrue(count(html, "id=\"gpu-rendering-chart\"") == 1); + assertTrue(count(html, "role=\"img\"") == 4); + assertTrue(count(html, "class=\"chart-fallback\"") == 4); + assertTrue(html.contains("class=\"chart-scroll\"")); + assertTrue(html.contains("class=\"chart-frame\"")); + assertTrue(html.contains("id=\"cpu-data\"")); + assertTrue(html.contains("id=\"rendering-data\"")); + assertTrue(html.contains("40.000 us")); + assertTrue(html.contains("0.480% of the 120 Hz budget")); + assertTrue(html.contains("Skip to report content")); + assertTrue(html.contains("