From d169adf3aff9df195bbbf360a51820bf5aa29409 Mon Sep 17 00:00:00 2001 From: Andreas Isler Date: Fri, 10 Jul 2026 21:41:28 +0200 Subject: [PATCH] added REST model test, tests failing --- customer-work/docs/model-tests.md | 370 +++++++++ .../work/SecurityHttpBasicConfiguration.java | 2 +- .../com/customer/work/service/VarUtil.java | 741 ++++++++++++++++++ .../com/customer/work/service/VarUtils.java | 420 ---------- .../src/main/resources/application.properties | 1 - .../work/model/FlowableExcelParser.java | 8 +- .../work/model/FlowableModelTestUtils.java | 10 +- .../customer/work/model/TestRestServer.java | 1 + .../customer/work/model/test/ModelTest.java | 4 +- .../service/VarUtilArrayTrackingTest.java | 262 +++++++ .../VarUtilExpressionFunctionsTest.java | 154 ++++ .../work/service/VarUtilMockScopeTest.java | 81 ++ .../customer/work/service/VarUtilTest.java | 327 ++++++++ .../src/test/resources/application.properties | 16 +- .../test-auto-deploy-apps/TST_APP.zip | Bin 30497 -> 18984 bytes .../test-auto-deploy-apps/trackVarsApp.zip | Bin 11660 -> 11664 bytes 16 files changed, 1967 insertions(+), 430 deletions(-) create mode 100644 customer-work/docs/model-tests.md create mode 100644 customer-work/src/main/java/com/customer/work/service/VarUtil.java delete mode 100644 customer-work/src/main/java/com/customer/work/service/VarUtils.java create mode 100644 customer-work/src/test/java/com/customer/work/service/VarUtilArrayTrackingTest.java create mode 100644 customer-work/src/test/java/com/customer/work/service/VarUtilExpressionFunctionsTest.java create mode 100644 customer-work/src/test/java/com/customer/work/service/VarUtilMockScopeTest.java create mode 100644 customer-work/src/test/java/com/customer/work/service/VarUtilTest.java diff --git a/customer-work/docs/model-tests.md b/customer-work/docs/model-tests.md new file mode 100644 index 0000000..5bd80b2 --- /dev/null +++ b/customer-work/docs/model-tests.md @@ -0,0 +1,370 @@ +# Model Tests for Flowable Apps + +This page describes how to write automated tests for Flowable app models (BPMN processes, CMMN cases) in the `customer-work` module. Model tests run as plain JUnit tests against an in-memory H2 database — no Docker, no running platform needed. + +Test data can be provided in three ways: + +1. Directly in Java (simple, single-scenario tests) +2. As JSON parameter files (one scenario per file) +3. As Excel workbooks (many scenarios as rows; can be edited with Microsoft Excel or LibreOffice Calc) + +--- + +## 1. Project structure + +All model-test code and data lives in the `customer-work` module: + +``` +customer-work/ +├── src/main/java/com/customer/work/ +│ └── service/ # custom runtime code used by the models +│ └── JsonUtils.java # JSON conversion helpers for service tasks +│ +├── src/test/java/com/customer/work/ +│ ├── config/ +│ │ └── TestConfiguration.java # test Spring configuration +│ └── model/ # the test framework +│ ├── FlowableModelTest.java # @FlowableModelTest meta-annotation +│ ├── FlowableModelTestUtils.java # main utility bean (start, complete, assert, load) +│ ├── FlowableExcelParser.java # reads .xlsx workbooks from the classpath +│ ├── FlowableExcelMapper.java # maps workbook rows to test parameters +│ ├── FlowableJsonParser.java # converts plain test data to model variable types +│ ├── EmailDto.java # captured email (subject, receivers, content) +│ ├── TestMailServer.java # GreenMail SMTP server for email assertions +│ ├── TestMailServerExtension.java # starts/stops the mail server per test +│ └── test/ # the actual test classes +│ └── ModelTest.java # tests for the TST_APP models +│ +└── src/test/resources/ + ├── application.properties # H2 datasource, mail server port, app location + ├── test-auto-deploy-apps/ + │ └── TST_APP.zip # the app under test (exported from Design) + └── model/test/ # test data, one directory per model + ├── C001/ + │ └── T001.json # task completion payload + ├── P001/ + │ └── initiator.json # JSON parameter file + ├── P002/ + │ ├── boolean1.json … date.json # JSON parameter files + │ └── p002Test.xlsx # Excel workbook with test cases + └── P005/ + └── T001.json +``` + +Conventions: + +- Test data goes to `src/test/resources/model/test//`, where `` is the short name of the model under test (e.g. `P002` for process `TST_P002`). +- JSON files are named after the scenario they cover (`boolean1.json`, `initiator.json`); task completion payloads are named after the task (`T001.json`). +- One Excel workbook per model (`p002Test.xlsx`) holds all data-driven cases for that model. +- Test classes live in `com.customer.work.model.test`; the framework classes in `com.customer.work.model` normally do not need to be touched when adding new tests. + +## 2. How it works + +Every test deploys the app under test and starts the model through a generated **wrapper process**: + +``` +[Wrapper process _T] → Call Activity → [Process under test ] +``` + +The wrapper is generated at runtime by `FlowableModelTestUtils.createRootTestProcessInstance(testKey, variables)`. This makes the test realistic for models that read or write variables on their **root** process (e.g. via `varutil` with the `root.` prefix), and it allows testing the model's **in/out parameter mappings**: + +- Variables passed at the top level are set on the **wrapper (root) process**. +- The special variable `__IN` (a map) creates **in-mappings** on the call activity: each entry `"name": value` maps `${__IN.name}` to the variable `name` of the called process. +- The special variable `__OUT` (a map) creates **out-mappings**: each entry `"source": "target"` copies variable `source` of the called process to variable `target` of the root process when the call activity completes. + +The method returns the IDs of both instances: + +| Key | Content | +|---|---| +| `ROOT_PROCESS_ID` | ID of the generated wrapper (root) process instance | +| `TEST_PROCESS_ID` | ID of the process under test (the call activity child) | + +Assertions are usually made against the **historic payload of the root process** (`getHistProcessPayload`), so they see both the root variables and everything mapped out of the model under test. + +### Test infrastructure + +| Piece | Purpose | +|---|---| +| `@FlowableModelTest` | Meta-annotation: boots the Spring context with all Flowable engine test extensions, makes each test `@Transactional` (automatic rollback) and starts the test mail server | +| `FlowableModelTestUtils` | The main utility bean: start instances, complete tasks, execute timers, load test data, assert emails / audit records | +| `FlowableExcelParser` / `FlowableExcelMapper` | Read Excel workbooks from the classpath and turn each data row into test parameters | +| `TestMailServer` (GreenMail) | Captures emails sent by the model; configured via `flowable.mail.server.host/port` in `src/test/resources/application.properties` | + +### App deployment + +The app under test must be available as a zip in `src/test/resources/test-auto-deploy-apps/` (e.g. `TST_APP.zip`). It is auto-deployed at context start via: + +```properties +flowable.app.resource-location=classpath*:/test-auto-deploy-apps/ +``` + +To refresh the zip from a running Flowable Design, temporarily enable the `exportApp` test (remove `@Disabled`) and run it once: + +```java +flowableModelTest.exportApp("http://localhost:8106", "myWorkspace", "TST_APP", "admin", "test"); +``` + +--- + +## 3. Writing a model test class + +Annotate the class with `@FlowableModelTest` and inject `FlowableModelTestUtils`: + +```java +@FlowableModelTest +public class ModelTest { + + @Autowired + protected FlowableModelTestUtils flowableModelTest; + + @Test + public void p005HappyPathTest() { + // Start TST_P005 wrapped in a root process, no start variables + ObjectNode processIds = flowableModelTest.createRootTestProcessInstance("TST_P005", flowableModelTest.emptyMap()); + + // Work on the open user task + flowableModelTest.claimOpenTask("TST_P005_T001", "admin"); + flowableModelTest.completeOpenTask("TST_P005_T001", + flowableModelTest.loadObjectNodeFromResources("model/test/P005/T001.json"), null); + + // Assert against the historic root process payload + Map rootPayload = flowableModelTest.getHistProcessPayload( + processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText()); + Assertions.assertThat(rootPayload.get("dataEntry")).isEqualTo("my root text"); + } +} +``` + +### Frequently used utility methods + +| Method | Purpose | +|---|---| +| `createRootTestProcessInstance(key, vars)` | Deploy wrapper + start the model under test, returns the two instance IDs | +| `startCaseInstance(key, vars)` | Start a CMMN case directly | +| `setTestAuthenticatedUser(userId, tenantId, groups...)` | Set the authenticated user for subsequent engine calls | +| `claimOpenTask(taskKey, userId)` / `completeOpenTask(taskKey, vars, outcome)` | Claim / complete the single open task with the given task definition key. `vars` may be flat (`"a.b": 1`) — existing task variables are merged automatically | +| `executeTimer(processId)` | Fire the active timer job of the given process instance | +| `getHistProcessPayload(id)` / `getRuntimeCasePayload(id)` / `getHistoryCasePayload(id)` | Read variables for assertions | +| `getMailList()` | All emails captured by the test mail server | +| `getAuditTrail()` | All audit records | +| `loadObjectNodeFromResources(path)` / `loadTestVarsFromResources(path)` | Load JSON test data from `src/test/resources` (see below) | +| `getJsonArgumentsFromExcel(path)` / `getObjArgumentsFromExcel(path)` | Excel rows as JUnit parameterized-test arguments (see below) | +| `testJsonExcelRow(path, row)` / `testObjExcelRow(path, row)` | Run one Excel row: start, fire timers, assert payload / audit / email counts | + +### Running the tests + +```bash +cd C-2025.2 +./mvnw test -pl customer-work -Dtest=ModelTest # one class +./mvnw test -pl customer-work # whole module +``` + +--- + +## 4. JSON parameter files + +JSON files define the start parameters for one test scenario. They live under `src/test/resources/model/test//` and are loaded with `loadTestVarsFromResources(path)`. + +### Structure + +All parameters must be declared **explicitly** in one of three top-level blocks: + +| Block | Meaning | +|---|---| +| `__ROOT` | Variables set on the root (wrapper) process | +| `__IN` | In-mappings of the call activity: `"name": value` passes the value as variable `name` into the model under test | +| `__OUT` | Out-mappings: `"source": "target"` copies variable `source` of the model to variable `target` of the root process on completion | + +Every block is optional, but **any other top-level field fails the test** with: + +``` +IllegalArgumentException: Implicit parameter 'param' in model/test/P002/boolean1.json: +parameters must be declared explicitly inside __ROOT, __IN or __OUT +``` + +Keys inside `__ROOT` must not start with `__`. + +### Example: `model/test/P002/boolean1.json` + +```json +{ + "__ROOT": { + "param": true + }, + "__IN": { + "param": false + }, + "__OUT": { + "result": "out" + } +} +``` + +This sets `param = true` on the root process, passes `param = false` into `TST_P002`, and copies the model's `result` variable to the root variable `out` when the model completes. + +### Example: using JSON files in a parameterized test + +```java +private Stream p002TestData() { + return Stream.of( + Arguments.of("model/test/P002/boolean1.json", true, false), + Arguments.of("model/test/P002/int.json", 123, 456) + ); +} + +@ParameterizedTest +@MethodSource("p002TestData") +public void p002Test(String path, Object result, Object out) { + ObjectNode processIds = flowableModelTest.createRootTestProcessInstance("TST_P002", + flowableModelTest.loadTestVarsFromResources(path)); + Map rootPayload = flowableModelTest.getHistProcessPayload( + processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText()); + + Assertions.assertThat(rootPayload.get("result")).isEqualTo(result); + Assertions.assertThat(rootPayload.get("out")).isEqualTo(out); +} +``` + +--- + +## 5. Excel test workbooks + +For data-driven testing of many scenarios, define one workbook per model, e.g. `src/test/resources/model/test/P002/p002Test.xlsx`. Both **Microsoft Excel** and **LibreOffice Calc** can be used to create and edit the file (`.xlsx` format). + +Each **sheet** is a group of test cases, each **data row** is one test case that starts the model, optionally fires timers, and asserts the results. + +### Sheet layout + +| Row | Content | +|---|---| +| 1 | **Path** of each column's value (see path notation below) | +| 2 | **Type** of each column: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Date` | +| 3+ | One test case per row | + +Parsing rules: + +- The column count is taken from row 1: columns are read from the first cell up to the **first empty header cell**. +- Rows are read until the **first completely empty row** — everything below is ignored (useful for comments or temporarily disabled cases). +- **Empty cells are skipped** — the variable / check is simply not used for that row. +- Formulas are evaluated (e.g. `=TRUE()`); the displayed value is what counts. + +### Path notation (row 1) + +The path decides where the value goes. Nested objects use `.`, array elements use `[index]`: + +| Path prefix | Meaning | +|---|---| +| `root.` | Variable on the root process (like `__ROOT` in JSON) | +| `in.` | In-mapping into the model (like `__IN`) | +| `out.` | Out-mapping; the cell value is the **target** variable name (like `__OUT`) | +| `id` | **Required.** Process definition key of the model under test, e.g. `TST_P002` | +| `timer` | Number of timer jobs to fire after start (Integer) | +| `test.` | Expected value of variable `` in the root process payload after the run | +| `audit` | Expected total number of audit records (Integer) | +| `email` | Expected total number of sent emails (Integer) | + +Nested examples: + +| Header | Effect | +|---|---| +| `root.param` | Root variable `param` | +| `root.param[0]`, `root.param[1]` | Root variable `param` as array with two elements | +| `root.items[1].name` | Root variable `items`, array whose second element is an object with field `name` | +| `test.result[0]` | Asserts the first element of array variable `result` | + +Array elements that are skipped (e.g. only `[1]` is filled) are padded with empty objects, because Flowable cannot store `null` list elements. + +### Special cell values + +| Value | In input columns (`root.`, `in.`, `out.`) | In `test.` columns | +|---|---|---| +| *(empty cell)* | Variable not set | No check | +| `__NULL` | Variable set to JSON `null` | No check (a null expectation matches anything) | +| `__N_A` | – | Asserts the variable/field is **absent or null** | +| `__EMPTY` | Empty string `""` (type `String` only) | Expects `""` | +| `__BLANK` | Single space `" "` (type `String` only) | Expects `" "` | + +### Types (row 2) + +| Type | Cell content | +|---|---| +| `String` | Any text | +| `Boolean` | `TRUE` / `FALSE` (or a formula like `=TRUE()`) | +| `Integer`, `Long`, `Double` | Numeric value | +| `Date` | A date cell displayed as `yyyy-MM-dd` or `yyyy-MM-dd HH:mm:ss`. The displayed (local) date is interpreted as **UTC** | + +### Example sheet + +| | A | B | C | D | E | F | G | H | +|---|---|---|---|---|---|---|---|---| +| **1** | `root.nr` | `root.param` | `in.param` | `out.result` | `id` | `timer` | `test.result` | `test.outResult` | +| **2** | `String` | `String` | `String` | `String` | `String` | `Integer` | `String` | `String` | +| **3** | 11 | | | | TST_P002 | | | | +| **4** | 12 | hello root | hello | outResult | TST_P002 | 1 | hello root | hello | +| **5** | 13 | | hello | outResult | TST_P002 | 0 | `__N_A` | `__NULL` | + +- Row 3: starts `TST_P002` with no parameters and no checks (smoke test). +- Row 4: sets root and in parameters, maps `result` out to `outResult`, fires one timer, then asserts `result == "hello root"` and `outResult == "hello"`. +- Row 5: no root param; asserts that `result` was **not** set (`__N_A`) and skips the `outResult` check. + +Tip: a column like `root.nr` is a handy row number — it appears in the test log line of every row, which makes failures easy to locate. + +### How assertions work + +After the run, the expected values from the `test.` columns are compared as a **subset** against the historic root process payload: every expected field must exist with the same (string-compared) value; extra variables in the payload are ignored. On failure the test prints both sides: + +``` +test : {"result":"hello root","outResult":"hello"} +root : {"nr":"12","param":"hello root", ...} +``` + +If `audit` / `email` columns are present, the total counts are asserted as well. + +### Wiring the workbook into JUnit + +Every data row of every sheet becomes one JUnit test invocation: + +```java +private Stream p002ExcelTestData() { + return flowableModelTest.getJsonArgumentsFromExcel("model/test/P002/p002Test.xlsx"); +} + +@ParameterizedTest +@MethodSource("p002ExcelTestData") +public void p002ExcelTest(String path, JsonNode argument) { + flowableModelTest.testJsonExcelRow(path, argument); +} +``` + +The `Obj` variant works with plain Java maps and additionally returns a result map that can drive detailed email / audit assertions (they only run for rows where the `email` / `audit` count is > 0): + +```java +private Stream p002ExcelTestData2() { + return flowableModelTest.getObjArgumentsFromExcel("model/test/P002/p002Test.xlsx"); +} + +@ParameterizedTest +@MethodSource("p002ExcelTestData2") +public void p002ExcelTest2(String path, Map argument) { + Map result = flowableModelTest.testObjExcelRow(path, argument); + flowableModelTest.checkAndAssertEmail(result, 1, + "Email", "Test", "test@flowable.com"); + flowableModelTest.checkAndAssertAuditRecord(result, 1, + "Audit record", "TST_P002 Audit trail entry", "system", null); +} +``` + +--- + +## 6. Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `Implicit parameter 'x' in ` | A top-level field outside `__ROOT` / `__IN` / `__OUT` in a JSON parameter file — move it into the right block | +| `Resource not found: ` | Path is relative to `src/test/resources` and is loaded from the classpath — check spelling and location | +| `Column 'id' missing in row of ` | The Excel sheet has no `id` column, or the cell is empty for that row | +| `No open task with key X found` | The model did not reach the expected user task — or more than one task with that key is open | +| `No active timer` | The `timer` count in the row is higher than the number of timer jobs the model actually creates | +| `Missing ']' in path` / `Invalid array index in path` | Malformed header path in row 1, e.g. `root.param[0` or `root.param[a]` | +| `Variable type not supported: X` | Typo in row 2 — allowed: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Date` | +| Subset assertion fails with `test:` / `root:` output | Compare both printed JSON documents; remember values are compared as strings and `__N_A` requires absence | diff --git a/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java b/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java index f304862..e36ea8f 100644 --- a/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java +++ b/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java @@ -30,7 +30,7 @@ public class SecurityHttpBasicConfiguration { @Order(10) public SecurityFilterChain basicDefaultSecurity(HttpSecurity http, ObjectProvider httpSecurityCustomizers) throws Exception { for (FlowableHttpSecurityCustomizer customizer : httpSecurityCustomizers.orderedStream() - .toList()) { + .collect(Collectors.toList())) { customizer.customize(http); } diff --git a/customer-work/src/main/java/com/customer/work/service/VarUtil.java b/customer-work/src/main/java/com/customer/work/service/VarUtil.java new file mode 100644 index 0000000..80fb791 --- /dev/null +++ b/customer-work/src/main/java/com/customer/work/service/VarUtil.java @@ -0,0 +1,741 @@ +package com.customer.work.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.flowable.cmmn.api.CmmnRuntimeService; +import org.flowable.cmmn.api.runtime.CaseInstance; +import org.flowable.cmmn.api.runtime.PlanItemInstance; +import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.impl.persistence.entity.ExecutionEntity; +import org.flowable.engine.runtime.Execution; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.variable.api.delegate.VariableScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * General-purpose Flowable variable utility bean for tracking variable changes + * and for null-safe variable access. + * Usable in BPMN process and CMMN case backend expressions: + * ${varutil.trackVars('root.snapshot', 'order.customer.name,order.total,status')} + * ${varutil.trackVars(execution, 'root.snapshot', 'status')} — BPMN, explicit scope + * ${varutil.trackVars(planItemInstance, 'root.snapshot', 'status')} — CMMN, explicit scope + * ${varutil.get(execution, 'orders[2].customer.name')} — null-safe read + * Variable paths use dot notation; a leading "root." prefix addresses the root + * instance of a nested case/process hierarchy. A "[]" in a path expands over all + * elements of the JSON/List array at that point, e.g. "status[]" (simple types), + * "orders[].name" (object fields) or "orders[].items[].qty" (nested arrays). + * A path that just names an array ("status", "orders[].items") tracks all its + * entries as well, as if "[]" were appended. + * The get/getOrDefault/exists/isEmpty/isNotEmpty/equals/notEquals/contains/ + * containsAny/lowerThan(OrEquals)/greaterThan(OrEquals)/base64 methods are + * null-safe, path-based counterparts of the Flowable var: expression functions. + */ +@Component("varUtil") +public class VarUtil { + + private static final Logger LOGGER = LoggerFactory.getLogger(VarUtil.class); + + private static final ObjectMapper MAPPER; + static { + MAPPER = new ObjectMapper(); + MAPPER.registerModule(new JavaTimeModule()); + MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + } + + @Autowired + private RuntimeService runtimeService; + + @Autowired + private CmmnRuntimeService cmmnRuntimeService; + + // ------------------------------------------------------------------------- + // Public API — called from Flowable expressions + // ------------------------------------------------------------------------- + + /** + * Checks which of the given comma-separated variable paths changed since the + * last call and returns a JSON array describing each change. + * Example call: + * ${varutil.trackVars(self, 'root.snapshot', 'root.status, status')} + * Example return value: + * [{"path":"root.status","oldValue":"a","newValue":"b"}, {"path":"status","oldValue":"c","newValue":"d"}] + * Paths containing "[]" are expanded per array element (e.g. "status[]", + * "orders[].name"); changes are then reported per concrete index, such as + * {"path":"orders[2].name",...}. A path that resolves to an array without an + * explicit "[]" is expanded the same way. Elements added since the last call + * are reported with oldValue null, removed elements with newValue null. + */ + public ArrayNode trackVars(VariableScope currentScope, String snapshotPath, String variablePathsCsv) { + + // Reject blank parameters early — report "no changes" instead of failing, + // so a misconfigured expression cannot break the surrounding process/case. + if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) { + LOGGER.debug("{}.trackVars: empty parameters", getClass().getName()); + return MAPPER.createArrayNode(); + } + + // Without a resolvable scope there is nothing to read from or write to. + if (currentScope == null) { + LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName()); + return MAPPER.createArrayNode(); + } + + // Split the CSV into individual variable paths, dropping blanks and whitespace. + List paths = Arrays.stream(variablePathsCsv.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).toList(); + + // Load the previous values (persisted as a JSON string variable at + // snapshotPath) to compare the current values against. + Map oldSnapshot = loadSnapshot(currentScope, snapshotPath); + Map newSnapshot = new HashMap<>(); + ArrayNode changes = MAPPER.createArrayNode(); + + // For every tracked path: expand "[]" over the current array elements, + // then read each concrete value, normalize it so it compares consistently + // with the reloaded snapshot, and record a change entry whenever old != new. + for (String path : paths) { + for (String concretePath : expandArrayPath(currentScope, path)) { + JsonNode newValue = normalize(toJson(readVariableFromPath(currentScope, concretePath))); + newSnapshot.put(concretePath, newValue); + + JsonNode oldValue = oldSnapshot.getOrDefault(concretePath, MAPPER.nullNode()); + if (!oldValue.equals(newValue)) { + addChange(changes, concretePath, oldValue, newValue); + } + } + + // Old snapshot entries matching the spec but no longer produced by the + // expansion belong to removed array elements (or a value that changed + // shape between scalar and array) — report them as changes to null and + // let them drop out of the new snapshot. + java.util.regex.Pattern pattern = arraySpecPattern(path); + for (Map.Entry old : oldSnapshot.entrySet()) { + if (pattern.matcher(old.getKey()).matches() + && !newSnapshot.containsKey(old.getKey()) + && !old.getValue().isNull()) { + addChange(changes, old.getKey(), old.getValue(), MAPPER.nullNode()); + } + } + } + + // Persist the current values as the reference snapshot for the next call. + saveSnapshot(currentScope, snapshotPath, newSnapshot); + + return changes; + } + + // ------------------------------------------------------------------------- + // Null-safe variable functions — path-based counterparts of the Flowable + // var: expression functions (var:get, var:exists, ...). All accept the same + // path syntax as trackVars and never throw on unresolvable paths. + // ------------------------------------------------------------------------- + + /** + * Null-safe read: returns the value at the path, or null when any part of + * the path cannot be resolved (missing variable, null intermediate, index + * out of bounds). A path with an explicit "[]" returns the flattened List + * of entry values, e.g. "orders[].name" -> ["n1", "n2"]. + */ + public Object get(VariableScope scope, String path) { + return resolve(scope, path); + } + + /** Like {@link #get}, but returns {@code defaultValue} instead of null. */ + public Object getOrDefault(VariableScope scope, String path, Object defaultValue) { + Object value = resolve(scope, path); + return value != null ? value : defaultValue; + } + + /** True when the path resolves to a non-null value. */ + public boolean exists(VariableScope scope, String path) { + return resolve(scope, path) != null; + } + + /** + * True when the path resolves to null, an empty String, or an empty + * List/Map/array/JSON container. + */ + public boolean isEmpty(VariableScope scope, String path) { + return isEmptyValue(resolve(scope, path)); + } + + /** Inverse of {@link #isEmpty}. */ + public boolean isNotEmpty(VariableScope scope, String path) { + return !isEmpty(scope, path); + } + + /** + * True when the value at the path equals the given value. Both sides are + * normalized to JSON before comparing, so a Long 5 equals an Integer 5 and + * a JsonNode field equals its plain Java counterpart. A null value only + * equals an unresolvable/null path. + */ + public boolean equals(VariableScope scope, String path, Object value) { + return jsonEquals(resolve(scope, path), value); + } + + /** Inverse of {@link #equals(VariableScope, String, Object)}. */ + public boolean notEquals(VariableScope scope, String path, Object value) { + return !equals(scope, path, value); + } + + /** + * True when the value at the path contains ALL given values: substring for + * Strings, element containment (JSON-normalized) for List/array/JSON array. + * False for null or any other value type. + */ + public boolean contains(VariableScope scope, String path, Object... values) { + Object container = resolve(scope, path); + if (container == null || values == null || values.length == 0) return false; + for (Object value : values) { + if (!containsValue(container, value)) return false; + } + return true; + } + + /** Like {@link #contains}, but true when ANY of the given values is contained. */ + public boolean containsAny(VariableScope scope, String path, Object... values) { + Object container = resolve(scope, path); + if (container == null || values == null) return false; + for (Object value : values) { + if (containsValue(container, value)) return true; + } + return false; + } + + /** True when the numeric value at the path is lower than the given number. */ + public boolean lowerThan(VariableScope scope, String path, Object value) { + Integer cmp = compareNumeric(resolve(scope, path), value); + return cmp != null && cmp < 0; + } + + /** True when the numeric value at the path is lower than or equal to the given number. */ + public boolean lowerThanOrEquals(VariableScope scope, String path, Object value) { + Integer cmp = compareNumeric(resolve(scope, path), value); + return cmp != null && cmp <= 0; + } + + /** True when the numeric value at the path is greater than the given number. */ + public boolean greaterThan(VariableScope scope, String path, Object value) { + Integer cmp = compareNumeric(resolve(scope, path), value); + return cmp != null && cmp > 0; + } + + /** True when the numeric value at the path is greater than or equal to the given number. */ + public boolean greaterThanOrEquals(VariableScope scope, String path, Object value) { + Integer cmp = compareNumeric(resolve(scope, path), value); + return cmp != null && cmp >= 0; + } + + /** + * Base64-encodes the String (UTF-8) or byte[] value at the path; null for + * anything else. + */ + public String base64(VariableScope scope, String path) { + Object value = resolve(scope, path); + if (value instanceof byte[] bytes) return java.util.Base64.getEncoder().encodeToString(bytes); + if (value instanceof String s) + return java.util.Base64.getEncoder().encodeToString(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return null; + } + + /** + * Resolves a path to its value. A path with an explicit "[]" resolves to + * the flattened List of the expanded entry values; unlike trackVars, a bare + * array name is NOT expanded — it resolves to the array itself. + */ + private Object resolve(VariableScope scope, String path) { + if (scope == null || path == null || path.isBlank()) return null; + String trimmed = path.trim(); + if (trimmed.contains("[]")) { + List values = new java.util.ArrayList<>(); + for (String concretePath : expandArrayPath(scope, trimmed)) { + values.add(readVariableFromPath(scope, concretePath)); + } + return values; + } + return readVariableFromPath(scope, trimmed); + } + + // ------------------------------------------------------------------------- + // Path resolution + // ------------------------------------------------------------------------- + + /** + * Expands every "[]" in a path over the elements of the array currently found + * at that point, e.g. "orders[].name" with two orders becomes + * ["orders[0].name", "orders[1].name"]. Works recursively, so nested arrays + * ("a[].b[]", "a[][]") expand to all combinations. A path without "[]" that + * resolves to an array is expanded as if "[]" were appended; other paths are + * returned as-is. A "[]" that does not hit an array expands to nothing. + */ + private List expandArrayPath(VariableScope scope, String path) { + int idx = path.indexOf("[]"); + if (idx < 0) { + // Bare path: when it resolves to an array, track all its entries as + // if "[]" were appended (recursing handles arrays of arrays). + Object value = readVariableFromPath(scope, path); + if (!isArray(value)) return List.of(path); + List result = new java.util.ArrayList<>(); + for (int i = 0; i < arraySize(value); i++) { + result.addAll(expandArrayPath(scope, path + "[" + i + "]")); + } + return result; + } + String prefix = path.substring(0, idx); + String suffix = path.substring(idx + 2); + + int size = arraySize(readVariableFromPath(scope, prefix)); + List result = new java.util.ArrayList<>(); + for (int i = 0; i < size; i++) { + result.addAll(expandArrayPath(scope, prefix + "[" + i + "]" + suffix)); + } + return result; + } + + /** + * Builds a regex matching all concrete expansions of a path spec, + * e.g. "orders[].name" matches "orders[0].name", "orders[12].name", ... + * The trailing index group also covers bare-array auto-expansion, so "status" + * matches "status", "status[3]" and "status[0][1]". Used to find snapshot + * entries of elements that no longer exist. + */ + private static java.util.regex.Pattern arraySpecPattern(String spec) { + StringBuilder regex = new StringBuilder(); + for (String literal : spec.split("\\[\\]", -1)) { + if (regex.length() > 0) regex.append("\\[\\d+\\]"); + regex.append(java.util.regex.Pattern.quote(literal)); + } + regex.append("(\\[\\d+\\])*"); + return java.util.regex.Pattern.compile(regex.toString()); + } + + /** True for the container types expanded by "[]": List, Object[] or JSON array. */ + private static boolean isArray(Object obj) { + return obj instanceof List || obj instanceof Object[] + || (obj instanceof JsonNode jn && jn.isArray()); + } + + /** Returns the element count of a List, Object[] or JSON array, else 0. */ + private static int arraySize(Object obj) { + if (obj instanceof List list) return list.size(); + if (obj instanceof Object[] arr) return arr.length; + if (obj instanceof JsonNode jn && jn.isArray()) return jn.size(); + return 0; + } + + /** + * Reads a value via a dot-notation path, e.g. "order.customer.name". + * The first segment names a Flowable variable; the remaining segments navigate + * into that value (maps, lists/arrays by index, JsonNodes, POJOs — see + * {@link #getNestedVariable}). Segments may carry "[n]" index suffixes to + * address array elements, e.g. "orders[0].name" or "matrix[1][2]". + * Returns null when any segment cannot be resolved. + */ + private Object readVariableFromPath(VariableScope scope, String path) { + String[] segments = path.split("\\.", -1); + if (segments.length < 1) return null; + + // A leading "root." switches to the root instance of the surrounding + // case/process hierarchy before resolving the variable. + int startIndex = 0; + if ("root".equals(segments[0])) { + if (segments.length < 2) return null; + startIndex = 1; + scope = getRootScope(scope); + } + if (scope == null) return null; + + // Read the top-level variable, then walk the remaining segments into it. + String first = segments[startIndex]; + int bracket = first.indexOf('['); + Object currentValue = scope.getVariable(bracket < 0 ? first : first.substring(0, bracket)); + if (bracket >= 0) currentValue = applyIndices(currentValue, first.substring(bracket)); + + for (int i = startIndex + 1; i < segments.length; i++) { + if (currentValue == null) return null; + String segment = segments[i]; + bracket = segment.indexOf('['); + if (bracket < 0) { + currentValue = getNestedVariable(currentValue, segment); + } else { + if (bracket > 0) currentValue = getNestedVariable(currentValue, segment.substring(0, bracket)); + currentValue = applyIndices(currentValue, segment.substring(bracket)); + } + } + return currentValue; + } + + /** Applies a chain of "[n]" index suffixes ("[0]", "[1][2]", ...) to a value. */ + private static Object applyIndices(Object value, String indices) { + java.util.regex.Matcher m = INDEX_PATTERN.matcher(indices); + while (m.find()) { + if (value == null) return null; + value = getElement(value, Integer.parseInt(m.group(1))); + } + return value; + } + + private static final java.util.regex.Pattern INDEX_PATTERN = + java.util.regex.Pattern.compile("\\[(\\d+)\\]"); + + /** Returns element i of a List, Object[] or JSON array (scalars unwrapped), else null. */ + private static Object getElement(Object obj, int i) { + if (obj instanceof List list) return i >= 0 && i < list.size() ? list.get(i) : null; + if (obj instanceof Object[] arr) return i >= 0 && i < arr.length ? arr[i] : null; + if (obj instanceof JsonNode jn && jn.isArray()) return unwrapJson(jn.get(i)); + return null; + } + + /** + * Writes a value via a dot-notation path (same syntax as + * {@link #readVariableFromPath}). A single-segment path sets a plain Flowable + * variable; a nested path mutates the container inside the top-level variable + * and writes that variable back so the engine persists the change. + */ + private void writeVariableToPath(VariableScope scope, String path, Object value) { + String[] segments = path.split("\\.", -1); + if (segments.length < 1) return; + + // A leading "root." redirects the write to the root instance of the + // surrounding case/process hierarchy. + VariableScope targetScope; + String[] varSegments; + if ("root".equals(segments[0])) { + if (segments.length < 2) return; + targetScope = getRootScope(scope); + varSegments = Arrays.copyOfRange(segments, 1, segments.length); + } else { + targetScope = scope; + varSegments = segments; + } + if (targetScope == null) return; + + if (varSegments.length == 1) { + targetScope.setVariable(varSegments[0], value); + return; + } + + // Nested path: read the top-level variable, navigate to the parent node, + // mutate it in-place, then write the top-level variable back. + String topVar = varSegments[0]; + Object topValue = targetScope.getVariable(topVar); + + Object parent = topValue; + for (int i = 1; i < varSegments.length - 1; i++) { + if (parent == null) { + LOGGER.warn("varutil.writeVariableToPath: null at '{}' in path '{}'", varSegments[i - 1], path); + return; + } + parent = getNestedVariable(parent, varSegments[i]); + } + + if (!setNestedValue(parent, varSegments[varSegments.length - 1], value, path)) return; + targetScope.setVariable(topVar, topValue); + } + + /** + * Sets one key on a mutable container (Map or ObjectNode). Anything else — + * including POJOs, which are read-only for this bean — is rejected with a + * warning so a bad path never breaks the calling expression. + */ + @SuppressWarnings("unchecked") + private boolean setNestedValue(Object parent, String key, Object value, String path) { + if (parent instanceof Map map) { + map.put(key, value); + return true; + } + if (parent instanceof ObjectNode on) { + on.set(key, toJson(value)); + return true; + } + LOGGER.warn("varutil.writeVariableToPath: cannot set '{}' on {} in path '{}'", + key, parent == null ? "null" : parent.getClass().getName(), path); + return false; + } + + // ------------------------------------------------------------------------- + // Scope resolution + // ------------------------------------------------------------------------- + + /** + * Climbs from the given scope to the root VariableScope of the surrounding + * case/process hierarchy, following call-activity parents (BPMN) and + * parent/callback links (CMMN) across engine boundaries. + */ + private VariableScope getRootScope(VariableScope scope) { + if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId()); + if (scope instanceof PlanItemInstance pii) return findCmmnRootScope(pii.getCaseInstanceId()); + if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId()); + return null; + } + + /** + * Finds the root scope starting from a BPMN process instance: + * 1. started by a call activity → recurse into the calling process instance, + * 2. started from a CMMN plan item (callback) → continue climbing in the case, + * 3. otherwise this process instance is itself the root. + * NOTE: the returned object is a detached query result used as VariableScope; + * its variable access only works because expression evaluation runs inside an + * active Flowable command context. + */ + private VariableScope findBpmnRootScope(String processInstanceId) { + if (processInstanceId == null || runtimeService == null) return null; + try { + Execution piExec = runtimeService.createExecutionQuery() + .executionId(processInstanceId).singleResult(); + if (piExec != null && piExec.getSuperExecutionId() != null) { + Execution superExec = runtimeService.createExecutionQuery() + .executionId(piExec.getSuperExecutionId()).singleResult(); + if (superExec != null) + return findBpmnRootScope(superExec.getProcessInstanceId()); + } + ProcessInstance pi = runtimeService.createProcessInstanceQuery() + .processInstanceId(processInstanceId).singleResult(); + if (pi != null && pi.getCallbackType() != null && pi.getCallbackId() != null + && cmmnRuntimeService != null) { + PlanItemInstance planItem = cmmnRuntimeService.createPlanItemInstanceQuery() + .planItemInstanceId(pi.getCallbackId()).singleResult(); + if (planItem != null) + return findCmmnRootScope(planItem.getCaseInstanceId()); + } + return (ExecutionEntity) piExec; // root: ExecutionEntity implements VariableScope + } catch (Exception e) { + LOGGER.debug("{}.findBpmnRootScope: BPMN root scope climb failed: {}", getClass().getName(), e.getMessage()); + return null; + } + } + + /** + * Finds the root scope starting from a CMMN case instance: + * 1. sub-case → recurse into the parent case, + * 2. started from a BPMN call/task (callback) → continue climbing in the process, + * 3. otherwise this case instance is itself the root. + * Same detached-query-result caveat as {@link #findBpmnRootScope}. + */ + private VariableScope findCmmnRootScope(String caseInstanceId) { + if (caseInstanceId == null || cmmnRuntimeService == null) return null; + try { + CaseInstance ci = cmmnRuntimeService.createCaseInstanceQuery() + .caseInstanceId(caseInstanceId).singleResult(); + if (ci == null) return null; + if (ci.getParentId() != null) + return findCmmnRootScope(ci.getParentId()); + if (ci.getCallbackType() != null && ci.getCallbackId() != null + && runtimeService != null) { + Execution callbackExec = runtimeService.createExecutionQuery() + .executionId(ci.getCallbackId()).singleResult(); + if (callbackExec != null) + return findBpmnRootScope(callbackExec.getProcessInstanceId()); + } + return (CaseInstanceEntity) ci; // root: CaseInstanceEntity implements VariableScope + } catch (Exception e) { + LOGGER.debug("{}.findCmmnRootScope: CMMN root scope climb failed: {}", getClass().getName(), e.getMessage()); + return null; + } + } + + /** + * Resolves one path segment against a value: list/array index, map key, + * JsonNode field (scalars are unwrapped to plain Java values), and finally + * POJO access via getX()/isX() getters or a declared field. + */ + private static Object getNestedVariable(Object obj, String segment) { + // Empty segments (e.g. from "a..b" or a trailing dot) cannot address anything. + if (segment == null || segment.isEmpty()) return null; + + // Lists and arrays are addressed by numeric index, e.g. "items.0.name". + if (obj instanceof List list) { + try { + int i = Integer.parseInt(segment); + return i >= 0 && i < list.size() ? list.get(i) : null; + } catch (NumberFormatException ignored) {} + } + if (obj instanceof Object[] arr) { + try { + int i = Integer.parseInt(segment); + return i >= 0 && i < arr.length ? arr[i] : null; + } catch (NumberFormatException ignored) {} + } + if (obj instanceof Map map) return map.get(segment); + + // JsonNode: unwrap scalar fields to plain Java values so they compare and + // serialize the same way as values read from Map/POJO variables. + if (obj instanceof JsonNode jn) { + return unwrapJson(jn.get(segment)); + } + + // Fallback for POJOs: try bean getters first, then a declared field + // (climbing the class hierarchy). Read-only — writes reject POJO parents. + String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1); + try { + return obj.getClass().getMethod("get" + cap).invoke(obj); + } catch (Exception ignored) {} + try { + return obj.getClass().getMethod("is" + cap).invoke(obj); + } catch (Exception ignored) {} + try { + java.lang.reflect.Field f = findField(obj.getClass(), segment); + if (f != null) { f.setAccessible(true); return f.get(obj); } + } catch (Exception ignored) {} + LOGGER.warn("varutil: cannot resolve '{}' on {}", segment, obj.getClass().getName()); + return null; + } + + /** Looks up a declared field by name, walking up the class hierarchy. */ + private static java.lang.reflect.Field findField(Class c, String name) { + while (c != null && c != Object.class) { + try { return c.getDeclaredField(name); } + catch (NoSuchFieldException e) { c = c.getSuperclass(); } + } + return null; + } + + // ------------------------------------------------------------------------- + // Snapshot persistence + // ------------------------------------------------------------------------- + + /** + * Loads the previous-values snapshot: reads the variable at {@code path} + * (persisted as a JSON string by {@link #saveSnapshot}) and parses it back + * into a path → value-node map. Returns an empty map when there is no + * snapshot yet (first call) or it is unreadable. + */ + private Map loadSnapshot(VariableScope scope, String path) { + Object raw = readVariableFromPath(scope, path); + if (raw == null) return new HashMap<>(); + try { + String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw); + Map flat = MAPPER.readValue(json, + MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); + Map result = new HashMap<>(); + flat.forEach((k, v) -> result.put(k, toJson(v))); + return result; + } catch (Exception e) { + LOGGER.warn("varutil: could not load snapshot at '{}': {}", path, e.getMessage()); + return new HashMap<>(); + } + } + + /** Persists the snapshot map as a JSON string variable at {@code path}. */ + private void saveSnapshot(VariableScope scope, String path, Map snapshot) { + try { + writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot)); + } catch (Exception e) { + LOGGER.error("varutil: could not save snapshot at '{}'", path, e); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** True for null, an empty String, or an empty List/Map/array/JSON container. */ + private static boolean isEmptyValue(Object value) { + if (value == null) return true; + if (value instanceof String s) return s.isEmpty(); + if (value instanceof java.util.Collection c) return c.isEmpty(); + if (value instanceof Map m) return m.isEmpty(); + if (value instanceof Object[] arr) return arr.length == 0; + if (value instanceof JsonNode jn) { + if (jn.isNull() || jn.isMissingNode()) return true; + if (jn.isTextual()) return jn.asText().isEmpty(); + if (jn.isContainerNode()) return jn.size() == 0; + return false; + } + return false; + } + + /** JSON-normalized equality, so numeric types and JsonNode wrappers compare naturally. */ + private static boolean jsonEquals(Object a, Object b) { + return normalize(toJson(a)).equals(normalize(toJson(b))); + } + + /** Containment test for {@link #contains}: substring or element equality. */ + private static boolean containsValue(Object container, Object value) { + if (container instanceof String s) return value != null && s.contains(value.toString()); + if (container instanceof java.util.Collection c) { + for (Object element : c) if (jsonEquals(element, value)) return true; + return false; + } + if (container instanceof Object[] arr) { + for (Object element : arr) if (jsonEquals(element, value)) return true; + return false; + } + if (container instanceof JsonNode jn && jn.isArray()) { + for (JsonNode element : jn) if (jsonEquals(element, value)) return true; + return false; + } + return false; + } + + /** Numeric comparison via BigDecimal; null when either side is not a number. */ + private static Integer compareNumeric(Object a, Object b) { + java.math.BigDecimal numA = toNumber(a); + java.math.BigDecimal numB = toNumber(b); + return numA == null || numB == null ? null : numA.compareTo(numB); + } + + /** Converts a Number or numeric JsonNode to BigDecimal, anything else to null. */ + private static java.math.BigDecimal toNumber(Object value) { + if (value instanceof JsonNode jn) return jn.isNumber() ? jn.decimalValue() : null; + if (value instanceof Number n) return new java.math.BigDecimal(n.toString()); + return null; + } + + /** Appends one change entry to the result array. */ + private static void addChange(ArrayNode changes, String path, JsonNode oldValue, JsonNode newValue) { + ObjectNode change = MAPPER.createObjectNode(); + change.put("path", path); + change.set("oldValue", oldValue); + change.set("newValue", newValue); + changes.add(change); + } + + /** Unwraps a scalar JsonNode to its plain Java value; containers pass through. */ + private static Object unwrapJson(JsonNode node) { + if (node == null || node.isNull()) return null; + if (node.isTextual()) return node.asText(); + if (node.isBoolean()) return node.asBoolean(); + if (node.isLong()) return node.asLong(); + if (node.isInt()) return node.asInt(); + if (node.isDouble()) return node.asDouble(); + return node; + } + + /** Wraps any Java value into a JsonNode (null-safe). */ + private static JsonNode toJson(Object value) { + if (value == null) return MAPPER.nullNode(); + if (value instanceof JsonNode jn) return jn; + return MAPPER.valueToTree(value); + } + + /** + * Serializes and re-parses a node so its value types match what + * {@link #loadSnapshot} produces when reading the persisted snapshot back + * (e.g. a Long 5 read from a variable and an Integer 5 parsed from the + * snapshot JSON both become the same numeric node). Without this, unchanged + * numeric values would be reported as changes on every call. + */ + private static JsonNode normalize(JsonNode node) { + try { + return MAPPER.readTree(MAPPER.writeValueAsString(node)); + } catch (Exception e) { + return node; + } + } +} diff --git a/customer-work/src/main/java/com/customer/work/service/VarUtils.java b/customer-work/src/main/java/com/customer/work/service/VarUtils.java deleted file mode 100644 index 182bce8..0000000 --- a/customer-work/src/main/java/com/customer/work/service/VarUtils.java +++ /dev/null @@ -1,420 +0,0 @@ -package com.customer.work.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.flowable.cmmn.api.CmmnRuntimeService; -import org.flowable.cmmn.api.runtime.CaseInstance; -import org.flowable.cmmn.api.runtime.PlanItemInstance; -import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity; -import org.flowable.engine.RuntimeService; -import org.flowable.engine.delegate.DelegateExecution; -import org.flowable.engine.impl.persistence.entity.ExecutionEntity; -import org.flowable.engine.runtime.Execution; -import org.flowable.engine.runtime.ProcessInstance; -import org.flowable.variable.api.delegate.VariableScope; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * General-purpose Flowable variable utility bean for tracking variable changes. - * Usable in BPMN process and CMMN case backend expressions: - * ${varUtils.trackVars('root.snapshot', 'order.customer.name,order.total,status')} - * ${varUtils.trackVars(execution, 'root.snapshot', 'status')} — BPMN, explicit scope - * ${varUtils.trackVars(planItemInstance, 'root.snapshot', 'status')} — CMMN, explicit scope - * Variable paths use dot notation; a leading "root." prefix addresses the root - * instance of a nested case/process hierarchy. - */ -@Component("varUtils") -public class VarUtils { - - private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class); - - private static final ObjectMapper MAPPER; - static { - MAPPER = new ObjectMapper(); - MAPPER.registerModule(new JavaTimeModule()); - MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - } - - @Autowired - private RuntimeService runtimeService; - - @Autowired - private CmmnRuntimeService cmmnRuntimeService; - - // ------------------------------------------------------------------------- - // Public API — called from Flowable expressions - // ------------------------------------------------------------------------- - - /** - * Checks which of the given comma-separated variable paths changed since the - * last call and returns a JSON array describing each change. - * Example call: - * ${varUtils.trackVars(self, 'root.snapshot', 'root.status, status')} - * Example return value: - * [{"path":"root.status","oldValue":"a","newValue":"b"}, {"path":"status","oldValue":"c","newValue":"d"}] - */ - public ArrayNode trackVars(VariableScope currentScope, String snapshotPath, String variablePathsCsv) { - - // Reject blank parameters early — report "no changes" instead of failing, - // so a misconfigured expression cannot break the surrounding process/case. - if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) { - LOGGER.debug("{}.trackVars: empty parameters", getClass().getName()); - return MAPPER.createArrayNode(); - } - - // Without a resolvable scope there is nothing to read from or write to. - if (currentScope == null) { - LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName()); - return MAPPER.createArrayNode(); - } - - // Split the CSV into individual variable paths, dropping blanks and whitespace. - List paths = Arrays.stream(variablePathsCsv.split(",")) - .map(String::trim).filter(s -> !s.isEmpty()).toList(); - - // Load the previous values (persisted as a JSON string variable at - // snapshotPath) to compare the current values against. - Map oldSnapshot = loadSnapshot(currentScope, snapshotPath); - Map newSnapshot = new HashMap<>(); - ArrayNode changes = MAPPER.createArrayNode(); - - // For every tracked path: read the current value, normalize it so it - // compares consistently with the reloaded snapshot, and record a change - // entry whenever old != new. - for (String path : paths) { - JsonNode newValue = normalize(toJson(readVariableFromPath(currentScope, path))); - newSnapshot.put(path, newValue); - - JsonNode oldValue = oldSnapshot.getOrDefault(path, MAPPER.nullNode()); - if (!oldValue.equals(newValue)) { - ObjectNode change = MAPPER.createObjectNode(); - change.put("path", path); - change.set("oldValue", oldValue); - change.set("newValue", newValue); - changes.add(change); - } - } - - // Persist the current values as the reference snapshot for the next call. - saveSnapshot(currentScope, snapshotPath, newSnapshot); - - return changes; - } - - // ------------------------------------------------------------------------- - // Path resolution - // ------------------------------------------------------------------------- - - /** - * Reads a value via a dot-notation path, e.g. "order.customer.name". - * The first segment names a Flowable variable; the remaining segments navigate - * into that value (maps, lists/arrays by index, JsonNodes, POJOs — see - * {@link #getNestedVariable}). Returns null when any segment cannot be resolved. - */ - private Object readVariableFromPath(VariableScope scope, String path) { - String[] segments = path.split("\\.", -1); - if (segments.length < 1) return null; - - // A leading "root." switches to the root instance of the surrounding - // case/process hierarchy before resolving the variable. - int startIndex = 0; - if ("root".equals(segments[0])) { - if (segments.length < 2) return null; - startIndex = 1; - scope = getRootScope(scope); - } - if (scope == null) return null; - - // Read the top-level variable, then walk the remaining segments into it. - Object currentValue = scope.getVariable(segments[startIndex]); - for (int i = startIndex + 1; i < segments.length; i++) { - if (currentValue == null) return null; - currentValue = getNestedVariable(currentValue, segments[i]); - } - return currentValue; - } - - /** - * Writes a value via a dot-notation path (same syntax as - * {@link #readVariableFromPath}). A single-segment path sets a plain Flowable - * variable; a nested path mutates the container inside the top-level variable - * and writes that variable back so the engine persists the change. - */ - private void writeVariableToPath(VariableScope scope, String path, Object value) { - String[] segments = path.split("\\.", -1); - if (segments.length < 1) return; - - // A leading "root." redirects the write to the root instance of the - // surrounding case/process hierarchy. - VariableScope targetScope; - String[] varSegments; - if ("root".equals(segments[0])) { - if (segments.length < 2) return; - targetScope = getRootScope(scope); - varSegments = Arrays.copyOfRange(segments, 1, segments.length); - } else { - targetScope = scope; - varSegments = segments; - } - if (targetScope == null) return; - - if (varSegments.length == 1) { - targetScope.setVariable(varSegments[0], value); - return; - } - - // Nested path: read the top-level variable, navigate to the parent node, - // mutate it in-place, then write the top-level variable back. - String topVar = varSegments[0]; - Object topValue = targetScope.getVariable(topVar); - - Object parent = topValue; - for (int i = 1; i < varSegments.length - 1; i++) { - if (parent == null) { - LOGGER.warn("varUtils.writeVariableToPath: null at '{}' in path '{}'", varSegments[i - 1], path); - return; - } - parent = getNestedVariable(parent, varSegments[i]); - } - - if (!setNestedValue(parent, varSegments[varSegments.length - 1], value, path)) return; - targetScope.setVariable(topVar, topValue); - } - - /** - * Sets one key on a mutable container (Map or ObjectNode). Anything else — - * including POJOs, which are read-only for this bean — is rejected with a - * warning so a bad path never breaks the calling expression. - */ - @SuppressWarnings("unchecked") - private boolean setNestedValue(Object parent, String key, Object value, String path) { - if (parent instanceof Map map) { - map.put(key, value); - return true; - } - if (parent instanceof ObjectNode on) { - on.set(key, toJson(value)); - return true; - } - LOGGER.warn("varUtils.writeVariableToPath: cannot set '{}' on {} in path '{}'", - key, parent == null ? "null" : parent.getClass().getName(), path); - return false; - } - - // ------------------------------------------------------------------------- - // Scope resolution - // ------------------------------------------------------------------------- - - /** - * Climbs from the given scope to the root VariableScope of the surrounding - * case/process hierarchy, following call-activity parents (BPMN) and - * parent/callback links (CMMN) across engine boundaries. - */ - private VariableScope getRootScope(VariableScope scope) { - if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId()); - if (scope instanceof PlanItemInstance pii) return findCmmnRootScope(pii.getCaseInstanceId()); - if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId()); - return null; - } - - /** - * Finds the root scope starting from a BPMN process instance: - * 1. started by a call activity → recurse into the calling process instance, - * 2. started from a CMMN plan item (callback) → continue climbing in the case, - * 3. otherwise this process instance is itself the root. - * NOTE: the returned object is a detached query result used as VariableScope; - * its variable access only works because expression evaluation runs inside an - * active Flowable command context. - */ - private VariableScope findBpmnRootScope(String processInstanceId) { - if (processInstanceId == null || runtimeService == null) return null; - try { - Execution piExec = runtimeService.createExecutionQuery() - .executionId(processInstanceId).singleResult(); - if (piExec != null && piExec.getSuperExecutionId() != null) { - Execution superExec = runtimeService.createExecutionQuery() - .executionId(piExec.getSuperExecutionId()).singleResult(); - if (superExec != null) - return findBpmnRootScope(superExec.getProcessInstanceId()); - } - ProcessInstance pi = runtimeService.createProcessInstanceQuery() - .processInstanceId(processInstanceId).singleResult(); - if (pi != null && pi.getCallbackType() != null && pi.getCallbackId() != null - && cmmnRuntimeService != null) { - PlanItemInstance planItem = cmmnRuntimeService.createPlanItemInstanceQuery() - .planItemInstanceId(pi.getCallbackId()).singleResult(); - if (planItem != null) - return findCmmnRootScope(planItem.getCaseInstanceId()); - } - return (ExecutionEntity) piExec; // root: ExecutionEntity implements VariableScope - } catch (Exception e) { - LOGGER.debug("{}.findBpmnRootScope: BPMN root scope climb failed: {}", getClass().getName(), e.getMessage()); - return null; - } - } - - /** - * Finds the root scope starting from a CMMN case instance: - * 1. sub-case → recurse into the parent case, - * 2. started from a BPMN call/task (callback) → continue climbing in the process, - * 3. otherwise this case instance is itself the root. - * Same detached-query-result caveat as {@link #findBpmnRootScope}. - */ - private VariableScope findCmmnRootScope(String caseInstanceId) { - if (caseInstanceId == null || cmmnRuntimeService == null) return null; - try { - CaseInstance ci = cmmnRuntimeService.createCaseInstanceQuery() - .caseInstanceId(caseInstanceId).singleResult(); - if (ci == null) return null; - if (ci.getParentId() != null) - return findCmmnRootScope(ci.getParentId()); - if (ci.getCallbackType() != null && ci.getCallbackId() != null - && runtimeService != null) { - Execution callbackExec = runtimeService.createExecutionQuery() - .executionId(ci.getCallbackId()).singleResult(); - if (callbackExec != null) - return findBpmnRootScope(callbackExec.getProcessInstanceId()); - } - return (CaseInstanceEntity) ci; // root: CaseInstanceEntity implements VariableScope - } catch (Exception e) { - LOGGER.debug("{}.findCmmnRootScope: CMMN root scope climb failed: {}", getClass().getName(), e.getMessage()); - return null; - } - } - - /** - * Resolves one path segment against a value: list/array index, map key, - * JsonNode field (scalars are unwrapped to plain Java values), and finally - * POJO access via getX()/isX() getters or a declared field. - */ - private static Object getNestedVariable(Object obj, String segment) { - // Empty segments (e.g. from "a..b" or a trailing dot) cannot address anything. - if (segment == null || segment.isEmpty()) return null; - - // Lists and arrays are addressed by numeric index, e.g. "items.0.name". - if (obj instanceof List list) { - try { - int i = Integer.parseInt(segment); - return i >= 0 && i < list.size() ? list.get(i) : null; - } catch (NumberFormatException ignored) {} - } - if (obj instanceof Object[] arr) { - try { - int i = Integer.parseInt(segment); - return i >= 0 && i < arr.length ? arr[i] : null; - } catch (NumberFormatException ignored) {} - } - if (obj instanceof Map map) return map.get(segment); - - // JsonNode: unwrap scalar fields to plain Java values so they compare and - // serialize the same way as values read from Map/POJO variables. - if (obj instanceof JsonNode jn) { - JsonNode node = jn.get(segment); - if (node == null || node.isNull()) return null; - if (node.isTextual()) return node.asText(); - if (node.isBoolean()) return node.asBoolean(); - if (node.isLong()) return node.asLong(); - if (node.isInt()) return node.asInt(); - if (node.isDouble()) return node.asDouble(); - return node; - } - - // Fallback for POJOs: try bean getters first, then a declared field - // (climbing the class hierarchy). Read-only — writes reject POJO parents. - String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1); - try { - return obj.getClass().getMethod("get" + cap).invoke(obj); - } catch (Exception ignored) {} - try { - return obj.getClass().getMethod("is" + cap).invoke(obj); - } catch (Exception ignored) {} - try { - java.lang.reflect.Field f = findField(obj.getClass(), segment); - if (f != null) { f.setAccessible(true); return f.get(obj); } - } catch (Exception ignored) {} - LOGGER.warn("varUtils: cannot resolve '{}' on {}", segment, obj.getClass().getName()); - return null; - } - - /** Looks up a declared field by name, walking up the class hierarchy. */ - private static java.lang.reflect.Field findField(Class c, String name) { - while (c != null && c != Object.class) { - try { return c.getDeclaredField(name); } - catch (NoSuchFieldException e) { c = c.getSuperclass(); } - } - return null; - } - - // ------------------------------------------------------------------------- - // Snapshot persistence - // ------------------------------------------------------------------------- - - /** - * Loads the previous-values snapshot: reads the variable at {@code path} - * (persisted as a JSON string by {@link #saveSnapshot}) and parses it back - * into a path → value-node map. Returns an empty map when there is no - * snapshot yet (first call) or it is unreadable. - */ - private Map loadSnapshot(VariableScope scope, String path) { - Object raw = readVariableFromPath(scope, path); - if (raw == null) return new HashMap<>(); - try { - String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw); - Map flat = MAPPER.readValue(json, - MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); - Map result = new HashMap<>(); - flat.forEach((k, v) -> result.put(k, toJson(v))); - return result; - } catch (Exception e) { - LOGGER.warn("varUtils: could not load snapshot at '{}': {}", path, e.getMessage()); - return new HashMap<>(); - } - } - - /** Persists the snapshot map as a JSON string variable at {@code path}. */ - private void saveSnapshot(VariableScope scope, String path, Map snapshot) { - try { - writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot)); - } catch (Exception e) { - LOGGER.error("varUtils: could not save snapshot at '{}'", path, e); - } - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - /** Wraps any Java value into a JsonNode (null-safe). */ - private static JsonNode toJson(Object value) { - if (value == null) return MAPPER.nullNode(); - if (value instanceof JsonNode jn) return jn; - return MAPPER.valueToTree(value); - } - - /** - * Serializes and re-parses a node so its value types match what - * {@link #loadSnapshot} produces when reading the persisted snapshot back - * (e.g. a Long 5 read from a variable and an Integer 5 parsed from the - * snapshot JSON both become the same numeric node). Without this, unchanged - * numeric values would be reported as changes on every call. - */ - private static JsonNode normalize(JsonNode node) { - try { - return MAPPER.readTree(MAPPER.writeValueAsString(node)); - } catch (Exception e) { - return node; - } - } -} diff --git a/customer-work/src/main/resources/application.properties b/customer-work/src/main/resources/application.properties index 7d617da..50d2932 100644 --- a/customer-work/src/main/resources/application.properties +++ b/customer-work/src/main/resources/application.properties @@ -42,4 +42,3 @@ flowable.security.basic-auth.password=test flowable.mail.server.host=localhost flowable.mail.server.port=2525 - diff --git a/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java b/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java index 38a6ef1..ae9762b 100644 --- a/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java +++ b/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java @@ -1,6 +1,12 @@ package com.customer.work.model; -import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.FormulaEvaluator; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.stereotype.Component; diff --git a/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java b/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java index b890b11..e868374 100644 --- a/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java +++ b/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java @@ -11,11 +11,11 @@ import com.flowable.audit.api.AuditService; import com.flowable.audit.api.runtime.AuditInstance; import com.flowable.core.spring.security.SecurityUtils; import com.flowable.platform.service.task.CompleteFormRepresentation; -import com.flowable.platform.service.task.PlatformTaskService; import com.flowable.serviceregistry.api.runtime.ServiceInvocationResultResponse; import com.flowable.serviceregistry.api.runtime.ServiceRegistryRuntimeService; import com.github.wnameless.json.flattener.JsonFlattener; import com.github.wnameless.json.unflattener.JsonUnflattener; +import com.flowable.platform.service.task.PlatformTaskService; import jakarta.mail.Address; import org.assertj.core.api.Assertions; import org.flowable.bpmn.model.*; @@ -39,14 +39,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClient; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StreamUtils; -import org.springframework.web.client.RestClient; -import java.io.*; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.nio.file.Paths; import java.util.*; import java.util.stream.Collectors; diff --git a/customer-work/src/test/java/com/customer/work/model/TestRestServer.java b/customer-work/src/test/java/com/customer/work/model/TestRestServer.java index ff854fc..931af3e 100644 --- a/customer-work/src/test/java/com/customer/work/model/TestRestServer.java +++ b/customer-work/src/test/java/com/customer/work/model/TestRestServer.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; /** diff --git a/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java b/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java index 52e3fe4..3c3c9ed 100644 --- a/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java +++ b/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java @@ -1,11 +1,11 @@ package com.customer.work.model.test; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.customer.work.model.EmailDto; import com.customer.work.model.FlowableModelTest; import com.customer.work.model.FlowableModelTestUtils; import com.customer.work.model.RestRequestDto; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.flowable.serviceregistry.api.runtime.ServiceInvocationResultResponse; import org.assertj.core.api.Assertions; import org.flowable.cmmn.api.runtime.CaseInstance; diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilArrayTrackingTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilArrayTrackingTest.java new file mode 100644 index 0000000..e766866 --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilArrayTrackingTest.java @@ -0,0 +1,262 @@ +package com.customer.work.service; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit test for the array tracking of {@link VarUtil#trackVars}. The + * parameterized tests run every scenario against both the local scope + * (prefix "") and the root scope (prefix "root."), the latter exercising the + * BPMN root-scope climb through the mocked RuntimeService of the base class. + */ +class VarUtilArrayTrackingTest extends VarUtilMockScopeTest { + + // ------------------------------------------------------------------------- + // Combination matrix: every scenario runs for local ("") and root ("root.") + // ------------------------------------------------------------------------- + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void scalarVariable(String prefix) { + vars(prefix).put("status", "a"); + assertPaths(track(prefix + "status"), prefix + "status"); + + vars(prefix).put("status", "b"); + ArrayNode changes = track(prefix + "status"); + assertPaths(changes, prefix + "status"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("b"); + + assertThat(track(prefix + "status")).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void nestedObjectField(String prefix) { + Map customer = new HashMap<>(Map.of("name", "n1")); + vars(prefix).put("order", new HashMap<>(Map.of("customer", customer))); + assertPaths(track(prefix + "order.customer.name"), prefix + "order.customer.name"); + + customer.put("name", "n2"); + ArrayNode changes = track(prefix + "order.customer.name"); + assertPaths(changes, prefix + "order.customer.name"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("n2"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareSimpleTypeArray(String prefix) { + List status = new ArrayList<>(List.of("a", "b")); + vars(prefix).put("status", status); + assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status[1]"); + + status.set(0, "x"); + ArrayNode changes = track(prefix + "status"); + assertPaths(changes, prefix + "status[0]"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("x"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void explicitSimpleTypeArray(String prefix) { + List status = new ArrayList<>(List.of("a", "b")); + vars(prefix).put("status", status); + assertPaths(track(prefix + "status[]"), prefix + "status[0]", prefix + "status[1]"); + + status.set(1, "x"); + assertPaths(track(prefix + "status[]"), prefix + "status[1]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareObjectArray(String prefix) { + List> orders = new ArrayList<>(List.of( + new HashMap<>(Map.of("name", "n1")), new HashMap<>(Map.of("name", "n2")))); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders"), prefix + "orders[0]", prefix + "orders[1]"); + + orders.get(1).put("name", "changed"); + ArrayNode changes = track(prefix + "orders"); + assertPaths(changes, prefix + "orders[1]"); + assertThat(changes.get(0).get("oldValue").get("name").asText()).isEqualTo("n2"); + assertThat(changes.get(0).get("newValue").get("name").asText()).isEqualTo("changed"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void objectArrayField(String prefix) { + ArrayNode orders = MAPPER.createArrayNode(); + orders.addObject().put("name", "n1"); + orders.addObject().put("name", "n2"); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders[].name"), + prefix + "orders[0].name", prefix + "orders[1].name"); + + ((ObjectNode) orders.get(0)).put("name", "changed"); + ArrayNode changes = track(prefix + "orders[].name"); + assertPaths(changes, prefix + "orders[0].name"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("changed"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareArrayInNestedObject(String prefix) { + List> items = new ArrayList<>(List.of(item(1), item(2))); + vars(prefix).put("order", new HashMap<>(Map.of("items", items))); + assertPaths(track(prefix + "order.items"), + prefix + "order.items[0]", prefix + "order.items[1]"); + + items.get(0).put("qty", 9); + assertPaths(track(prefix + "order.items"), prefix + "order.items[0]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void explicitArrayInNestedObject(String prefix) { + List> items = new ArrayList<>(List.of(item(1), item(2))); + vars(prefix).put("order", new HashMap<>(Map.of("items", items))); + assertPaths(track(prefix + "order.items[].qty"), + prefix + "order.items[0].qty", prefix + "order.items[1].qty"); + + items.get(1).put("qty", 5); + ArrayNode changes = track(prefix + "order.items[].qty"); + assertPaths(changes, prefix + "order.items[1].qty"); + assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2); + assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(5); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void nestedArraysExplicit(String prefix) { + List> orders = nestedOrders(); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders[].items[].qty"), + prefix + "orders[0].items[0].qty", prefix + "orders[0].items[1].qty", + prefix + "orders[1].items[0].qty"); + + item(orders, 1, 0).put("qty", 99); + ArrayNode changes = track(prefix + "orders[].items[].qty"); + assertPaths(changes, prefix + "orders[1].items[0].qty"); + assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(3); + assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(99); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareTailAfterExplicitArray(String prefix) { + List> orders = nestedOrders(); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders[].items"), + prefix + "orders[0].items[0]", prefix + "orders[0].items[1]", + prefix + "orders[1].items[0]"); + + item(orders, 0, 1).put("qty", 7); + assertPaths(track(prefix + "orders[].items"), prefix + "orders[0].items[1]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareArrayOfArrays(String prefix) { + List> matrix = new ArrayList<>(List.of( + new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3)))); + vars(prefix).put("matrix", matrix); + assertPaths(track(prefix + "matrix"), + prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]"); + + matrix.get(0).set(1, 20); + ArrayNode changes = track(prefix + "matrix"); + assertPaths(changes, prefix + "matrix[0][1]"); + assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2); + assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(20); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void explicitArrayOfArrays(String prefix) { + List> matrix = new ArrayList<>(List.of( + new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3)))); + vars(prefix).put("matrix", matrix); + assertPaths(track(prefix + "matrix[][]"), + prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]"); + + matrix.get(1).set(0, 30); + assertPaths(track(prefix + "matrix[][]"), prefix + "matrix[1][0]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void growAndShrinkBareArray(String prefix) { + List status = new ArrayList<>(List.of("a")); + vars(prefix).put("status", status); + track(prefix + "status"); + + status.add("b"); + ArrayNode grown = track(prefix + "status"); + assertPaths(grown, prefix + "status[1]"); + assertThat(grown.get(0).get("oldValue").isNull()).isTrue(); + assertThat(grown.get(0).get("newValue").asText()).isEqualTo("b"); + + status.remove(1); + ArrayNode shrunk = track(prefix + "status"); + assertPaths(shrunk, prefix + "status[1]"); + assertThat(shrunk.get(0).get("oldValue").asText()).isEqualTo("b"); + assertThat(shrunk.get(0).get("newValue").isNull()).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void shapeChangeBetweenScalarAndArray(String prefix) { + vars(prefix).put("status", "x"); + track(prefix + "status"); + + // scalar -> array: the element appears, the scalar entry is retired + vars(prefix).put("status", new ArrayList<>(List.of("a"))); + assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status"); + + // array -> scalar: the scalar reappears, the element entry is retired + vars(prefix).put("status", "y"); + assertPaths(track(prefix + "status"), prefix + "status", prefix + "status[0]"); + } + + // ------------------------------------------------------------------------- + // Local-only edge cases + // ------------------------------------------------------------------------- + + @Test + void nonArrayValueWithExplicitBracketsExpandsToNothing() { + localVars.put("status", "notAnArray"); + assertThat(track("status[]")).isEmpty(); + } + + @Test + void emptyArrayTracksNothing() { + localVars.put("status", new ArrayList<>()); + assertThat(track("status")).isEmpty(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private ArrayNode track(String csv) { + return varUtil.trackVars(scope, "snap", csv); + } + + private static void assertPaths(ArrayNode changes, String... paths) { + assertThat(changes).extracting(c -> c.get("path").asText()) + .containsExactlyInAnyOrder(paths); + } +} diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilExpressionFunctionsTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilExpressionFunctionsTest.java new file mode 100644 index 0000000..791b259 --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilExpressionFunctionsTest.java @@ -0,0 +1,154 @@ +package com.customer.work.service; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit test for the null-safe var:-style expression functions of + * {@link VarUtil} (get, exists, isEmpty, equals, contains, comparisons, + * base64), focused on null safety in nested arrays. + */ +class VarUtilExpressionFunctionsTest extends VarUtilMockScopeTest { + + @Test + void getIsNullSafe() { + assertThat(varUtil.get(scope, "missing")).isNull(); + assertThat(varUtil.get(scope, "missing.customer.name")).isNull(); + + localVars.put("orders", nestedOrders()); + assertThat(varUtil.get(scope, "orders[5].items[0].qty")).isNull(); // index out of bounds + assertThat(varUtil.get(scope, "orders[0].nope.qty")).isNull(); // missing field + assertThat(varUtil.get(scope, "orders[0].items[7]")).isNull(); // nested index out of bounds + assertThat(varUtil.get(null, "orders")).isNull(); // no scope + assertThat(varUtil.get(scope, null)).isNull(); // no path + } + + @Test + void getReadsNestedArrays() { + localVars.put("orders", nestedOrders()); + assertThat(varUtil.get(scope, "orders[1].items[0].qty")).isEqualTo(3); + + rootVars.put("orders", nestedOrders()); + assertThat(varUtil.get(scope, "root.orders[0].items[1].qty")).isEqualTo(2); + } + + @Test + void getProjectsArraysWithBrackets() { + List> orders = new ArrayList<>(List.of( + new HashMap<>(Map.of("name", "n1")), new HashMap<>(Map.of("name", "n2")))); + localVars.put("orders", orders); + assertThat(varUtil.get(scope, "orders[].name")).isEqualTo(List.of("n1", "n2")); + + localVars.put("nested", nestedOrders()); + assertThat(varUtil.get(scope, "nested[].items[].qty")).isEqualTo(List.of(1, 2, 3)); + + // bare array names are NOT projected — the array itself is returned + assertThat(varUtil.get(scope, "orders")).isSameAs(orders); + + // missing fields yield null entries + orders.get(1).remove("name"); + assertThat(varUtil.get(scope, "orders[].name")).isEqualTo(Arrays.asList("n1", null)); + } + + @Test + void getOrDefaultFallsBack() { + assertThat(varUtil.getOrDefault(scope, "missing", "dflt")).isEqualTo("dflt"); + localVars.put("orders", nestedOrders()); + assertThat(varUtil.getOrDefault(scope, "orders[5].items[0].qty", 0)).isEqualTo(0); + assertThat(varUtil.getOrDefault(scope, "orders[0].items[0].qty", 0)).isEqualTo(1); + } + + @Test + void existsChecksResolvability() { + localVars.put("orders", nestedOrders()); + assertThat(varUtil.exists(scope, "orders[0].items[1].qty")).isTrue(); + assertThat(varUtil.exists(scope, "orders[0].items[9].qty")).isFalse(); + assertThat(varUtil.exists(scope, "missing")).isFalse(); + } + + @Test + void isEmptyHandlesAllContainerTypes() { + assertThat(varUtil.isEmpty(scope, "missing")).isTrue(); + localVars.put("blank", ""); + localVars.put("text", "x"); + localVars.put("emptyList", new ArrayList<>()); + localVars.put("emptyJson", MAPPER.createArrayNode()); + assertThat(varUtil.isEmpty(scope, "blank")).isTrue(); + assertThat(varUtil.isEmpty(scope, "text")).isFalse(); + assertThat(varUtil.isEmpty(scope, "emptyList")).isTrue(); + assertThat(varUtil.isEmpty(scope, "emptyJson")).isTrue(); + assertThat(varUtil.isNotEmpty(scope, "text")).isTrue(); + + // a projection over a missing array is an empty list + assertThat(varUtil.isEmpty(scope, "missing[].name")).isTrue(); + } + + @Test + void equalsNormalizesTypes() { + localVars.put("orders", nestedOrders()); + assertThat(varUtil.equals(scope, "orders[0].items[1].qty", 2)).isTrue(); + assertThat(varUtil.equals(scope, "orders[0].items[1].qty", 2L)).isTrue(); // Long vs Integer + assertThat(varUtil.equals(scope, "orders[0].items[1].qty", 3)).isFalse(); + assertThat(varUtil.equals(scope, "orders[9].items[0].qty", null)).isTrue(); // unresolvable equals null + assertThat(varUtil.notEquals(scope, "orders[0].items[1].qty", 3)).isTrue(); + + ArrayNode json = MAPPER.createArrayNode(); + json.addObject().put("name", "n1"); + localVars.put("json", json); + assertThat(varUtil.equals(scope, "json[0].name", "n1")).isTrue(); // JsonNode vs String + } + + @Test + void containsWorksOnStringsArraysAndProjections() { + localVars.put("greeting", "hello world"); + assertThat(varUtil.contains(scope, "greeting", "hello", "world")).isTrue(); + assertThat(varUtil.contains(scope, "greeting", "hello", "mars")).isFalse(); + assertThat(varUtil.containsAny(scope, "greeting", "mars", "world")).isTrue(); + assertThat(varUtil.containsAny(scope, "greeting", "mars", "venus")).isFalse(); + + localVars.put("tags", new ArrayList<>(List.of("a", "b"))); + assertThat(varUtil.contains(scope, "tags", "a", "b")).isTrue(); + assertThat(varUtil.contains(scope, "tags", "a", "c")).isFalse(); + + localVars.put("nums", MAPPER.createArrayNode().add(1).add(2)); + assertThat(varUtil.contains(scope, "nums", 1, 2)).isTrue(); // JSON array vs Integer + + localVars.put("orders", nestedOrders()); + assertThat(varUtil.contains(scope, "orders[].items[].qty", 3)).isTrue(); // projection + assertThat(varUtil.contains(scope, "orders[].items[].qty", 4)).isFalse(); + + assertThat(varUtil.contains(scope, "missing", "x")).isFalse(); // null-safe + } + + @Test + void numericComparisonsAreNullSafe() { + localVars.put("orders", nestedOrders()); + assertThat(varUtil.lowerThan(scope, "orders[0].items[0].qty", 2)).isTrue(); // 1 < 2 + assertThat(varUtil.lowerThan(scope, "orders[0].items[0].qty", 1)).isFalse(); + assertThat(varUtil.lowerThanOrEquals(scope, "orders[0].items[0].qty", 1)).isTrue(); + assertThat(varUtil.greaterThan(scope, "orders[1].items[0].qty", 2)).isTrue(); // 3 > 2 + assertThat(varUtil.greaterThanOrEquals(scope, "orders[1].items[0].qty", 3)).isTrue(); + + assertThat(varUtil.greaterThan(scope, "orders[9].items[0].qty", 2)).isFalse(); // null -> false + localVars.put("text", "abc"); + assertThat(varUtil.lowerThan(scope, "text", 2)).isFalse(); // non-numeric -> false + } + + @Test + void base64EncodesStringsAndBytes() { + localVars.put("text", "hello"); + assertThat(varUtil.base64(scope, "text")).isEqualTo("aGVsbG8="); + localVars.put("bytes", "hello".getBytes(StandardCharsets.UTF_8)); + assertThat(varUtil.base64(scope, "bytes")).isEqualTo("aGVsbG8="); + assertThat(varUtil.base64(scope, "missing")).isNull(); + } +} diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilMockScopeTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilMockScopeTest.java new file mode 100644 index 0000000..449a6e3 --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilMockScopeTest.java @@ -0,0 +1,81 @@ +package com.customer.work.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.impl.persistence.entity.ExecutionEntity; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Shared scaffolding for varutil unit tests: a map-backed local scope + * (DelegateExecution) plus a mocked BPMN root scope resolved through a mocked + * RuntimeService, so "root." paths work without an engine. + */ +abstract class VarUtilMockScopeTest { + + protected static final ObjectMapper MAPPER = new ObjectMapper(); + + protected final VarUtil varUtil = new VarUtil(); + protected final Map localVars = new HashMap<>(); + protected final Map rootVars = new HashMap<>(); + protected DelegateExecution scope; + + @BeforeEach + void setUpScopes() { + // Local scope: a DelegateExecution whose variables live in localVars. + scope = mock(DelegateExecution.class); + when(scope.getVariable(anyString())).thenAnswer(inv -> localVars.get(inv.getArgument(0))); + doAnswer(inv -> localVars.put(inv.getArgument(0), inv.getArgument(1))) + .when(scope).setVariable(anyString(), any()); + when(scope.getProcessInstanceId()).thenReturn("pi1"); + + // Root scope: the process-instance execution that findBpmnRootScope + // resolves for "root." paths, backed by rootVars. + ExecutionEntity rootExec = mock(ExecutionEntity.class); + when(rootExec.getVariable(anyString())).thenAnswer(inv -> rootVars.get(inv.getArgument(0))); + doAnswer(inv -> rootVars.put(inv.getArgument(0), inv.getArgument(1))) + .when(rootExec).setVariable(anyString(), any()); + + RuntimeService runtimeService = mock(RuntimeService.class, RETURNS_DEEP_STUBS); + when(runtimeService.createExecutionQuery().executionId("pi1").singleResult()).thenReturn(rootExec); + when(runtimeService.createProcessInstanceQuery().processInstanceId("pi1").singleResult()).thenReturn(null); + ReflectionTestUtils.setField(varUtil, "runtimeService", runtimeService); + } + + /** Variable map for a path prefix: "" -> local scope, "root." -> root scope. */ + protected Map vars(String prefix) { + return prefix.isEmpty() ? localVars : rootVars; + } + + /** [{items:[{qty:1},{qty:2}]}, {items:[{qty:3}]}] as mutable lists/maps. */ + protected static List> nestedOrders() { + List> orders = new ArrayList<>(); + orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(1), item(2)))))); + orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(3)))))); + return orders; + } + + protected static Map item(int qty) { + Map m = new HashMap<>(); + m.put("qty", qty); + return m; + } + + @SuppressWarnings("unchecked") + protected static Map item(List> orders, int order, int item) { + return ((List>) orders.get(order).get("items")).get(item); + } +} diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilTest.java new file mode 100644 index 0000000..3ac95fe --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilTest.java @@ -0,0 +1,327 @@ +package com.customer.work.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.impl.persistence.entity.ExecutionEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit test for the array tracking of {@link VarUtil#trackVars}, using + * map-backed mock scopes so no engine is required. The parameterized tests run + * every scenario against both the local scope (prefix "") and the root scope + * (prefix "root."), the latter exercising the BPMN root-scope climb through a + * mocked RuntimeService. + */ +class VarUtilTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final VarUtil varUtil = new VarUtil(); + private final Map localVars = new HashMap<>(); + private final Map rootVars = new HashMap<>(); + private DelegateExecution scope; + + @BeforeEach + void setUp() { + // Local scope: a DelegateExecution whose variables live in localVars. + scope = mock(DelegateExecution.class); + when(scope.getVariable(anyString())).thenAnswer(inv -> localVars.get(inv.getArgument(0))); + doAnswer(inv -> localVars.put(inv.getArgument(0), inv.getArgument(1))) + .when(scope).setVariable(anyString(), any()); + when(scope.getProcessInstanceId()).thenReturn("pi1"); + + // Root scope: the process-instance execution that findBpmnRootScope + // resolves for "root." paths, backed by rootVars. + ExecutionEntity rootExec = mock(ExecutionEntity.class); + when(rootExec.getVariable(anyString())).thenAnswer(inv -> rootVars.get(inv.getArgument(0))); + doAnswer(inv -> rootVars.put(inv.getArgument(0), inv.getArgument(1))) + .when(rootExec).setVariable(anyString(), any()); + + RuntimeService runtimeService = mock(RuntimeService.class, RETURNS_DEEP_STUBS); + when(runtimeService.createExecutionQuery().executionId("pi1").singleResult()).thenReturn(rootExec); + when(runtimeService.createProcessInstanceQuery().processInstanceId("pi1").singleResult()).thenReturn(null); + ReflectionTestUtils.setField(varUtil, "runtimeService", runtimeService); + } + + // ------------------------------------------------------------------------- + // Combination matrix: every scenario runs for local ("") and root ("root.") + // ------------------------------------------------------------------------- + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void scalarVariable(String prefix) { + vars(prefix).put("status", "a"); + assertPaths(track(prefix + "status"), prefix + "status"); + + vars(prefix).put("status", "b"); + ArrayNode changes = track(prefix + "status"); + assertPaths(changes, prefix + "status"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("b"); + + assertThat(track(prefix + "status")).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void nestedObjectField(String prefix) { + Map customer = new HashMap<>(Map.of("name", "n1")); + vars(prefix).put("order", new HashMap<>(Map.of("customer", customer))); + assertPaths(track(prefix + "order.customer.name"), prefix + "order.customer.name"); + + customer.put("name", "n2"); + ArrayNode changes = track(prefix + "order.customer.name"); + assertPaths(changes, prefix + "order.customer.name"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("n2"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareSimpleTypeArray(String prefix) { + List status = new ArrayList<>(List.of("a", "b")); + vars(prefix).put("status", status); + assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status[1]"); + + status.set(0, "x"); + ArrayNode changes = track(prefix + "status"); + assertPaths(changes, prefix + "status[0]"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("x"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void explicitSimpleTypeArray(String prefix) { + List status = new ArrayList<>(List.of("a", "b")); + vars(prefix).put("status", status); + assertPaths(track(prefix + "status[]"), prefix + "status[0]", prefix + "status[1]"); + + status.set(1, "x"); + assertPaths(track(prefix + "status[]"), prefix + "status[1]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareObjectArray(String prefix) { + List> orders = new ArrayList<>(List.of( + new HashMap<>(Map.of("name", "n1")), new HashMap<>(Map.of("name", "n2")))); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders"), prefix + "orders[0]", prefix + "orders[1]"); + + orders.get(1).put("name", "changed"); + ArrayNode changes = track(prefix + "orders"); + assertPaths(changes, prefix + "orders[1]"); + assertThat(changes.get(0).get("oldValue").get("name").asText()).isEqualTo("n2"); + assertThat(changes.get(0).get("newValue").get("name").asText()).isEqualTo("changed"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void objectArrayField(String prefix) { + ArrayNode orders = MAPPER.createArrayNode(); + orders.addObject().put("name", "n1"); + orders.addObject().put("name", "n2"); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders[].name"), + prefix + "orders[0].name", prefix + "orders[1].name"); + + ((ObjectNode) orders.get(0)).put("name", "changed"); + ArrayNode changes = track(prefix + "orders[].name"); + assertPaths(changes, prefix + "orders[0].name"); + assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1"); + assertThat(changes.get(0).get("newValue").asText()).isEqualTo("changed"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareArrayInNestedObject(String prefix) { + List> items = new ArrayList<>(List.of(item(1), item(2))); + vars(prefix).put("order", new HashMap<>(Map.of("items", items))); + assertPaths(track(prefix + "order.items"), + prefix + "order.items[0]", prefix + "order.items[1]"); + + items.get(0).put("qty", 9); + assertPaths(track(prefix + "order.items"), prefix + "order.items[0]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void explicitArrayInNestedObject(String prefix) { + List> items = new ArrayList<>(List.of(item(1), item(2))); + vars(prefix).put("order", new HashMap<>(Map.of("items", items))); + assertPaths(track(prefix + "order.items[].qty"), + prefix + "order.items[0].qty", prefix + "order.items[1].qty"); + + items.get(1).put("qty", 5); + ArrayNode changes = track(prefix + "order.items[].qty"); + assertPaths(changes, prefix + "order.items[1].qty"); + assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2); + assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(5); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void nestedArraysExplicit(String prefix) { + List> orders = nestedOrders(); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders[].items[].qty"), + prefix + "orders[0].items[0].qty", prefix + "orders[0].items[1].qty", + prefix + "orders[1].items[0].qty"); + + item(orders, 1, 0).put("qty", 99); + ArrayNode changes = track(prefix + "orders[].items[].qty"); + assertPaths(changes, prefix + "orders[1].items[0].qty"); + assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(3); + assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(99); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareTailAfterExplicitArray(String prefix) { + List> orders = nestedOrders(); + vars(prefix).put("orders", orders); + assertPaths(track(prefix + "orders[].items"), + prefix + "orders[0].items[0]", prefix + "orders[0].items[1]", + prefix + "orders[1].items[0]"); + + item(orders, 0, 1).put("qty", 7); + assertPaths(track(prefix + "orders[].items"), prefix + "orders[0].items[1]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void bareArrayOfArrays(String prefix) { + List> matrix = new ArrayList<>(List.of( + new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3)))); + vars(prefix).put("matrix", matrix); + assertPaths(track(prefix + "matrix"), + prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]"); + + matrix.get(0).set(1, 20); + ArrayNode changes = track(prefix + "matrix"); + assertPaths(changes, prefix + "matrix[0][1]"); + assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2); + assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(20); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void explicitArrayOfArrays(String prefix) { + List> matrix = new ArrayList<>(List.of( + new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3)))); + vars(prefix).put("matrix", matrix); + assertPaths(track(prefix + "matrix[][]"), + prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]"); + + matrix.get(1).set(0, 30); + assertPaths(track(prefix + "matrix[][]"), prefix + "matrix[1][0]"); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void growAndShrinkBareArray(String prefix) { + List status = new ArrayList<>(List.of("a")); + vars(prefix).put("status", status); + track(prefix + "status"); + + status.add("b"); + ArrayNode grown = track(prefix + "status"); + assertPaths(grown, prefix + "status[1]"); + assertThat(grown.get(0).get("oldValue").isNull()).isTrue(); + assertThat(grown.get(0).get("newValue").asText()).isEqualTo("b"); + + status.remove(1); + ArrayNode shrunk = track(prefix + "status"); + assertPaths(shrunk, prefix + "status[1]"); + assertThat(shrunk.get(0).get("oldValue").asText()).isEqualTo("b"); + assertThat(shrunk.get(0).get("newValue").isNull()).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"", "root."}) + void shapeChangeBetweenScalarAndArray(String prefix) { + vars(prefix).put("status", "x"); + track(prefix + "status"); + + // scalar -> array: the element appears, the scalar entry is retired + vars(prefix).put("status", new ArrayList<>(List.of("a"))); + assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status"); + + // array -> scalar: the scalar reappears, the element entry is retired + vars(prefix).put("status", "y"); + assertPaths(track(prefix + "status"), prefix + "status", prefix + "status[0]"); + } + + // ------------------------------------------------------------------------- + // Local-only edge cases + // ------------------------------------------------------------------------- + + @Test + void nonArrayValueWithExplicitBracketsExpandsToNothing() { + localVars.put("status", "notAnArray"); + assertThat(track("status[]")).isEmpty(); + } + + @Test + void emptyArrayTracksNothing() { + localVars.put("status", new ArrayList<>()); + assertThat(track("status")).isEmpty(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private Map vars(String prefix) { + return prefix.isEmpty() ? localVars : rootVars; + } + + private ArrayNode track(String csv) { + return varUtil.trackVars(scope, "snap", csv); + } + + private static void assertPaths(ArrayNode changes, String... paths) { + assertThat(changes).extracting(c -> c.get("path").asText()) + .containsExactlyInAnyOrder(paths); + } + + /** [{items:[{qty:1},{qty:2}]}, {items:[{qty:3}]}] as mutable lists/maps. */ + private static List> nestedOrders() { + List> orders = new ArrayList<>(); + orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(1), item(2)))))); + orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(3)))))); + return orders; + } + + private static Map item(int qty) { + Map m = new HashMap<>(); + m.put("qty", qty); + return m; + } + + @SuppressWarnings("unchecked") + private static Map item(List> orders, int order, int item) { + return ((List>) orders.get(order).get("items")).get(item); + } +} diff --git a/customer-work/src/test/resources/application.properties b/customer-work/src/test/resources/application.properties index 50bda6c..c888e3d 100644 --- a/customer-work/src/test/resources/application.properties +++ b/customer-work/src/test/resources/application.properties @@ -11,7 +11,7 @@ management.elastic.metrics.export.enabled=false # Set debug level in tests logging.level.com.flowable=INFO -logging.level.com.flowable.local.work.model.FlowableModelTestUtils=INFO +logging.level.com.customer.work.model.FlowableModelTestUtils=INFO # Disable the timeout process in the tests flowable.external-system.wechat.timeout.process-definition-key= @@ -21,4 +21,16 @@ flowable.app.resource-location=classpath*:/test-auto-deploy-apps/ # Email flowable.mail.server.host=localhost -flowable.mail.server.port=3025 \ No newline at end of file +flowable.mail.server.port=3025 + +# REST: incoming calls (simulated against the Flowable REST API) +test.rest.in.base-url=http://localhost:8105 +test.rest.in.username=admin +test.rest.in.password=test + +# REST: outgoing calls from models go to the test REST server +# (service models resolve ${propertyConfigurationService.getProperty('customer.serverUrl', ...)}) +customer.serverUrl=http://localhost:18085 +test.rest.out.base-url=${customer.serverUrl} +test.rest.out.username=admin +test.rest.out.password=test \ No newline at end of file diff --git a/customer-work/src/test/resources/test-auto-deploy-apps/TST_APP.zip b/customer-work/src/test/resources/test-auto-deploy-apps/TST_APP.zip index f1070c283ae4373371c79a3e5a5726221c5cbd2c..a0d36df14338e88d68e55de84f7c35582e2f55aa 100644 GIT binary patch delta 16687 zcmZvDb8u$c@^)-v;)!j0Vq=ntZQITp+qNgRZQHhO^P6*S)vwOE-`)T0cU3)aRqxfk z8mphqy#y{!0EU;70tG_@f`WnqQjtH7g~tQL_gMEbAPED&UvNnvj{`NptbQi`iq#0D z+M@{#G-%TnL(6^lu$n7{r`$KRcyx1dCdnQFmNk?Jlex({;FJi_ht~{{06r!g$E-^7 zS%=vRQ0_biR}tFZ<&^0laxGW7H9WP=W^&X8|L$2KQk`Ol5Mo%OPKo+UZ6l4^iv9t> zcz2gH*|@Ev_M*Tj?dMP()FDUItjY+P*x2(XzsAs44WsP{J=^5L{k1|3C}g>?a*idl z2zroZ)`+`Oen>gL zWe3&0Tb_dHDf-Q!si!tYwRfWf7vmUW*;e@mA}0k2#WECT#sLfj0%AD8Z4O8H%tKla-z=aR2ZYqw8$G1w79JKUzNrCz0zP;e(CZlUQV+IuHu z`xKJu&>tn@fQ)1TOv?c?cilaVScX&k;n^f+f?_07yvZhWD}AIK+DRZOMeno|;u+!z z(nmC1qeY%%^ZXZ)kK6d^zzNx1Hbik^idd%?_>Z??Ad(%c<*wpRWr9iBCS{({M8|NVpD$I;=R&anG1=8W;k(aB+ToI^ZGncx z^H@g;gGNRy1VLclEV5}NV)|cDU6r9A*Y_H-;e2#?p{~Q(O;F+VuXOhHZEJ$M4XQH^BikSh%-gbjZ%Sj{K84P5?4)TqBWtLBm58GjxZ$oq`}=ngYc6UwXZsXhi(`GrGQ4zpcP zRHbc?5*SR zSQ)91sNAR!4BxlZO_DcRs(0)jIr$*z`9Y|WzBag8WsH@D)xQ%JYMxnXfodv=L)G9b zGv1XR&`gjLp&lF!TFH72w?v<_FBWYKhw{L$%8XGjev*mUJZz2&mc~$t^ob7=%uk@z zH~v98rdDwnr1dR?y}&|ycM`sY+T?I`3;aoL-d3^*ly%|H(G)|Fh@DO2q=uM|Y2~oT zGN;cd*G!El-ODIcvXaCvqI$Szc(vn=Q0H9-G+qx_BjB0feXRUg^wz|;sQ6Kdp}qw+ z=vY}!gf#vMv1N}Ey{j?f?brNK&|;otxPc846dS42tLVtScO1 z8^0lFy~;3sw5>!$W4)Za+#$}#-9PUL6VY_8z!6!uY<13@6eEP}WZpl7KNQKWN7E04 zBw~c7fHtq{`^rAS1Fks)Ex%YNyABBh2u;;dZK)zQJA7zfi5@FiKssiZ?$p#zK*(|j z?0W@`UYd&8n9D_mfuu z?xx4(6REK({$(~2sP%N9>ST|@f%({ykatRS5sCW^?(LB8aFl|x`9wL0IoC*zTz%LT zS3JM-vtSE?$r=0?z|7-}&4$$@Xe%3d!akR$68&8WRV%YfzuuI@hVMDRe z6^kPkk1!0=8Gdt$vwafNWw@#1m(xE9l{Z=0U^BOR`PJ5R#`kgixzl(4>YR3lkBZ9~ z-U`kC)1t0PA{*E@(m-2^N z)Vaton=Q&ZwI*6 zf(yu-AcI%_4rjniWbWiq3I%e9P2R#J9Z!WSHtljD`h_!|zA%mc3uL=rUHDZO7icl0 zu}@SJuX+`b`BGnA6EBx=KHZEsfjd)adxa?E9N{Ca*WERZ*KY=c?I9kXXj}J>?hkJY z?Oy8nqm)9}b|P?Cz}v(0tvBw$@a~YMd7Q1hQ)b;0i^Jfo-?Z#POL=s|4(5+W>l1O2 zQPt$%kw;|V4lC7(xghv`zm!DS2W!EMA`7QogqymH5!3<|LiP)H%Adu@TnH@S(1A+Q z*OxGyhUf7Jy>oL;z!wUYjhu-eK`mL)6&-+V{GM%JRPPEyF~^i;Q&Vrg1qy&9Z==wB}iq;E!Kj1P{U+8R$V>>3LMaEikA@ zHcg-c3tWhb0Rli~oV);--HScWu{EKVLJEp!EPB(8QcyO59)0{O^~!q}H=*4*2Zusn zfm+FrA3Hwt?UK{dMy447sOGfJv7iC;Zuq+GW?9>0Y8DRaLM^ zW`~qtD0tORT3PGiehbT}o23g$;p0et3XtzKo0pY9`fx4Eo6vnUrnER8{XV!K@<)ZR zt(NUA-f|as>Dn~_abLvQRGW!4vyp4by_3#6zx`tc;VmN+2nVV0=D55CzgL;e_SO&F zgBhSi0cE4}aqyd!g7o65Q$VBqVY*2W_0lu#Of(V$AYkaq!7)Pe0(%|g6wc+ojcCL^ zLC?kB3`{ZdV=V5A{l$lcHz&S9-y3orXB7*MsWPN$FJT3R6jXra4c6FLMe02KLmn9w z(NnuiBb9UwZ_yk~Ey8k_0gr$vbTH0E>{4UpNwjIR}N>haoJTrKRVwCUAYF z^)7>A@xFsT8me}UdTKr<4we_b8Vuw0NVTzCY&$=xrUixi*wCeFWS);7&48AUH=NG= zH7_`Z7vPx1)+mWomgMrimMv6ho)y=|Wmh_$#~1xq>;hD)6+mTUA&#^LlwG>taq?!@ zwTpIU%51q&n11pUm72l@Y82+wLUJj(a|`nonqeiC79TeYzS5%zL^lczuhpnwOOemW$EfBi{wA)&l!aS$qDfPi8}{yXiZBO?JM$waR*AO(HF++tDvkUbc5 zZbJEms3hP~qP#$o>eCQK{vhTn`f`Og1Z}7`@4WDRJE_Lx+$C$t?iaKWH}#hKq?c^c=ZldN3Xo6T&=_pc*3n?bWX&O1xcn|f{@?`J|>uNPta{NvWZi0xmfO;xL zROStC9z;|vUBV-*_tR`ZY@^QrrfROkFB?nKDAr-P43ZBlmQoKhDR9Wmd22HaM~#>G zrEKXn1a(e%OC&TdcwSi!+hCM6x%rI?;RfTI5pLWeW;0I`oLyH85m8c&Fpf$or-| zc^q0;YJnU$1~H;$Q}1T#)hwo9J$bWtpS8pq%*QtIQ8z7jzr5*X11fj6ySVP=06cf_ zm5$+n69>UJK4Na;+Sbb#1$><=xJUYLHg>eIEEtzbNt3M8UW5(#>kTX14bZWI(<*Rp zxj-e>pw7e(xR3Wqy7Zo88&=)c1zCMrS^*OYFpk|g0?YSUeF$d@uWLg$9EzqC)$B}A zU6B46(lfRp7~E2DE*$KUgM$R^qToiwi*`c-jYKN-umqPl8u)GZD~3#mRfrVUT_e_W zqTEbV`EuN+WM{7!e_4@z<+cVP2oR7s^#5-~cmO6kV;g&`ieEBPef&rt)U`)o>KzY< z$Y8OW&YK>0FSFEQ+*RM6I!d7sqp{oFYNgg< z)bB@`mq&GxxopR{s1%V$K|*aBz!ec!NYob1bEyjCj(#M#vCj$>$|d_8QLqM@oGKPJ zVgo?M=bxfNf&~b2N1zu??+IY`WI|EuU{U-QP!<$S-|^bP_K4z8+TBne-W7*AhK9f# zO5m%`9f8LOf4np&oQJha;r;Rrz)SWl+yHOEUMDYTvPDiAsq8{{me2&?HOw}GyfYr9 zye0*@|MacOVO*>x#0mcNSNQ!HQ&&ze)(j|UB!*1_G+HWt9DegRvC}E|I$v2D7)$Ng6{s~yQMpP;IHi~6yJ;lhZ zHgiGp|V{!-P|v5bh%pE@`2|EqK9$nXG+f2*oNLB?i{0q%o3aFQ*izG;220oZ^~>ckNYEa~-{4PVx~-gjc(B zHJbJFh%838<#~Rz&`5c4n<61H$yUZK_XXX^!gdth2VAmm>iNeSM6~Z&Q-$)XvJbh# zVXli>@e|rJ47LG0PHoLemt5&Q;Jba&jS;j?$;GhW!CYKqarC_YbvyDlpR#2+B{-WP zAZZm(DdLKP;E%cru^VsUt2!Nx*9hPCp|@fz0Dume2=96XV9|BJswiVkcIy^u@{0*M z9`sdCXd4~dmi4!Ahg=)b+7qNoT5ixEzQHTmXmiF~1ubHfBzS)*+|uwI`cdpMMb8PA zd1{5i4qHmGG~=Z3P1&w)``xgAKFPd$LXpvkMZB$fxJ-WQ4fI{VMbpq8nt~d820H0# zm^X6bD0rKp{cq1c2H6f*`DpfN}R$Sv~irkGHEw;Jl;6k!gqb!Ar|J4zNO8PDa6Ew9iC#&Sy9u*0m zYRgDvKi!wWu{+nRPO4+Bv~NeA)jTsENykO{#Sj_2_ecE~?m?AD%Z~azWCX7uMg-wX z^^o4w@HaPr1ZdDq*B0RBU_0! z8WPY>LZ_&ctSBL%o6ISIrr3{2G;p`HZ8Z6fjcw}!>@TA5Y37&-IyP~T=A5ud#)dntF1({o&*G;BlT_|p#4Or)!*u9|#64lwHV z14NfYMiMjg)jas6piDxHCj!ORhLIoY@6Txc91Cl4W#adnAAMreTWJUZjlnRKi4ET( zB2kP11g|3>hn5gCetowlx7K-Xvh8QAnl&^SNFOM8R%~dSy`@JgF=*e+M|K9iY7D6r zV1n4*LgS}G7zbJ1-90+@YLhzbn-^Z~f z1Qktdil%dpGT@aZ>o&b6$i-Opm`p}B0k`f0*63OmydDtpb;Tc&CQ5tl5kF{e%2aTmY>#FYw-vkvmF><~)fS!VUak=op z*0Yn3lfIQ4)(@#dhUEDZkBu7z9O|2PPokFy(N>?UG*8MRH)|!`W%V0r(P2o^5M0ax zMz!ONYQ>+-IPq;hTrpZ5FdY3}>nL^NdyW_Yp4aJIJ_$w0mVL zYR7vt9!GUReaw+7lt}V6rK&tHV7Y{qTdKEmp;$K@yBdyXRDOl8a*>GS4Z(?^Z6|5f z9-)*RZl{N7PFws`x@_8@dU`ITaw)>sc$;lp2D#+S{1ce zG@k2bI^&cGk{f~tC2v3G0(IOH3TBM4sA#G?s4lX>`2rWlyG=1VZ!iW+8Kg>gyXr@G zKVh)-ngqSRLW?eVS+XGxXR?s7iapDapsQX3?r#hnw_S&L{2?Waa(+&SjsPI6g6JaciBx}T-HNdS`cZckxsrD&YaqK4J8lbG@s+^gzJ2K=1rP3C{rv$}umZT-Z1%#&tK$-X9X6aE+9PD+g`_v*9eG6Z9D&OFMJ2Tj6vtbb2?p*f% zZH+kdqBF}{b@inAaSpqysr-ue46s@&K8=$?WVe~%;6P7}KYFrTaGmCsf~LNA`N30k zHiPhii|&NF;3P#m7QWEGF|4hNp5NM`fZN$L@l|ZKxH*Mwb=S)a;AT{rt>M;EetN%4 z{+yZlEfI+ZpOX3$Z7x6r2Dn4Vl^_l}r;i!6VnPu{|NQqaB{CbR29g!frM#`g^XTZP z@4pVdEmMZJ8J@L{F;-k8#ivhn{T1p*PNNZbz=43kasMjwkQm^BV)lRgHh2z)+E)5e98d2Qa?i_rrOId4!&3xkp<9LdJ z?L8vY4z?%%x`=P&C%VwkwwHCv=SrCD8PJi2q8jtU1Ee$+2w_ zkXjESZ4XD4c56s)Kkw1i-P9s(^kdI43|&E2H^3&Y3Q`TCMD58B*fA{6HW9T-JD`-= zDwxL|J8{a0a>|Qu`IP~1u~a1wTFBP;$X&p5_Q>=-doK&3aZ}E#%{R38P4Hh08}W=rp9>56ALX1%nv{P#uaSlutn;yxUN0v!V#9=lJOJLaJ(@ zRv-8VRJ*bm^z>oxCkgdrm586{ ziKb;DP+6sApjxhuSQAL&KYYK|_`$}Dj8_RqK@?*})BF1XdjnaU5<_*>Zv68lL&KDu|#W>f!NfYi@^q2ifgymJ63aTX~&;k=1o}keW zJ(UC9X1IbBPRJKiG|Cu@3ThDX7J{djhsP(Mb_qMy;S>8B`73$0j+TOG@^)G7%ymv-47X>V2} zf1Gb)+hhZ-b!<%?qFxfeV=8yRI+wLHg2}%uCla1#i#h?lQUNssn?SYlN)a7@#|IKn zn?whDYtRnvKB0!1g#@x)r!%jkb|iHB6uD}=@z-pAuS$Ha|fH$KyC(o+Cv(D zH)=>6%QlDhv1zYb4x>*_X_y=K=pWfo{i)Qs^oj`B*J&)AYo1hTklcc?F>Dr@vJ>9E zpU@buCFLfE;T|Y7wsD!BZ0n~xI(*n~h2L)tCR}n(A4Xpkb$9o_ldbG%9_J67xvQk2V&QSjmKjvE$aIk=DyTu9lcbNaFcb=hx`Jom>{_WL=&}pL=W4 z^V~~QIDmE)EjS4Y^*4d6J!ED*x^b0enc}}CR>~MxUWw}VBN2Em&K=65Ih!{unEwVq z!ipglD;N;gm}gs84zkV_>1T_DRtWIxdb+>?p$nGsEX=w{^8FC2KhF?T3c`RLGHO20#(gzzc%5NtisNFgD05me$nl#{nTnO;QbZb} z9&&v?O6q3@Tjv(2cP$$s09NO7&b4oirw2bDmS*aO%ae8oWxlMVS5EF6OnL` zzd;`XM?zhgj|v3nVO!$H zLZiWjX3gnh)zYL+MaUuJM+Vd`5x&Yqn**G$@ym$6k zY-@yWcF&f)oBmuo+3Lyry}0dB8fp79|T}yCKfq}{z)C!K?)}}LRL`$qt zYa!1bQJBIza9$Yg$lly&? z(n&SEW}CmxFEER)e_50JouIpCk6mBHi66Dbi-0;R0TI*Bi)*px=P}oB94y|Dr)?9I&7yf+;MXU@!AX zfgP8rf9|{@>nb%Td;q5nQOzjgGd0DOI)Ya-W^`pk^P_8o<5D&N95VE+FJS?GJG-c9 zFZ{;sdt2{(aQgc9>z#{>2dnSW6!6DniZ#_EdrU{|zea+RN`%IKc{lKdc>(D{n6c7Ml)H^+f-7ZI=Dlz1;XQt#-zCyQ~grLG}eEL@}T za`P2Dd<8Dc{rMm*H`PCpFi7MVaH{T!Bf_+Ay~!MKzr9;ZcQ}$;;)j_B%HAN)zo5~0 zrRQ?mWpXxKV7}kk9jwl1{wUu`uk{`WbaW?vg#dIw`T$g122Ckvog&3TzbJIaMrfrz zg=N0~7%+UiAWrq#JH{}~#y=4JTsvwn2e}h2A@KGA!JcrJ?0VS*32Ia@un^tpbW znmS!YLz?*mfhi|;SzYMCy)O7SP@LPz}{&NUuV6BA%5(a92JQ6z3*B2U2HZ@s#a z2Zb4$)DkN~E3pP0_W6cDDxZfY_c^(%VWsq)5) zW#_7m23f$=P&_Jg58NdO(T>D-cQ$*>#2`3+*c4$AKCguW&-(~%)Uk>oy3`a*2Ugoe#gK^5Jb?*>^L&_EB0Pvk zs6zmnM}*It%fvQaA9IGX;v4xQ;ttq}bK?lgjCKET=->gvSu4q4AW# z@rCG0GRpNxH%p)IB7!)y?OAU3F8#m)S4RNe=DVCmV+MYpS1{oA(Yq*V`922BQq}f9 z`IE51q$JfXCV#gaa@8fHBCkc(ZGtCpmu$Y85njD+RPE1>jwqh*0+fMUiwI)lluO&Iiq8TGcUs$|~38B4`~S{075U=bD0Z&BFqC zn0HgPYCRTet>YxeijO08(O!KM zEV=Zwnkq&uP&5s3t)!m)E-%&@6hk^xeFzj`3xCz-)*WM8SgMrkVR z%y=Z;+K~avs-`9aQP>FCoxsQbXL_NBEGP^%2ws)T|H_OywV=krwBbPe8#4B?^PRdJ1=?9@g8$s7k!k? zrn!2%{S5PxFd{*(lBz8SIFL_RNN;G2cSekB<31^$WXmavn9Z)1R<|nZNs+|$Kx~L5 zbk7M}w_X&>Tv?p&OFkU88=lU9JCm|UlP{GJE|riioh-y^#C_`9XKFuT?GGxwC7wB+4M+ZNxOep6o~^^nY&s>6)liKxF=v{A zjTtFtsNFGuFU~#RFtkvby77&cjZT?RojvYf7dDELj0we;eMoxO znohTzoZ+r+maX2v>?vUYH(eR-+*t;Kto^xj?MVVbD@iDYg;GdZA}&&!Lzh~dqcmEa z64Vd%Pfs(z1Td`RHfscbM7>OY+|b_5^;ktBPcM48DMH-OMUehnqb8tqAC8`Je+uB4 z)Mr8*w8E7icezt7C6sY_*%ain;%L9r$Z`*SNqk}VGa%^&?pdJnyMwsD8}{wKa0_ru zbfRgRV%N2;d{mH7=56=MBxMI;ttZ4xpnk0Z{r71?r!-L8e|T-G;ep_IOkQ+HlKY^q6{Lg6j~7AsgE*+ba6LFZxKT z!zt4u_5eS71B%AMCZM|Ou5kh;>TXPh3KCejT=0#bAp*DG2QnZnPDqvV72PgQO1&`F z!gP|823qDd_4$gMvG7QGk1WUT=+67SoZ}N`XBO98V|=;!w-UE=Vl*16Ugy19X+p99 zS~%n7&(M7@mLhbk2BCrwCq_t>0rPQj{2_h>gt&{q-&2J6tHKl>rQhGa{xm=)p9fq{ z1h=569D|C0bz|spQ2PQgmuz*iE#6LxGk^-+>0_mWKAjp8;;}jQx!Y@ck;p<*7cxRA zQ7A>_oQoCYeQxyC`aop3d#=4OMp!KXmTJnI&B{eatF)B}oC~Lm90OxW!b+fYG!?!I zKQaeGV1Ljs3XCPz2@Ve6pfwYdcH#69;*b$FQ&2w8bz>7d6z_xbw9r)HqK+Lf3whS> zYPRte4f#JK?i6KjW9{&=Ab1aXSg9tXsEe;xtC!BODvS*QsXNAqev{`{@QH^8Xcp8{ zA*1Vyvx4Vhl+RK&1boXn++-i~P6MA~>7DekLBHb?b>yqVnC%Fo!RZyYS;lUavv=m>-O>)bqwe`}@t;9(NEU!Am`3=-J zmk>Vs1{IA_SZ!=fCZx~l9hB(@I{zr@Gwx1F>-aTLs=i~yTnt*wtD!E=0=0k1%Xz4f zfgDS0Gb0bn5lORMjgGGb{HKO_>5Q%qh)-OU6ev@*PyfO%)Nq*qt8W~4xc4uBs%;fH zEomssdi{LpT${f$*K7F>5IdEKbl}1|0#ZL|-o-7qyR*W#NSRab%h2Ynj!h}%1r*0t zcpr&aaxgPlj>;82n_aZRoK>frZA(KG*nAZPNL8|tN$eSor6F!_ zuSoRv&<^G9qeECQ<<^xhbI763*2ZdHmK92FAo|(0MoTBhX-!)N0ZlJs^^C9c}5f~W`D&49>l@6?0V_tmEwc?Rd0D~QGK7O>I!!Gj$EridY|8pu_W>R4zbE4uP~oa*(9$# zuZy6Y#17gT;8hmEG>s3g08IS=I z5^+9V7fch7;*Msa2JC#O>Xi_6FwC*N-rUHhNR=}XKn+ju`L`;^iE~!%nJqoW@TL|2 zw1Gc&PbxR+_+Uz;YxmYUYtUTS)E(b*8=ep^>}1y1DzVh3>>^ifj2l}gZdo-pRIip! z&DonSU}l?saFJ?VstEP}R*}I!OPbI^|g~vuFo_^xEXKAt9M7T7+P@8De=a+Q} zj5bA+Z7vOtl3`z#NQ1sX3+w2r2eB`6ys|bG)Pjbs(STQ!H$-_)h+1^FpqftT0o5g- zW#w5ig+?@G^owhaJTH|{9}`)&`di3#v%UhW0L<_|(jtHD%={**KT&i|D;cxQliH9E`)oJE?%}+!K^!IvY@9DGoWmAZGz?t!bZT5GF&p_ zB#IrTfa=u7T;S`beNdsj9RR2B5i2zVv8g`Pf%*)gc@70bSC!uF%V1m3HiFuUq1lQB zr(T~OhJb1c87NL5bhA=!rv;}rMUV~-16HIfx;A>q=63z0q2YM0?VqZpY^&_0jyis)PdG3uAUv&m84(^=LzD=MtQzu+XW){|9B zpMYF7m@5rwn&mzw;o+~M2d&THLGX2>V08DxTYLe{rS&lV#%W*bB^xlZ@Cl`U)%yf4 z)G``|bWdjden%3r{}%aq^P3gHNOCXcEUB*JD#7J9o8!70itsM+!vSpe8d1ErbaG+5%B{3KgERGU z$is71SdNzA8;dAFD*K1~NAsvg?AD3CK4KCmV#5l=J1sLsbU-vRuqL1EIG76(N1}gH ztGERSgjBODJv0_lP1jXN@|cudBCL=0=x+7QluxQSvu}c_8!*Nd(lk^ z=_7nhdZfp^wZdWfnq5JEv@tAfBc@E601oUVUROk&?8Ymbza!UH*f*?5%W+{OQXR%6 z%%)mm5)Dy{qC}TS)<5j9UT3h{>f$AB$K!X$R05n6lYU@Fa%Ec!)RQp#bo!&h0INa!*)=#i-1|2)uD%ikrrXvx99J{*mta^!9T8 zvHNkw(r*IA0j&CQ1L^xL$YjzBLjWqJH3}$?Njz_=7m&iCtKU&&9x|@tJvIA6m61&2 zETfLI`X1Aee}J*dh?j}-gN*Ka8+ULg#h>{qNK{78~-30!R6nXSK-xc&i6 zfz-YpVy_re`j3+k|A9Z2U=z|SVg(M{eCa_g7)`Od_zTDaM+6qhyM*+D9}$9ObsLav zfdX9JNOW}NE$}%n?+ON-2i%T>nIBuflCYwF*cU*Yc5LoOPsNgz`wc=qcTF*ONilaz zQEysNZ&^`Kv6$gxnsx1jzve&7xyZaE#Y;|2Hj zY`_H7I;d?QjaCBrF&Hv)Kh^h!MR!2Xp?Y*l2Msk-8x8oP{1_Q!6=->TK}m5dS_0Xk ztSA}1L{_F9HEElBc>=d5A?DhG)}_|1);Q3(9_7j{41aA#3B}U#4!WjZac%{b;`h}N z8nX`eKnm#4P^^2c_DB)?g{<|vd-K&w`ra?o^3nPlwm z_M)F)Y*xpD!D!5}K38AH?uIXiH611bzRt6udOK#P`-z$30wz|?*guwAF_%@4HAy$^ zgV1XgmaZRuBdZ8jE88&5;tBvDA=ez)&gbTVGy|KAVEsNI!5cFhYsnP9aso7Jb*XPD zYFA~*Od#xRNrk~kdDT(OP2(Y~R_nA^rtCjQ=V2^^zCGqz#>NKRDmUwiK@I292(v zP4ir(AMl|)zp;*d1EpvKdcm@0_Fx&CqW=_H)#gkvyaDlO2Q5509mmyzS&1QOl0usP z$Po3JFD@ci;G$yVCMYQ27hht(aL#hLKpJm~MZE&6@Rf_;UPwQ|%MeL31zu)B; z7P<=)$qxVYgGq+gk&$3AylQqd%Psm*49jB>?dYB>^{4z08w z`iDVcwtoFBQkj1eYXgi(b$di@U=nR0^bqK1mJ&7^EBDFHl z(<%|L-3G@pdgq1XB7e349 z*YV0GL4PG!&3>l{|J&{M9fZIW$#e@f1n{{40Qi~Q=aCeU10K8>xA98WgAnFYCf}|x z(FA3u3OAELFnaS2As;1qCN-j-MKUTu{?Zfa6}q*74SQ9^u-yCf*8AiO)}iqj2I&>U z7E&u|9xL%}HJ)}dxqC`YdXs%ODW%`*T~0TyL!Rvux{8-uF)3z~RaPm#XeRlFZ8$1P zLFvle>-7O(R_g3=-^cdCzl^@XY=O5h3)G(Z!_0VxdDxc+^dPfY1rmtn<^nV;R9isOKF7il5MhKiB9 z$O&9-vy3dT=9BS}o_?fpz5(4MP#@!EQFOR#d50|o?%m7YOGewgd|iqEjQ7~AbGdit z{5aaL`vUzd^TET0uo;8`0-E9ZugvG)9GG}SXvn`h-wc0uzAKciey%YfeRk@^9(bg& zOg9yy0n^Tr=$g_(1tBqwur`Qi&^VjDU72v(?G_f4Eq4xddStbEcpkaBwnh=};W34w z+UqxJM418I!~?}&_qV5OseAny1vJ9!#zh@(MrGyjn?`l$Q9WIGG;c5`wOf{~oi3yV zJU_l%JVah_Sq+^u4?R9a-|UZ$Gj68s(ypJ1_i7HAtsyR{C;)&NYZ-FkknyP$uU!)C zv&2SVIz;7&=7aj%3mN6JcDcKQ7a2b*ZPzKU)AjahAX=vdJoK%6N5z5VaO0w>N305a z*eEp#QRw8Mf{Ve1Zb=McGEXg0IA8TK`}F6!+gblK zX~rt{t|v)(M$E0GcN454dKXw2GRu2(YTJ2L8~5=F1>Jt^P9PCLC}`79-DxBNXcW{B z?8OM%x8OSae;1-E z$Y{UAbCb1~Kmj3WTupnvH5Z@(>_jSfn;*Kx4J2dcZGRWpjWGNgmly7#fwkm8wWba= zi_i`I6Yhp*C*togdfX{e`WRKkeMnN6Y}g}UfdG?R7B zi}hmDO3X9|f9}}VMS2q4gj*J=JQqs0GIZw_J&#`&bh^iqG#1`LqBj}l6_|%{_Yxt7rPz2ID}{M zLk#_~dFEAB)uL9?Tav$*X@8dHAn{GMBm{rq|2fP8{||~A5RfKlJird;Z_qzy6;S>K5%kf zcs9_1fNb?ljA#`V6m{gp7#JAo{#wdO0fV3c{okCw=0C|h|Nh1=ocJ!M|9s;=o6!FT z0TIT(I^k&iCCWeBAO8j!kO2YxEd>Ju^WP5fzlHc`J^AmylGDW7I{!zAf4bg(gOJ(d zC7f`6{3XOcP2%4m6#oAc;y+&bzl4a7a>fBk7m6=)Ci;7$r~fQzMdB}<$qWmv+#f4BmqOc|XcpWdTBH^zQn?g}28c4-5W)x?-dtRN|k8X+#kRc%yl zY9}49TH{ki`-JAJnC{0aLhXYbTM~_6~lLynlru1%%&0xQr1DC`^h>6x3T$h7^4C&=Y(rhvY2xfy2DEHEV%c=-EVNw-P zQ^D>$!BC@&x~Q5}$a=t|Q5&X%n0o{)seGJc+-{MBei#ohegG-TKtf&QCuIl$0|7OI z0{vb8w^y9t2TYidD-M;A4KfP)_m%%$KLh@L>H__@2j%Z~S7##=8+9XR7ZD>DGX@ho zJNqjw8;1>cw9jpWJZR8*;HIa05q4Y5v;$Y*{l?TRdqE|vO3@8nQzfLcmC&z0zZawG z&2;2j=(ge&meFt8{tytvYsj8VneS24Vxa_oR$HsOsReWg=^f4h+Rr~<+Knq5ylS`^ zGD!%P*u$wN2PpO2}8psi*GSLCbRo^u(hg@<~)by zX6~$`*cVsht~(5D{ALM*RTSdS>-8|)XM4KE@p(2pPsa*wXwZBallYN`ma-(epZ6LG}hE*8d)h8^;MlM2AGu zjMypb(ukbj!vJs7OA77vd;t=kx4=-l)2glJJ{AwrNgB=UZ5p}+jTCuCahk|-l$@GZ zgshcv1P&ophYZ=7h1U*xV}NEIKk;CTZK2-iD}=&VHqv@sbDgLB|W+4B;IWS|?R zfXF+wwu;ZX2mr-GVkl>W#P)K+!Gdw1z3Co;A~NJENZp5a$poa1_stievm0vN^}$Sm zX@=wq7}h*&jeIQ~z1)*-f>jEi`n@e)SRVPn8b`eDrD+uNK8@2%@kNnYkA`vWyAweX zegL&#TXR1ERxYRby_orfqS(Z-f)I@Nm`l5PeL<`pY?*=A`>;=MrMmJQiH`+5C0*Um z6dQ$Id89%d2Utf!axNu%daX3*v8L;#dZ*#ciqkVd0T2q1ENT%9>N>4gVc}9aT;Y;I znGq?%gH!<+pPRV-^E0Gos{HkIv@Meg8fIAzC5VBCVe%Jr=k*i5{j@NWl7`2xzYOzkREb{8-zGTp?*gH(hQ?3^RBxEj>FD9r??m zuc#X4b#-=FSJb(xr%LwHLGFo|`I|BS;TtV)aUdO()l<*m7`QCAF_JUDk4i zqshLIz<_Pk4r0nGLa`QtMZUa6MV__QR+-v@P5MZF%jrE?oO)^|iQE{b$PmGt8A1M3 zz-TD7+~yJ6N3OeHHw5~F=2@8D3?!iIctUdr5E492ji>q3^Rr6yIJ$2oQgwYO)O#8SP%5s#B-i2Vb4Lo!u*7RfsUbvfaq-!d zfY*z7*2+V!^dO*OzkMrZ$Fx4a{Yx>Hb^+0^d-rvLy`mcwGlV(Y>B0re<`?`eoHo~O zHM<5iyD>m{{aHENM0L7bgZUzEabC-0Xt`}i`CO^SmG}a)>jv{wvJqPw**ui8YFVky zZ5q?++_Nn8O$S_J(ebLs7lT9*e+TaI>)O@y*GA^u^ZZNlI4Yoa7dxR zO!Y#1qVi&@eY<=~d**k}XU&7 zSZp`Z48Cb$puLsLx$Xf-gxngZU<13)gpOfY@7MR}Rvl~#ilb6D2m>&SHD&K=CED?% zj8>4H|8E5@!v;r>#z)~Xi|R1os1O%~U6Y~#F{f(hX)eF$_;IE$!f`9KXMBUa%>9Kk ztE2>$kuB#<=Mx^g?d_6p(d$oe{C|jD)e;w^01gD?N$~H)cBDX0@B@Vf*tuj#qI`At z1Rh*hkO>k6$ULj>r)(__*U>6FYcw?-;S6Z*M+<_F)?ZtHzdymj!7&EKRsBk8=PZk4 zb^Y@=!NMmu!ucCHiQywV7asT1ySMHal_hkEY(vU+z;CCw`0G=-j z9K*<^hH2H#jSa`1At#^>n0{B{B_yN0M3}Cr5rBu2YjM?cfmq?dOd(u~J~`yNqxH!k zHe+FxBmB5{J6P1)B|m>PiqD@NioPHYXFuid1*gT$oxFC&W|qERd*~<^@7D7%?9ARs zy4mmCJ>$jXK$QRRq-rMjE~cT5JmLHdPUh4iZdtmGJ~9mf$t)HGj7fTBQ2AZ9QT26w zqz!Bw@=dhs2e^pt=j2#LiQFMH=Ts`{eTuF-n}1 zKm~*;^McbV<9(w@T&OA2Jd^h^sgY|OvkUII9q##CqlcuzRU0}=LBKvuM{JvF<{fMX zBPw-v-aGC3iUFqqQbAsU!3TjFrQNpbHvwIta zId=)Ff*K*gF7(5*Ke)1TQ(nINEE0M?{rv9ipRYYWIzcMx{)`Y>BnWZn_vx}452kN} zGL`&MT~(PDy(*Vq=%+{vMiSV!qfQ$9f-&Afo0OE1;@U+4IA&iA$QC8K@TaS)1|`4x z5xIQ@559;*rl28Q!n!aTTF1BrdL_s3AB3qiJ!6@hM_(CJfBBn*#*g648#Xe@C1pND zAl6S8F#amY#j^|3WTT-)8#ur#Gi`VCPjc-bv9To(XHtLd4KJmA7~hg1fN8usuO0G= z5H|j*mBi%)=)V!ud89L=1!9BI@;{;#>Dj6O6x4cz_O83T2r_+9#AtBU4;rE%h^-Z~uM+ zg%2?5A^L{oZq*c8VBV@jEXb>(R&@^-*eKq|!|{{@P!wXsh6+Ojtzf%^PEp7`l*l=R z!Mo_#Tbcv`q0X0$6AGXHA()^PVxEC`ly)1RvYHOhlI!~9c`xN`Y! z5VHxViJ6Q}*#@nUq5>|WzjgxaIoXe+;K|AyFjZsfp`tviqv)!^^~^G{YL)DS)sTHJ zovNPV*G>=e6yS~pB8di5AcaH0-UjZL!>F|^(l|4r1Di{0>`^?qLOwN6I3+?h4O%#j z58Em7W=l6={)N_CWIdEErJ60C5#h=Kb7Auc3De_&7HJ?=*fC{*AZUL~2Q9-fT0asA z2;3!Ti)}b1v9X?Z440%0Y*v+N2TT2NOB3EaXwejNL3`(;Q{D!d`Y?7)8|7K=kA=s#QZ5 zZN_otZa@h1V2G$IuQh{b(tF7c8*4+na8EMnly55IuE{jO_K&gHl7#>-Z|h401o+H& z)egehrG4WX_riN2O>q6J)^)MvtA(&-9=c1o*%vSfza%rET%M%KrAT;$5vzEqW|1CTZdix_O1P3&@tn6~L;VV@YVrE%O`)f==bf>mGn@GnfEsP=p~L}v zDixA^$!yf#t>N6S>n-8k^~}$u4x@BChe()~4YnXvcFY~R23d&q)5BZlyX!NggLpU3 z)QM2&wQa&bDxQ%;aD`jD_P~|Pz&)!Y4svzK83xc6h@P0$**-L8L(L>`c@iS6+5T8> zT~?|MeC-Lf!E|-0_2M=Kkb`7+)7QVR%7>5fo*NK}@VGm-uKI|ny5^Bw@+e)JhdCAR zT6reW<_Q&e^gboPwaFZ}pMbMD!c(A45r3|=66e36tEA+mu(XDrLuT*7o4UBoBu*Gs}+4AdGT zU9V`u%@=G>SM=4=<=rjoP=bsd0b#?Ka)7iC0J%f8YjKipHHW4%@yla{vxA;#*;rtu zt}9x0nh`?7flaw!qpipEcMJ$|XfiRfW_G)w$(OoWnIXjKp8*2y+RyNyL0%AkPdeY3 zeXJDwxBL+Te1hoNs6FQU_BIAX_s+(e5tgzmbEAR&*Z?OR>fI2@y;^}W-L~xNqP>Og z{MJlAP80|U+Ko7ird5EloOA-%T9I3p)(p!7Y`J$BO!U1T=CuwdvT#Fg^Has0I0{cM zQ&7wNV{j_~%C3-uS+qZ#37s+tRZTt2N*5~k$yF`@8X1k)5|CDD&8q1thiCZxL|DQ} z5#+dydSZU5^B%j%WFS%WKwDU5-n*%4LgY=lp_S>DnD@$tad9^hQOWe8jooCYaXpdU zSa@;v6&I6}`8G=!#hyOB#5YN#VISD*x#elVBXw_~z*dVm>(4(;akH2byNY=b95Z63 ziNN*&tXs_D>!T2H>!EIFj?6r2WO{~=-@i@hlr7Rc{TShav9w7s#!Z4lxE68wO651?#w@fxlM@wBaG@tY2cVdYG&%5mOV@5!u(%|E zC+7HTrn1WOL4_VhS(5X36w%GpCP&vm3BVvF9=)C|SwmHeu-}YATn)(U{x%|7!VO?kkgT@{=t_ zF-VaC>B-5y32qIcE4Y<$3VeJumL%O~d9_HInUsJyF$M^$OVV%*IiLof&}nmYJFNgQlqn7+2ux%l!#k?Z zjzcaeFz0^l7Ui%9$3U?kK6Ypr3r?0a?5cu6FpxY~u<%ex3)5I`NkTCi2%0y{2Ta~i zVU^<8EIfxxUR6;`bA~vd1!GrnXoM=cA1nAb8nXGf)cPdx0_OPFqY-< z2mQum6bW*t`xnHrTQgg?YL@BYuOhgvI(p+Dd}Vm$_6(_6P9#YNcX^`d^|STUe{PS_ z_*Q=G)$1Sh<|_2+L9;DSl;q00K7eRi&;l6a#6HO!3(s=|cqu1GGQGol80;Sie!82U zoRFR&c7iF1DMaDul7%@5=Yk4p7%9-^gJ%L)H=-%NJ&1xkmy;x6ALZ%GlL;_-bQ9^2 zAVlRC2$T(=3I}0r$VTKGYMAhsJ7tO2;N{4O794*VwQ63|91nRJRJMvQq56gr=K+SY zV616|*dt=m$uU!_$Vh4ufXMq?VMj@G#F*ejd27p^>36La&GHsT$C*>;OF#rR%8!DD zjb1=XrcDxDWq+oU(1aFCvcDxB8FB}#jUdF=Tt0m(a?BX-Bq5D|LL(yXLN|P(HKyHqtcC{(%-^q>dtdH%9@q^n!>rBf4E0HkbyM_1_T87+eH0;I->J>;L|S-5;Mo>dQq)8?e%3G5fJfxr*avN}wigWcY>dH^LLa%GYCgtf7BS%xzBy*=euBXJopIxRa(#L7c)rZQRRBMNukVy^1v>MvCD5?( zZV8R;%(w^g8w$?m_dG~fv7!om?wC?C@QyB9uSL8FhzOp!%v=G#pm2M^=us#Wu7(w2wVMHPsoocY($r3g+e~HxC zhXi@2IeZdh-6cdDrWws%Dky?;9z-yZ*=iW3Z6OiIzD->6nphm<+Kd)t6;XVJ%c0zR zx7U?VO{_;*)ckLCN)d;qVoWhC?rrDRiw~s#)q=u6HBrq}ctaDF!kc<@d=b;;0b5#uX(UY8fbvoPwtuYoo0WGdsY$GhU zezB0(>@x+jnS~3rsO$h{>h*n`D?9^0Ht1pn!J-<>#)enO?@>0Ppl8dT$AcqPaa zh}Ba(zlB>SPATEjG=}ZCtz>p`hjpsDvHi3@g-{NA<8I&Sm&B^tt?&l@cdG*$Jc+fj z#GyrsWVKIH6l$~@zCyivM7b74eZ9^+tI#bX<2B;>M`@XMU zT-o@yrEL^kNXHG;fl3@$vsmB8s=jm?`A~AAj$i z7(@URJ)l8=rCGrfeTyT81ziDoP9^#@P>fSOk14W<5!{F*q&r4Fgls4|9v5MSPUadq zrr?GuIYy16MrqP%4+3wmpjY+zm3J7rIPe0ul{O%EZjK)Vf$9=JQh!W5fGhJOh_YbE zPu3zQB?qu;0t4st_;6x+2zYL=yDO3=BlTEjc0Fl|?z%7JT-^-ekX9zOlRBR-W%^i!6QnfM`MvsTu$F~2a zr3c$doUxb^t{ZejNSpuw{s%vI zvo0xYe=9d1^8XJ%n12hmxr4JE{r|ln75}?p_~)l9H93U?Mx;;Lq?xlwkdkfxz(w5r z@QKJERE6I2L3R`GjxDNhUyHxV3Md_sc08p{^E~imxfznK7`@5Y{<2j&a{9@HbQPu3 zv0h8>kHh**MPr=K!&Fk9BgM+0Hk%3Tj z2`vEb;s%llar-Um#^&p6pK$3j$m9Dy_w&*SRH{K(dvU%0>p<8;S&ga$mutBtOUjQO zB9`i0f-QC*^5)9U?(D~;>*N4ef$ST6mx?x_>^#Aw-;T; z4X@oh_%Z6L+yCAK5JBUhoqsRR%c1^l6C^mH;U~nQKmq#fHyGhQlY}jBCD(QAY1cv8 z;N4~?kl^a&JcP055=za}_5g(Bw7+;vPuUx9#mESAvR_VxxYoU!-k3{69l0BKrwa5o zViv6xv-{W~DrSRpT9EU~6*VM3v~&cU=wssCD%8t`VC>FB^q$LjF<{)_U!X%&&Skr3 z78{0vPyp~jr29^Zi5vpt4V`p}Kx1QV3#UT0!x=c_=9pw)739{jd3S$q`LHlq+WJKC zf50U7!wamQ!KD83eY{h;S6}o!p0eZOAidwQ1jto`dq|&Iv?5HJ66#NIquU$6+BVt& z%{))a^tk2mEAHZ+Mzh(DiqC!TsaEkN(#;4sr~t}4kC1{+egW;Q6}gt^)*`<}k&*FW z&djm$KgNl$*&pE=ID0lVo7~0PcuUy-Ir(*E{8Z%x#kkLJiL*55XKyiMd$3dGMAjL` zFx|fH)1^bl5aNd!Cl~8zvxa?(%{%>-y~_ST+{2?qeAU~&R9R3<$(h+|61K#XYQ_*h z^s1({uY4dYaMc)@0De-kOEaF2;mER;%EMjgi?<_p|7Q=XN1FjN#1>>c9EE#eoUg5f zteY2O^)tO}!C7{={dB&|iHbq4JmT_xRmEsTqBDEpuTumTnOe#s~gaT`2q- z=ObYzO;cE=+2;d0{O=t+<6F9zsK*>%oOXb8R1#}yxWNGHSocfJ^j%&EnA$g$!S`6v zx6G)lU4UDa)(A^yFEIwaxUO9d-JM z?Q+Yqt?bzRy3!fTfFjXO-Lo>avfb&0VzguezBpj^Q4Z=oOJ0_|~T$APs_A zUW|KyEB1C&Isfs~iA!AebJ)wXSTnFo`J90uw^J)bK0q^TY}zE7Z$da2c{2(4OB3|& z9f()c7yhU3mH&;l%$+X~L1j0Dk2i9yaye6AbC~NQvdWH63gEj@w-^SJE&FP-@Owm# zyqkbCcwQwT+DbYwls{UCrfP`fwr&@hNw#Twk1aVDeeV^>B zRkA5c>+*f)pD?;p!n0=o4Wl09zvWkh6Pi2lf6N(S{YNbovv+m&{8uquQl796Vnq5# zvYa70nnP`oJ`2!E5^YoEqWdXTNypwKCl&efr zFtU?BV^bBQp1_Al#d^C1zD;)snrarHf^&+on>guJO%xPwS3zMW>0d|WXT~K{XoxGh z3)@C6n7L0bYh}-BU25-Z9FHF*v!GEj)XCX<~Rc z3lr3Qj1PaO{vLYAQ2b@g!(_{6XJuNm7&qcM>rksTnmLJf+<_rK}CkaY_LuB|L2s}sU1FXAgT|G}Ap*6ZTM<^Cb zE4%ZqeEWKkJsIn0C9rA2G(zo$mymk?nAsAs#iPuGc1o4@9=^q%ax4ZOls0kqe8lguW*S zwYtmlFBAK%ccf_bw(8n%38ccnh3#4a)H0&(=!p#?3&`7g*kN)gGpOtBPes7>k!2E1dk zngq+QhL0s>73i>OcjM79Wc)%RSV|*3v%+4%B|DQf<7%g%cD#ZvV06}ak*mUdYFp)k{@a@%z#2ay||L9wOPK1iH?X#GU^#E>118gvaEb%=pUl6*j?#)U}yIaD! zW>8ZyUXSH4ob)T2of}2!CWLn9B^2Se8pBnKrSOJP`;PwODeak<9h_9!DU%2`cq_5Y zvo0|kh>u%cf69AQ)+|d0lcGCXJ?djq^G^l3UG2e=(?<%z` z%t70(5GE_`C{;Odbp+&byac?CenHpCYRYJBhVc?2RsNW-WYtXqm*JNgGw?wOAIN5o zq~8pj(<`+3JfAH)B2_*s1&hRsED5^mE#UD?#dX(pgf|FMx+GVD(QyozCVx5+767}x zq&$6zXGexfrJ`=7ivliUJr*uy)kc2nE$X@{<*loyLJ>GNfs2Ngg3uA}SQT7)-$U+u zI(XRYNd90{!-uB2zE|m##_UXJ>;Vw|Rf=o(}oC$Bj%gp9|(lV_n_I>h|%v&DPy>NkvuG4tDw5a+iX} z(?mzp9{fakuT1lW7j;_@-;d@Kuu}~dkO;K?dt{i5CMNY{;w>`G7dC^2tVh-OfTo_tI~Vd(>p(%BtP_TffwQAhV__e`v`a!kT`RtmkSsFJ*)UbN8?!V z%7MG0PGXi1S)@JGl=I2}(fLmOPZk;W`lBXW(8f_pU&9P5|zNmttUdXj%XEYkoKei+65Z z+VUmMPNRI_Wg*~k6|haIt`Chf+_@+d*V911wcY94?kw@k)?HaY5Bt(u3BZ14G?N!K zLMAvXgOv`7DYj$JXz$5f0em`E{|2(XIyk-7XK@o*p>HVL^2LF3SfVO!Am_wvu?lBY zS%JU0yLPwzVHjb(N{Z&mZmKjrp%n2j`NVNL7Q#xRg~X)LH!s8c>HtYNVd9uD$V559 zfQSoMm`lr5y06!k`zVKi#QO(m}4V!x)uxKebv zA@gQnSH4ottmBY2J;2jqM7)v~Pt<5Kydf9>`>7^aU&xONC5Au;yQ+N4nUx1Cb4wMm zoJL+Onz}X2;QqE{(5(;8K$&9TsRPfL9%lJ^rPEV!<%vObiG;M{!CM3uQnpflR+Vlf z?^wt1b*F`;BHl z=;x&2xc^fhPhD8Quk~lBDs_J#1XAl4!`&3R^Ke|Y=Hnn?FI<%_H!xz6Ul~ymTV-Xw zY;JpWQNCq8%^l7~6DyFpw@`ID9MdKtc!Y@QTvwn zd5ypelmgKhC%|h(fjSZ~A$z>bI!sW7f&)^SM}7`%!op&jV$k{It{sXVkrQ7~lDShv zs8_y16LUY$pU!9eUZ2zh998Iy=dMdZVW+RSJlepYv;1CcJ85Z0grovU7~4Km?E^@8 z|I3+0Dl6d;CC6ZZ%>|{3jDT+kbXVq{9|e*p-w!KEAMla*=Qklk4O8nhs11k2rRU!x zl1$8|C^b^fG2SJ%yYt)uyx$bv>-oeNxg(o>&D2l#N|S^~X4l`x{MJ&dOh=d{{d@^Z zRNm*yGu8>2WZ4Nx)~8%&xT`UMqtf^}`6&*mANVSOYE8yl6@63QWQ<}tHR3Ot#yyYJ ztJt?wZUC{Y!-UH8-eJ2WmV{H2vo7K#+BIr9b zS+QT^F5&e+yatT5&C@;i?5laO&qF0qC3Xr;H~@_ACKHVZ@u*K@kcqvt)*;o{Lq|)O zLr>MlNrA(8%bXtCl&;|!RO)mC3p+J(1`vQdPgekX*0CRW>I1L&j6vjRVggT8LPQeUyg8K_S-89C_=3r*e*ovp2tP=Z5;Sk)@93N?ViEq zK!9UwgHT8B$Ytrto2mD;?)k!G8F#y{sjIvJ!!-20w%Wb+4QY*-E|_PP zNz?0P`bcPOwgqLgDYLDRK%nKj+=R@N+ z$3N#4N%jw-)xSd63H`sY2>6#3<^SR0-+%wh525JnU}EOt@-IkUY3V2&aH9GBy{;=r zT1~QavGU5Z<~FuW69a*;3ovtM6{c{~5y7!A`4g!Au(RUM;5JX;I&N2ZRCU+px5LF> zw=f+xG^k?ENCx%Z{C^A)0KLOl+o${e(s^Y8R}XFmyuXHsVo@~nJyach6`V#kJN_Jv zJ5$x6JxNkv*T&eB5{vY|qeJ=}^jD8AHGq6^_A789><&-4)UZPHd`frOF}KJmfEVA`sUkZ~L=uxU zXM|UmiIV-?RkVUF{eUFO78eBbTK}DQebnyqyku#U027 z3R98OFjcEMN0=9WlsQKRLe3 z=LmmE#1JiTti@p~ra3>&WiJ@%mVQJ<+skN>jmE3MI2F?i(51(*QLJTmw!Bk65p6bo z5o~u&?A-j=X^cOYhkiR}lsFX!`U9v9bIABeFmnPlEFU``kwaX$+JxPp(71Irh!i6? zhrP1u>+J`ja0D!KxDOzu^2NPYty(RO+&Z3f>i(x3jrF*yky9GoQ^?w4W|$;J+@Ay@ zn_u%dh*F^?03xW>&R-tsRsT2((7(I|MJUvhuy2cRg^9@S@z#2Y4Vi&O)yXVr;;?J#+ePipgglzm3r&dp|UdRNkNH+ckxs;d84at;R4Y+wm zqQPe7G}f=cwR;_TsiygLvKIOsz2x$n4l6Kz4fgytPP4VGW@yUSqJ`TWYL?Is3_4{3 zNmveX6*Z{rP=pUugq5A}eBe5LTR!yK%*kbRK)jDFM7tWoIt}FKBXbY@`5S2wJ4#(WB1pZ}vz!#x+xGhJG?cW)SsL3lyUdw9|d z`nZ^WgCC>`(&0yS21A5Jo}Y>`#Ic^wl`UXUWrutmD}OGP%kJ!h=MJyU)wUv(AseF^ zV12g#eiu}g-URVJ1VfbZV{E3RlOE$jiEg=B^L+X1!g#M>x2`{RBj4(h!vJk+oPCC5 zEQ(M>DM4uw1&NIme2!5cNYwA`SP%{VgapZ41_YUp5sAJJ0M_qA6W+};Zlvk?PKMQb zJ{8Hz_bf)*5tikRrQdLknOT$MxA)EqApIroWo@GwGXJBFRRZ67rccBlnP-P7G<`zf zPP(gIm8L2Ag}!B)adK@fbl04^Zz_ejRi94IR))I#$H*fFm`uCXCB@m zH(Yc_&b;~FzdQi$fA5&*viET8SJW71oOIt`zpj}y2OzXk8Dk`Y&}lxH$|d!O#t^y& z6#*oX;31w;9EGEaJt98U2;VIrl=1z!_{Z-!r{a)Vs}&G59xwrW;hi;PQE3nYg^pp8 zjbg}|2Pl^+FtYCNXyf<@Vb2eEcTKCnb5Jj!>p(I}v&M9g!|0yET(c3L8IbS-H4RXF z=mp1I`aK73nY0U|jnT?zpeM#bfotpJkANjPWVB&DlC-BEvHQgH%iQMiURfyJCS*j%=z+7B6bFv4r~S7KJQ~7+@cgMiHw$&XiGa0S zC@Lb)b5kkwsN3st=W4nY9nMds9b8X+i`~bsMQWSTzxK||8*~H)Q+{C!_f>koXi&6~ z72pwvR6^AO`^FHnwW>A?9A@->Jiw-w!UY;C5#c;17g}4Bcs1BUl;95(LUl{r2XG|S zVTrHG9&~N}(D9g{w;xF+z?aU2F(uHCXg7HnhD&4nNiI3Yu##(_WQcLDfef`FNOV9&gSUZCYD@;EZT#ccm{EuoOqw zz1#|*@iaehlzWZx{}NxHkS<%vP8s>Jm8m^2Z6ewm2V2F_?n8JoNz;+kGX2piZWYwV zWdmL%t=H@-SX(~RHg{qwBMdOF1eUN7Vs|_YyG01|q34gpe`?H0-P%+9;tsD#xlVc^ z?_AkSx|vd*S#)F-aYLTEja7AZL*G&k=AjbRZTk)gA0D%}wB-6@hte59LF|o$^41kw z1a@OVKG|QIm+M)84r6*;k6pw*+)BTp;U1HzID11u~s3`&;iIFZ0}k=UEy74 zM2K}ijib=)6blyUvX&RHcF`u!61BO`7GL>DI@UF;@fg>y+0t(TjBkrK8aecAedM!I z!k<4w2pBp1**tDs)zMK;F?SCjG+5sUbX0vh<$V418$&`tlj6L<{Z)Nei2oB;0DpP% zujhXP3-kX0i=9J(_&-Cdf%jX-L&H*Z8zmjPs%izrB?(OdOyYg8W-E1{H#{Ak z^sA2Q)k$yg?&jH9CuwPVsXm(yBvUBt<6Vj2dxu{q^qUh779H@+%ZH-F=T&Oqp+@Hl54fvVD1Yyr%P8rsdM449c{9g&HCJUl(`Py=E1#K31& zxCrHr4Wf%4`KdPi`Dz7*>h&Q26c*GgUCMlc%};VSdDc({gj0fim6DoK>8hIQ;{1I4 zryr5eFP=D7AU_56UX=YNsEGh4Xo3>F`7*x*TsDrG21Y*gD)Wryg_ieY$rWaXgJ98K zE_+3yKkiO%>oW7J1JT}XS~VJudoIp>7X z=D4XpyLO(-s0gu=xC@_bzgnOxl;8S^lCTXCYEJ~g(>%d&b2H5@ zEXSak8oiCs-WtY!>{M?lL@DfusU!xVQg|oc?b74Ll@G>!iw%`9In!|h3b8(Zz-O5vAJYB;Q2T2tA0^y`S(Xw9zuqfXMXt&^r7 z!mz+AZU@a%vVEW0DuG>8zeT#GR8`MDG$;I7uIh50!b2w^u#rcz?)^YNPYd~RLk|(e z`DivAJKldUyrS6&kmGwnRe#!>vBzlpxN%nf1MrYTi*ESI&6X|kP!y-e`a+13h4p$w zV^CjHSm*2@bv*ul|ME4Jm$Vudt~fg=-2FLb(;=9=UDfJ#IKn1{Gin`+9yf32DeNhl zVBmT%bC>)kt0RI{iF`PZw$tA+dt&p!z<#vhSB_ZCFo?Ph5O_8WRw!-5(LdXgy_?*O zeJhsFwfaJi3K%z5jIWhOA-xbFnoB)~2us#-C@?gjL953s+=Zisd7e>DeHRK6dH|!v zf|@Z~(Dd~jef2Q8L7%snR66&ae{(BGyjZ^Up)#p5s(7-ow}Zlr@i>edB;Lj;ovA%v zfGtv>Et$5|65F~yP45=Y^ccNx9lL0pu;J3XXRn0!%yPernsADwt?HAHEoyodiNj+UIs!GF07Fb7P^*VK`yx!14gP{O}*z;Vt{vuR{IK#`j zS!g3p7*kN7RqzZp3a%AUd#Wy`nf0iR`Z!YkbaR@+E)&Sgmeu_k>w}gc^;?+i!?K)z z(24OH@buTriCL4ZhsWTp1eCQs!;w?%9a?OYZuaP%Ic#biT71?u zlT&>>4XPV}a9@D+f-qUMxl;6U=#A*mi-~BC@&S?Xic;yrpWx@Aq=MHV!yrM?Cf~Gb zO97P?A+cACU{f2Y1e6J5Y=Si;%Y|TqCBGEqs+d;fexIjvZ5?W^saH?|uS(v>jNlJ>kV@Cf z=E2VSu?{VKi4?_iR_o=JPlXEUP4E&ZBB-Ywlq;=`x|!&Tn+Uw)Zk;GBDg2%8}*L<5h&HgcIl*E4J(NKZ{d=S34Ru56&hk+FpOCJSe? zd>o;lc;rq%aS5xTk^F<=C;MB(9ob_-3__1e`X7Qj*`P{=pGknIhNBEcj$(tIDj;V@ zQ)*a3yUD4jAoD0j6I+~>DZoQakxD#+rGA{POH76)2TFe$FW2?SBnQP9^A`+0Z>$dr z0zZ0q6~2=``6WBBb4Nv+s@4cnkV4k-nqsen*c^#%&=;bukP$zi3dPS|$-X0Cc>Af{ zM-gfqACaf?Qiuo0>Me2J5C^8!LSyi8&*t-@{9aG2H~yoGq5v2d_SWMH<6t%wL=1_< z>?aRMle#p!2D65^dz$S~s;^StA|T2&Edxa{_ZqRPmJr^&ewPs3owEUtTgPzgHkv<7|(hgc^s(8t^HkaVSBIm3n!{7kqpkI;+d4>gNXZ zl_*zbW_l;kv`lRh`C`I)|Polmd{4dM6h+p9?ur`|Mb<`sKjymcEzg zL}k%py2|mol{2H{3umMZb#XXTSX%RHYUf1-KkW%7iY<;%-WUJsu~9_Q%5CC3LL(nN zv8i}W;kb{)&&JMyOI~@W+Ne319wk~$>9}6|1vZmFbzOT0um&QS6SFs(#q}9RK@val z+S@rdl;D=q7^Tu^n|jx|bdg{=62acB+kN+c|Dyb9#Y|HhqXejy$GBL9)r`fz!WF?D z#M6o?;2%#UjB0JE3ah`gY4CNzxqYNh&Q%H-Ij1ZxE-+l@wwI0&)2yOTyZjN|i>o-S z&|(|_JltVclQ;wmR;d}+Z;`;rUM11r^``SLs3YDwzhos;yDjP_c&h(McYrx50JKrR z)iOTSjc4Qo_`Sb)*EDuLRl_R$^41DurWs$Dythfn?u$GzH&q^sC41@a1c5h^3M&&O zxaNfyM{y0JH4cFy8-5e$3aZ84Z=NJaa}1jXA{IEjJYjH_f&{swGqn(mRH#iGp!o?_HZP+DWza2`nNf9ek|vUA(ENzhwVhYg>0Q~N5_1-5Gthty zczQVr%G*ZdPAd%V#E+Ujx*wD$7UNl*X|aqaX?dti=m*XRCo2gg%kv z`1L$+=)myGUpeUfFr(69&Q01*P}3);rgJ{C4>#}|CfVk9*Cj2n)V&hqj0)@)nY#JT zQnA>zwPyFjV+v-6+Dd?Ir)>FU+@yN}=%PRoBV(8FeB8zF?Rcymt?ZUGBOG0u(e8YK z)*rqZ7oB!D-AJ?-!TXNtY@UmGAfj^3a7? z%AuM8{X%GthwoLYlcmF)r{#g7CU1ujjZM}maba|u+Ub4wWnx>5PU9x=wQFf3Oz{>+ zReWpu?;zn2@MeOIqY_(1%vew1H)1d!f$ z9O@6)Pc@1isP2;I8H;&FNQ_E`C2@>O_6>Cl1kMp5u=Tf+AevVEcA%6rawbDm!hxnGcWE$<#zY%^n_oau1@F@xt&WmH;j$OOWkoR7f zPv-|x6!&}l_ge$9&~wKXD~&S~BHZXD!4T@rz4Rr{hQ)7_n+g=_3%vp2*FRX)bmuhz1Kww-( z^;+mpWu)WJ8*f0dWypDoS8q za{$@||Fu5*>jjGCuO(QyhQ7lx-y7ex%CpClTk^1F9IIeES!Sk5_dZ1~R}UP7F>?Z= zh9hk`(DQ@xMe@e1cGdA1x*PHkdHlDlF(JiBY8sxgg~WYif*CMub2PK_#5*T9JN+~V z^U4CWGTP4&C{B=Qk%p=Gq!*nH9xW>*@|&U>y<8Kdz`C`SwbjL}O}_C3{KZD%oe2Ee zUML?W6vviaf3N7xC6q?)5l2YcYMM>|DpVb{D;qUm&rDPP3&u|{$__N6jRI!ZvUh}2 zE|)1Ux|?SJ%%43wE=X4fu$gCWVAcYXomQg;<6r7hgFVOBgR4B{jeo7acA*`;mJ0zV zrfk(f;*+q299WfJk`oyO0cxX|Du^b)mNscl6I&(yXV%J%GR5ozl}iE#fiswB2Rt(- zQ6+R#2*TOXZ%p*4z^+`>^TD+_3_oB1p*~l2Vxa@7~3|(vYn7U&M*$>V0-uKzz>@Aqhdt(lz2+ zrOKLMz*XNJX@1v*}i?7bXcC@ z#PSnj0${-?H5+dR zd?RAtoOWwnhZ<+J&XJwwm5H^_8ANd}f$k+;;`fbW{XG}_&sF#(JTmon@nbsje22yB zHbH<^xCr@pE_bT@SZsh>7uMrZx0tnpAZiBG*;}(EU28c-eh7LR3x-RwaPPo0y>jo zqIZs5;TYqb_m=qRC9hVIs$oN`-N!wCnz2tBvz2R1)TW;U3uNxjA-YA*5u~MlvL}*vZaV+7=ub4s+PITn2BzP zh0N@Z8TlXJe>qLCH6GI}Ucs4YV2JfPe|VePKdW6Z!0}7Q`}9P$vBR)RDMGl28lW$i}% zE~VT@yy64<83Z0L>f%WP^vd&->xdg|tQUx<3COjptpswF3IG+?Lxsmb7P+2!ILrqM5?nO%gW&wE=0t%1k$R1tVb3K7>EXj(gPZu z=nYg1y>pA`I~j{CEBqLf&4Hrk4c%BmyAYb`>wd^l%={Y~KRbmoPq92xFNM!t@b13X zd>5Y$*frn6RStMp@6U}1was_5vRVMK8_QBy68G>HZI7xy46lGH@cYdr-f;ZTbLWd4 z3B-;n`!T8+c!|ts#f|ljbK`fXUpBQD1V|2MO}4_SEL!xT@p?65ov$b%!Imx*dU+F8VXBgJeSw}-yYRfI+G>0aA?Ppw=Fy3iu8CJ`w z@(SN1@m6Y76RKh%_J`dP9REyfkfYeS!y|FmK0k(pZqAvqJ%G3U!0ZkEP=jYe7|nvI zB=D6_UZ!Qyw*d7*V?!ONvP@H^tD==p5#Bw#^bm;PB2?PlYDw5re1%~%T0WPeI=v7Q zeIRaWYihpha~^AO5J9BzAX;(!~?|+)VABcDAa-YfqzQhurWeue#~(} z3ZJ2YP1tyEpSxJ+1g$y7bk z>?PQ257~TTG+4hv zZ$&bH$2s7?+)#bv(>Ujg)SPeT)GasR&OG4)BKNZ$eMuxqc|`d&69{g9;p~6)Zw$Y} zcmD*@|MG8e{Pp)-uVtsY{F;rIWte+OozlV+eF-6p!IwbshP@RD5Z$5_g4VFn=0KdufAjA(s=?yrUskliJg;U3r7E`i02k>Sz)EfyKqwswH;;7X~S8?QE;kbXX z&+>z{i~_Ll5yE~Eon^TuEvlt<#>PTwr|Bewp(|J1IpKj#j`v-FWf(C;Jn5`|r$Fx| z!0TeIqQj2pGe!_3^?Y$~loZ~c46NNkDcG>PGyG-S*`30dF_9rv1tq6F#wzIs<*+hi zIUr8uPb#(A`9sbVCMUK4LY8Y8=jh$QzJ+2`E>Z@xEi^?qLOjrHsFYR4uxqK#Si@1Y zRl3H!2wU=N6eFIZ>XUH=Gx>cR(ahkd(8JOS$A|o^cdR6KF|wgf7?C}rBp?-?Iz!_qwX`v+ z%p~%1d31e;_YY!l4VeOeU}-x*j&K(;q|1CvRHr|<{mu20c;f0#Dyj{s79h1nirED$m@s#Ng`0MoX_2N`a@k4OPz7Ws?a zwNV&T>5T79-06w2^eA8}EU%5u3|_)zzK0F;ya$vD8izwv2L~6u=P+Uz4kGLc+cD=t z&2OoG5dHW$#GzL0<5$2(ew(J=KnAXIe?gMlElc7NrV^l|E-C_;IVgrVs6|&)p5(Iia=mas;M- z$38oso9k0YQ=p03<5RnI9O}svF(}h$tA?d}GL8--y zT})7hjDM{CcqejeCJ`-T2OdljfjvvAX?j0eD!c0aMG?R3lrT^S%)t+cVo7iyg#PtT z7n`WYW2^;|$i~zGNlUU(QT@nR{Yo=UBkqN>&rlg8O5TsHLYM z5^syGlT`Er>56*CIUUiunmO%--h`vxhW4SiB=v{W5pzYQbF|qPOh&QfT-@_E+{t=| z=ewq>71IJDjWfl?@0%UKtRKT`+iS@=Rng;fj00co(7izk=$<=U8qgh;uqfHaboU0D zl4}Rg{(BMSdf_j3LY2dvtMhEJD6t}?E2CSDYw4W}*TV;IrAxxI`B#%|7X?+b&Xl)4 zP+uWaoScDW*FV>rC#sV4xR=S%zhC~Y6F+v!e6C7x$HM64wX9zYoDZ&Cc~YvhThwTz z&tICc&Lrnj-OjAR)sIa>y!OsNMv_~elS`hZqg|=^7Oz>{CTnH1LKCxcD3jmUq46-r zXM^-am5DfFY(E^A_tbG zGY+^PV_F?EKu}nrwpe*YkiDnS*^x?8i*LZde7Ye#kfTjH_<~gBTiln*p4_wkCUt80 zA+gey#f;)je@QWJi9aN@tL%@cxtL3kyP>boEMBvxC`7kt80|;61ucDNA|yB8mKE#u z;Tf@meTMGn=aY*^vBozuEViYk*+5{5!o(~Td#lBlz-4#i6f>NH=etMS9fos+df84M z1OmOB@7bT(wu6Z6r^;hoBpOgn>_4l=T7|DMEpo>USp`E<&KHL$Qlam)y|F%1huF^2 z8x4JXG)@s$i~tNh!$oN2yf_nLSbqpLrgCqAH$#Sje0t3RmOuMg6mkcxV}uPPEezI* z#*KNMzfAZv-l^oW$!?BH%xf2G2bnO~_<4w>PQ61yNHvyB|v$($~sArXc)Wrojv zPRfg%mJAZH8nw7gm+#ptk^>XPUK^&Cc$2!88TfzuG0-zRQCerr_J!4Q?^SCo^q|Gf23BabtOPF!C^T z{&*WfQv@$cBw1Q%7$bF0J{AriVoafw0asiF(R#fkRcWW=(nGZLZL+5B+31FN(G*_0 z`m29!8_D=PhMQn1iv`yD9;HIlJ2bvr@yd0RUZ}(wq_SRJETBOkcyuyQA?&fKg@j1# zcuSb_oa{>}hQIF(-kovG2D%POwh`yMD))CHN%-ONa#}Xbfz3au8Z}YTmZNG{VKsP) z*Y34xPj^--w^f)2M;H3mzJU#F)KNalA$Bq$&d+vfdR7PAZD|eEAmvGzdL22|kUn;O zkC_Q}&1cEVEHfqNcm1RbOhEr2v;Sz^sXDaa0y&`QrEiZB7$}afgo({u=V0jV9s1h$KG>kW>8kh zKD_hCtPt*Ujs6pyow5{%A94Y7!=d~DRp{&MbZh`Hn0uj8^Jfr`Iy85V)&+F}CpO=cFls;?oCUw)g!<)_XaGA)0ok>r!>2e({g6BzWs21yPc8F8q(*zetNEj}QV z0!>ZgieVB3E15$8(9uxRCOa97LMt%-ImUB$8fg$gpfd6ia|X%D(O@k`*Gy;oGu@4J<;oY1~+4G&gLUU`CMOZp9x}4roy!OI)#<0|7JYP^z1--JM z6IHO|%UZbV5)TAoIU!XI>cqNZ&kQLgWhozY|2E#lW+YA_FejENYVNYmIUtt)oE);H z`hT--atOL0ZkA>kgqI+re zNvgzMxQeXN%~L)Tcqubw><1Bu6;a`LogY3y$-PY)Mn0ms{QBd8?cpr;-RIMVOosc4 zCy1$2p2BT{gUX-pMxls8(v0x*KhG1oD&9`q>su-6R|3CMZ?`9yQD8dI)wH@$kJ+H} zd8GIemAVt$DDAHS)Xc1=mkz3GIC?Z>D0&#ZL&YH8VN~x27bAJ2tB1g=`-YW3Oxczc zWt+Q(S&Pojq;lb>a2=*{9a9z1Pp^&ES-tWLda&SJ9orXL=r%mr791Wt^jS6HSvDFJ zbdK3$97|N!#g$grQPxqv9Ccka-9%4Xh$+>@U9+=@zv59diR7Ic(QyRXas_vs)($2OK4!4T{= z^JO11X2XdmpNGqg9l;t{r|gN5pyak8zwBnk>y*~<`!0F*!uDm;Za42C5A;a&gM=84 z&$&LEcEE2GR-pVnC)u@y)v@9BTtz@0%E-sy(WN|t!J4wEfg5*MMDm3$q^Q+JL_8+D zLYRfnU}c8yC}emU!HCw@NQD(Iz;ra94Lqm;7 z;qh}h8ktL9q0a_cJ>%V$nrSI)$Hc;%53+Fih87JZG^NZyU+1xSAu|?}k=F5_e9z3x zeB7K!23_voWk)Jt%(ufI^%(*)Gb6vPmW4V(ShG8rsM37_Grt#^f`xmBWmmS&+Alss z@&a6!O!C(F!qiz=sc#Ws>RVQ1f`x=s&)`+=0WnYn69bBUZiBd{*%Y&-c?OTqpEQI< ze#$fuM-*SV6qGv^0UMTi+c*Vl(T=df@6B1=<>@YCq1n-WcJq@w-UZ1fii~4~lAF9* z-@2aQT*oYp-Hj=QE^Jl(*m@yqfT*Zg4McF}sC$T-n3ogXNpoN%@r*V(nwZ&*dtO}~tV)Ixpzaike zJ{lLL!-dPcW{hSJ`>Rmh_&FrlVmFv0ZE(nlMKga5Ltmxr!miB2$znpku=Unj$mFqV z5z?Tm{EiO^=yX!;n74ktUXPLEwCtyn?F!k$FOS3#NW>~$>t<||niX$N#Lz`y7OP0- zGfL0#cCK^TDwF!pNi|zh^!$#sxaEQ8(=hOhZ`PJZC{0soZIrg5!l1|}32o`*Q5`j~ z7#3`LaM~-`gv}*wL2-@IbJi)|{pzF+I0(}Pj`d$eQ#*NQxMYS69K<&Clt3vWY?}gX z&TdEunc{XH+`&|^Y^hBpv`0*$aq)a=R|rW$J_dC|eP^_24#zCDC+~*t$zFi+_vg}D zraSZa_#OQ&D=ZZxb!Sa|=CsoDvkVR&-q&SIU~3y!PHLApW?_`-slnyW2QuwGn_S3I zk_WrKe_nKNS9p>|H!7U1y}Z$pa%J8|JT>r2LuPTpnCJ#iz$aCsrrdQ&-zH?{^>mLq z6X;bO&ABk=QNkx^1rJ;wqU!^G3Abt@scUBv_90o?yP8eM!h(8?+nvgc$U;j9G`K$AT0kvwMlULQw5S@%W z@!GrdWsvf|vXEF?258E>6Yp4)o>F73dpQyS3wZiwYaB)*a_j+(zl(;1#(SYCW&|&L zi_T_#uACTqF67H~O3nx*L#rrPmkAqkdj_W1uW%)jxHVUv3l7M{0X$^y1;<5tE3v4;8V{B2bDF9k>2Onsy*zds~qRVq4E0@k|UwGR!c%aN?Y~)Uk z4yV4QX%&}qj^wh!{7A7@wC|=x+yra7=Ck~c0;lVPFKP>H{$T~St%Xmu4a_?6D-n+k2Fx5nD zc<8E+4|hf-#~mBdLHvdGZE=vqh@Df0rkK+mJ#yB>82bJ=;+abVHclfmiG%VdaFw#;TiRn z?rcuyEt+#cbL8<`#IAGZ=i&6AX+}2(d(n)NDhK-06>Zn_r*lU-jHsszPdf6%Cm68H zOhm_0RlA~qTWfdiT&XqfIV)+;W0?X%GV6-0=>Tth`|?D$l8z(o!q6dl|Ag;K2typA zzLqM@SlZu=F9$dnIYnq{p*142I02G)MJ?7JImk7z9c ziF7$XH;}ij6>>V=(dTI1lhS)wK3Q$feHBw=%5R9!#AEe|(%fjVjG9|M5lx?CcWcwU zwq}-0WA%Jp89zzRc?epbuMuiVaXfu=_=D@FAHROZth_6 z6sdpB-i%jtdRt{)H8wzNr>~&;;Ku4_k}vRrX=<}k=>w{&jI~N&f|81>OG~xiv#|8bz?QyO1zR>B5z~;u zv?Z+~!_30gKpaUTzOhEm6@-G>-@pWBLbll>t78~3qKM+9NNKtTkc4a#;}D!}w#Ea{MMP0?*gHbpAr1=XV41 zQ(7WyNo|JEnu$~;^1+>8LTAf%rc+`gwAAX4qxz_?XxvSExWiE~$*urAuO6bw2B`PC zAz}3;XcashB+1LT?%fBYj{|B$7`gUv0B+=3z^no!U;wU2Rspdqyn%j?k5pi<*OP2g z5q!^FIL8C&ds3dLqP@;SoofIP%Z)!^JhSx*nFAdfiz1XxKj);i*&G;}fc@-}Cu@_X%1Zmj{kP9bqmKIwZ+aM!K|vxK(!y2_n|7B?|fmhyz~KWDS+ zkBplz>pxbwf~B|MPYEyNfyi8Qq$L4KH3%YIU#(LLDmdtpxJ<_M^Y)2trhd|(+s3%_ zSy!y_jc|QyAW%EwWO_W}kL2{Awx=$VkJ((lJo4JN^5%Xyg8eBg^85nB_wbiXsK%yw z6YS0F+QIT)>xbGR@fh&GcKG3wY)1OE!_R;A1d5kpAo+E|>UTi!%`AlvVG99@0>=RrNxk`t!sGX?Dt>Jh^iMH+4G{i^7wbP82La^4Auz%I zv&)m-@6^CZ|0us6UA?j{si~?NzS0B#&4>AKRp9URnt$r{zdFVKNB3_=z<=xhPA~Y2 ze)FHw_ZrFgquT%d;;#f@!2d9W{b}LuHLv;)3;(?ezq3pIA`<(jIFS5*X~llOkH7lu zcNU{R+uxB46eCUYm(c$*>iiUw}Qh(HGOuQB?2X6K*dgpKv@aROjr`8(77-xvEm?dVtL=RZY~>$hJ1JrDE`6TsJY z`aQ+q&qr&X|Nq+f8x#D8kyjhPQEX|+o;41+Vkj2;i z)vNy-+y52X|5K<#@js3AH}2-&mHa&z_@}CoG6*1p|JPXj-EaM;kg*0RLgw#E{O$?) zQ)pZFKb2trE4ujaO8h{pa_Mlfx(_b~M pisGwvd_5KR`Xl&5D8&IJD@lY6kcWo(bsqoq)BJj*@=F@*{{bruPnZAz diff --git a/customer-work/src/test/resources/test-auto-deploy-apps/trackVarsApp.zip b/customer-work/src/test/resources/test-auto-deploy-apps/trackVarsApp.zip index 21cb2acd1414692fa5c4021ea1a0e6a8e035ee13..9c3a899c4e7b4be1cf266098637f59b73091524b 100644 GIT binary patch delta 7931 zcmZX3bx_r9*DlQlr5ovz?(R-$kl1udcO(2jx?|HVC9RT@8<6f!=|;Mnqt7|>eb4)z z_1BtvR?J*$=DJtJfD^!IYD(}3xG*3P2nJSjFA^jS$L$vH$L7=Qiz;^5JxJ3d|4W2W9awJw* z*31pRCeeG_Z!K>VG$1tqiwmUAo=lGl$=IMofBMxh2y!M9g_UgiRlq2UG5bEes}xj$ zok{(pB-_7(eVr}wn}JgJd^Yg`j3^4LiF3zqlY~dbQ|5dHTRvRujA6l?c(T)n`Ow_q zR}X50@Q&H2g;?8k*cu^viPu^kSaegb5JYZpPNhv4wy{p>4Z_!h){+1TT>s_af`O2&bi76o)+|6v%e5- z&As{4%S!e(>JzooC|LcEDX6bF>C{nE3TgCj-RwZBV$sQJcl|Y$!<=FUDINyev+L!R zh2^M}7Nv$H#pg?H9XE>n(M>u({j{?)LouG^R&F(X!ox_g)`$R?{35&fL$1(>stLsI zqY(kuzRsyYKCk)7nrp4Dg(B>RpTnqb4*qin;GQ46dEs_Qd%?rEYVpwzoM6OXw}`_M zgZ*IEt*qCFEbwqP-hmHiPTvct4Fm%%;z(}mZ?}5(xZO$54Z?+eIU}iHRy}92Y99JZ zG+8-!`bI+2GcSQu?xp$NB#997Yf0v2VQL8r>Zjzl!!vR zAD)(#hm%_{e6M}BZ(*)+LRa>*1h7Ds2f=|V?1h%FuGl~Z4qhH3gHI0Q8QAD_V9fZ{ zTy2YpQ$ryssr@dsK{AM30ud%Pd8)D7Ut~JxLEBVYfoLOGESgu}?dZfS1>Au7{5@tFR zO0xkfD=jOi?)WfjqO`=>)9S>jmYnI|D3L{icMpV&QVz{(8W1&q+<4Stnjr}o&O9?b zH7DzgzoQSZMJqJmA-2-~s&ge}2Z(QZ0eVw^x^A}iur*GF0d z1S>7{z%#vp5xwLZmakuDqZ6i0%AT5ay*j{ zmTl(>wMz)xwM<OP!NDK2!jcoNQ9P*pA;Jt0^Ia2&>lp{SaYb@+kg`#UUT@ z7y4phF7 zWgiJ!kc%XzehH@fI!4Er5?YB&-k+R#;~>SV;{u0znT*to$WL3F%haE5u~e==gl)nc zVXSwI%`lIIeJkmxptj0K$oi8axqlW#hZ68hLOJs2{1j}5m?R-#8@70|S3ASX8IQ=} z_Tvj?oxlU3u5evfewB}$RO?K;X=`*DbYA5(SQSj1v9H1tbmhvB(e{jgl7;|>-=yvy zY|qee=5nUTw19CFMy6(TC+DH}z7Lf9wdM-Ywkq|T9OQ*E38867DZ}<)6+WaCqyW;4 zU*+7NM-aTyXoEcr1f>J8LVGIZH;bo~hhJsbWE8`@283WjOB<3-*)!hH&PZz8x6BWD zK2~iCx9e5W*atH^#7xD1ud6-I>|J)p4`2TW@`QGOkQyuuj0wVjL2mHpH~k+W_@hB$ za0q}I4Q0DG60e__tW+2(bNEq2(lJ3Oh&SV@g16YNgeSI;&cz9L(N>Str<0WH;G*zr zGs#nj+@c$rHuXZ`83`6L;uM_gc(_kKHA>(leQrkSZ%v)ru4`$bTBWh*?8Uf-Q3o7v zm{LRaOGy~;%&SLm56~$3MAWmlhr#+dGnfEW;`rx)e4La}EDE8a0LGlMrkTh?^Zl~} zegWV0K=K0wDqRz)9I>$VsJUd6gBs3*O7GG2gm7(^j?z zqiJ?e6Eh-tGNO=0pvX+B_ew8bSF{U*%+u8c<`NMl0RbPN_ zN7(3>(88&}dvB!_>R0a#d_N4Gw2d|*`>m9bWbN<1a6hTr11}J-TvyQgS_M$(W)C>u zQHF~VdfpyE@dSB)vpu^#+gl;3*OuTvu|()5v&=1qvu!cafelfbzyVZe0|{%uP@4;9 zyuN!$De3LF1g!%TBh1OeiscwXqKNInL-oVOm2Ln>UY_Nk&6Y#U!DoAY$f`=H5Glmr z;h&O*gzz-y{3+=-r2i@@1c!hcki~q?HD1?$LgoGHg6TYnsF9PrrM6yRp!&A%JMw(= zcf*tO+47gR+TV@!ogq6i4$$?SjJ^wDE}@o^2h$e!dY+J&dn47a+RBFTW7nhyBYyX3 z10Fq-VwYph=)Kj-avwE=;z_v^n*VCQ@LCXRB{0Y1Bug>t^w zrLo}kSh61XhM1iVUNoZjd?T4>&VF1C1m9@z4mebV-_+3W-j#Oy?A*UO-;*RxWbRMz zGufxj-Ds3j-R8JW?*0rhPYXvwQXEeqcHc5=%D+@bXBWId=Zds%?)8HGh3V8z<;E{V zJC8)ku6|^$ULPI(YSUEu7Jyq7?-Do_szJcVY?0PQk5O_K+rqrQ{5@Zawjqo&k{PlQR0)q!tJ^yEy({3S9P~l8&G1i?5$onPYgM;5ab+bsP;*> zxUN)|G!wJAp|M!(#;8-rEwoEUYc%=lJ^fbLz_fz)WzU&$Q#yQ%6@a!!ag=R=Zed3oT1%>W4eP_PUK(9HTaNCmt!c)Dzfh z+mlBxl#Z`fHn^GG0o;}ER8kKW_0ngPx?)e$oDtu~5%hFoN1^uiR4}BN5poB1TzN%0 zRVQ#s`*!a|iUktsHGPC5Q+U#lP_*-|i((Agv}DHXQPYl``_?!+9fo46s`1uB1u;Xb zLbhP$Ek|sm(hH0OsSrnKD?ID)cg~;gEjO2WFSyjgK^B{Ph5*2zZkB%OzOF5RkjZ)H zPEkLFtNi_=up2L>wvg(yjIB?{K8$42ItvP%aFv;1x*S}?slZMf-7zX%(j>-?UmiIS zIO2f^g>li?j&6;A4`4)A!GW}YxV%4umydk77%6gBnN%_hK%3j60V|05wj)!dPW#hf zcy>LpGqydJ0msy021c6BIezwha>qW|VcnK*9Sd;f-?_nFBM8;+ogqPGmD=yi0Tct7 zc_ywzQcwFNqcGN~UvexcLdB2gT0f+Sifh=G@AVv#Al+^?8fYSK{o#*qb>bNDj*9Q- z_KVFAZt#$T`L`{5%`HtAr+Ie1O1=pQU3e~yug00Y1R8{ANQ?{!5I1S6t4QXgHucxR z2^%l{jk%jGe44m5$aS|E*vG_Y${M25c?hK2QFM4}xnK4zxn#8@Wm(U}abE3T(Y9pL z@lTdTB=r%o$*XlRI2Ir%H>)V!qpEK=D()6S&q+#H0!IlzRv8;iPEw@p9c-eSq7Ht( z6%*J{z|)(@sqTybwKD9(2EL|9;?K_`iMuXEmd})FVII%b2G_)5szufTaWYbt6Y>hv z8rVmRLJ9OSt8n2->jtu)9T{*7kc-9}CUb4)jFn9HhF`so>Qm_w_g0DSJ#3Lc{}$vV zCUG0+&vqqh{j@lhz*+W6~7$o7NLE*sC(i2+f8UzuFP)y`PN+U{>6g`e`jV0JqbMk zR^8;s4cOJOC`_48vCb-DX)Cqt9_Nfkc)bWW#4OoQO)9tCte?e_S$YopCUEYP;2H#!9sUee5U1 z$QLG{OaDb6`N=TXg%Ur@4;(fTMS#`~4#rwrF@}>IE0CE2dDl2x7*xKIG@-$kZqeL} z)0?J>iSbD*b5%wOgNR2m?SS=rGod==&)VKf2 zVxPozRx~{W8WF}+T#Wp7Or)+}FGzT<^y-nHmRE@OxvTqTxJC2_Nd5lkGw@-Q#xwljz4LoFtX>3$xRT`09k50EII|QQp2p_c?HNMU7D00u-y=) zE0jX;Kf%7;n9%_6zhQht&71ex(AMQ|36J+Z>A4cyN^-|JxFppa1RNN550m8i!j+@W`0q^%bmv}G&-#hw7W{htKER8szPC6?)+lPyZuI8K{--el@9X= zEa<9Bm~E|5l}($V=3xEo?MR~v2lM(wGA zY@v#j4pY5hon`rGoNT}HyKltV_V%cc4{e_9+2d*rwM<8E&f0cwbKSn?us^r1b}urG z5-RLTH{O?2n-bGdex8tiY5uX_bicd*#U(-~1RGu99Xx_1j{k_y0a34G;rlU;mE1%m$6fh!hs@M7cdrM`$}^-ZkR{0k%v|ZI zzg9+T6ok|_&LxzdfM!Qvd7M-85q+RZ@NrdEv$u9 z6Vz&e7qIkDFeVzcKvaWYaeEhu#T{OeX~T3zc-jlnSHEzp@(e3ssUqwzykQA*iR96C(JWA;Tm4VakIAy%OdiE|E@yh{lC zoaZBM*13(?M9zf#qhQBev>Pn6!w=8sVTJYT9hw~1H0}gC^h=kzHh&6eh(*Rg_az93 zm>qDm+2g-4#J1N-Ob`nUSzoJ3GI^;C(Ic$Kt#hi=2# zz;*9XfRQ=zSY7&UHlORhDxx8VdIgm6bI{{+GSsJj)&V+O1mcb>&jSZgURFQV-n93&YWY?RbA)>|s)%P1nm z5fwf^{3b#`!Zow>syId~X@Tya{5~ck1b#V5ZsTbXcTY$kdA`;T>W^)sIDdPPb{EU5 zC8i=4Bch9>-Z37@NQgBN*=)xA-NV0e$zvmSHB!^~3(AlC-S8GaV41r)Y=Y14e%W~R z@coF{p$HnIi;z-CM%ctm-iagmGM;?|zI1Y8e<* zuedfRtEZ%-$!os;#CpT*>Ux@3!(m;a%$a>;86t9m6c})DakrsZVzQ`3bV<2%`jK>w zTW}Z4;6zbAOMb1$4{)jnic;ge^<7+p(zmz5U29l2=FQMAz!~pnOGRV)A}aV{*z4C+ zDpvSiNK~@59(5!r>UAv=gx*tUPOH^9su!xO-kJiiXPPy4&;Ek5s={`dj;JB3q^f1o z5=YhC=J`ZFn>gywh8pg3COYor!YZX_)%f^VhdTV<_*j73MxFU9De|O0xbC6(#;DiK zb%Y9hwp0IV2T1k|6{HayG&7|HdR?(qg`h1mr5Kb@1jp`D{0xnMjE%WW%w z(0}rd-2er$4Vp6bq%BR1?-VHkFfA>N?y9%p?$QyYsK-!}=WRS0}Q+ z7|%DOXm~7|`dsDL8~6)_3nJMBAKY4Fh07}`PIW+>+_#OVtf%v+&}g)?hP={Q>yEtB zXO1eSkCG$Xry3`|Q3N{ylYH869v5I{=8RBnAYl)mrn(s#DD9AGM?!4X7Wqjancjj; z{_`pmL%Nuv>CPPw(iIN#hYk?}5A(Zi%AM87WuBt3^uqCn%B$bfN7_{jcWT|9ezCJ( zXxTU9a9@*^e?`ox&JKXa(T*|=L8&!3zkSpRM0hyua=e@6e_ievvrq%AZ^Dia0D-$?)L zaUo*NcyM_*kZ3$&h%@u+e^UH=8~AsP`@h122f<>dCI9RF?@<};e=YuK1duFdl7DRe z-J|`PsQ)G0kXdG$f0F&1hVlPp0RwYQ2_d5*Wd3iI{tfznI;he?oahK4o-CM1)r@~b F{U5$dz&Zc` delta 7967 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 AJ^%m!