Skip to content

Commit 68eb11f

Browse files
authored
feat: development hardening (#1)
1 parent 61905ea commit 68eb11f

24 files changed

Lines changed: 1946 additions & 373 deletions

File tree

‎.github/workflows/checks.yml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,10 @@ jobs:
7878
run: >-
7979
./gradlew :cli:run
8080
--args='test --projectDirectoryPath=project-1 --onlyFailures --target=java
81-
--normalizeSpaces --withIcuModule --withInterpolationModule'
81+
--normalizeSpaces --withIcuModule'
82+
83+
- name: Verify formatter examples against Java SDK
84+
run: >-
85+
./gradlew :cli:run
86+
--args='examples --projectDirectoryPath=project-1 --onlyFailures
87+
--normalizeSpaces --withIcuModule'

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## 0.3.0
4+
5+
- Align datafile validation, format merging and input ownership with the JavaScript SDK.
6+
- Evaluate ICU branches lazily, preserve quoted literals and validate missing arguments only in selected branches.
7+
- Correct plural precision and effective currency and time zone handling.
8+
- Strengthen module removal, failed setup cleanup and shared concurrent shutdown.
9+
- Preserve child event snapshots, subscriptions and resolver ownership.
10+
- Expand executable conformance fixtures and run strict project examples in CI.
11+
- Document corrected behaviour and native formatting boundaries throughout the SDK README.
12+
13+
### Upgrade notes
14+
15+
- Invalid datafiles are rejected without changing stored state and report `invalid_datafile`.
16+
- Corrected formatting and argument validation can change output or errors for previously accepted inputs.
17+
- Integration CI requires the updated project-1 template from the published Messagevisor CLI.
18+
319
## 0.2.0
420

521
- Revived the Java SDK against the current Messagevisor datafile and runtime contracts.

‎Makefile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ verify-artifacts:
1717
bash scripts/verify-artifacts.sh
1818

1919
test-project-1:
20-
$(GRADLE) :cli:run --args='test --projectDirectoryPath=$(PROJECT_1) --onlyFailures --target=java --normalizeSpaces --withIcuModule --withInterpolationModule'
20+
$(GRADLE) :cli:run --args='test --projectDirectoryPath=$(PROJECT_1) --onlyFailures --target=java --normalizeSpaces --withIcuModule'
2121

2222
evaluate-project-1:
2323
$(GRADLE) :cli:run --args='evaluate --projectDirectoryPath=$(PROJECT_1) --locale=en-US --rawMessage=Hello --withIcuModule --json'
@@ -26,4 +26,4 @@ benchmark-project-1:
2626
$(GRADLE) :cli:run --args='benchmark --projectDirectoryPath=$(PROJECT_1) --locale=en-US --rawMessage=Hello --withIcuModule -n=1000 --json'
2727

2828
examples-project-1:
29-
$(GRADLE) --quiet :cli:run --args='examples --projectDirectoryPath=$(PROJECT_1) --withIcuModule'
29+
$(GRADLE) --quiet :cli:run --args='examples --projectDirectoryPath=$(PROJECT_1) --withIcuModule --normalizeSpaces --onlyFailures'

‎README.md‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ GitHub Packages requires authentication, including for public packages. Use a Gi
9898

9999
```kotlin
100100
dependencies {
101-
implementation("com.messagevisor:messagevisor-sdk:0.2.0")
102-
implementation("com.messagevisor:messagevisor-module-icu:0.2.0")
101+
implementation("com.messagevisor:messagevisor-sdk:0.3.0")
102+
implementation("com.messagevisor:messagevisor-module-icu:0.3.0")
103103
}
104104
```
105105

@@ -168,6 +168,8 @@ Messagevisor m = Messagevisor.create(
168168

169169
There is also a simpler interpolation module that can be used to interpolate primitive values into messages without the full ICU MessageFormat syntax.
170170

171+
The ICU module evaluates only the selected `select`, `plural`, or `selectordinal` branch. Values used exclusively by other branches are not required, and their formatters do not run. A missing argument in the selected branch reports `invalid_message` and throws. Apostrophe quoted ICU syntax stays literal, and inserted values are never parsed again as message syntax.
172+
171173
## Translations
172174

173175
Now that we have the SDK instance, we can start translating messages.
@@ -298,7 +300,7 @@ Messagevisor m = Messagevisor.create(
298300
);
299301
```
300302

301-
The generated datafile already contains locale info, so you do not need to pass it manually.
303+
The generated datafile already contains locale info, so you do not need to pass it manually. An initial datafile establishes the active locale even when a different constructor `locale` is supplied.
302304

303305
### Setting datafile afterwards
304306

@@ -314,7 +316,13 @@ When no locale is active yet, the first successful `setDatafile` call also sets
314316

315317
You can keep calling `setDatafile` to update the datafile for a given locale.
316318

317-
If the SDK already has a datafile for the incoming locale, `setDatafile` merges the incoming datafile with the existing one by default. Segments, messages, and translations are merged by key, while top-level fields such as `revision`, `target`, and `formats` come from the incoming datafile.
319+
If the SDK already has a datafile for the incoming locale, `setDatafile` merges the incoming datafile with the existing one by default. Segments, messages, and translations are merged by key, while fields such as `revision` and `target` come from the incoming datafile. Formats merge by family and preset name. Omitted families and presets remain available, and an incoming preset with the same name replaces the whole stored preset. Evaluation still combines default formats, stored formats, and call formats field by field.
320+
321+
Typed `DatafileContent` inputs are copied on entry, just like parsed JSON. Loading, merging, or replacing a datafile does not mutate the supplied object or another independent instance that received it. To update stored state, call `setDatafile` again rather than editing the original input.
322+
323+
Malformed JSON or an invalid datafile shape reports `invalid_datafile` with the message `could not parse datafile`. The call does not throw or change stored state. A datafile requires `schemaVersion: "1"`, string identity fields, a nonempty `locale`, and object maps for `segments`, `messages`, and `translations`. Optional `formats` must be an object and optional `direction` must be `ltr` or `rtl`. Java also validates values when converting JSON into its typed datafile models.
324+
325+
Dictionary keys such as `constructor`, `toString`, and `__proto__` are ordinary keys. They resolve only when present, including in translations, defaults, named presets, segments, and context maps.
318326

319327
### Replacing datafile
320328

@@ -348,7 +356,7 @@ Once you have the desired datafiles set, switch between loaded locales at runtim
348356
m.setLocale("nl-NL");
349357
```
350358

351-
The SDK switches the active locale to one that already has a datafile loaded. `setLocale` throws if that locale has no datafile yet.
359+
The SDK switches the active locale to one that already has a datafile loaded. `setLocale` reports `missing_datafile` and throws if that locale has no datafile yet, leaving the active locale unchanged.
352360

353361
**NOTE**: `setDatafile` for a new locale does not change the active locale automatically. The first datafile loaded becomes active, and later `setDatafile` calls for other locales only register those datafiles until you call `setLocale`.
354362

@@ -503,7 +511,7 @@ m.formatDate(
503511
);
504512
```
505513

506-
Time zone resolution follows the same rules as [Time zone](#time-zone): per-call `timeZone`, then the preset's `timeZone`, then the instance time zone.
514+
Time zone resolution follows the same rules as [Time zone](#time-zone): the call's `timeZone`, then the preset's `timeZone`, then the instance time zone, then the host default captured when the root instance was created. Children share that captured host default. This also applies to ICU date and time arguments, including bare arguments, styles, skeletons, and nested branches. A bare direct `formatTime` call formats hours, minutes, and seconds. Unknown time zone identifiers are rejected rather than silently becoming GMT.
507515

508516
Use `formatDateToParts` for segmented output:
509517

@@ -584,7 +592,7 @@ m.formatList(
584592
);
585593
```
586594

587-
Options are mapped to ICU4J list formatting behavior. Use `formatListToParts` for simplified element parts.
595+
Options are mapped to ICU4J list formatting behaviour. `formatListToParts` returns native element and literal records, preserving conjunctions and separators. Joining the part values reproduces `formatList`; an empty list returns no parts.
588596

589597
### Display name formatting
590598

@@ -610,8 +618,13 @@ m.formatPlural(1);
610618

611619
m.formatPlural(2, true);
612620
// ordinal category
621+
622+
m.formatPlural(1, Map.of("minimumFractionDigits", 2));
623+
// → "other" in English, because the visible number is 1.00
613624
```
614625

626+
The options map accepts plural `type` (`cardinal` or `ordinal`), fraction and significant digit precision, and rounding options. These affect the plural operands, not just display. You can include `locale` in the map or pass a separate `EvaluationOptions`. Children expose the same overloads.
627+
615628
You can also pass `EvaluationOptions` when the plural category should use a different locale:
616629

617630
```java
@@ -822,9 +835,9 @@ Common built-in codes include:
822835
- `missing_translation`: key missing from datafile and `defaultTranslations`
823836
- `missing_datafile` / `missing_locale`: locale or datafile not available
824837
- `missing_format`: a requested named format preset is absent
825-
- `invalid_format`: formatter options are invalid for the runtime
838+
- `invalid_format`: formatter options or date values are invalid for the runtime, including unknown time zones; the exception is rethrown
826839
- `invalid_datafile`: JSON parse or shape failure
827-
- `invalid_message`: module formatting threw
840+
- `invalid_message`: a module format or transform hook threw; details include the hook and effective locale, and the original exception is rethrown
828841
- `deprecated_message`: message has deprecated metadata
829842
- `message_override_matched`: debug-level override match
830843
- `unsupported_formatter`: runtime-native formatter limitation or unsupported option
@@ -901,6 +914,12 @@ m.removeModule("icu");
901914

902915
Resolver changes installed during module setup belong to that module. Setup failure or removal restores the previous resolver, and spawned instances observe later parent resolver changes dynamically.
903916

917+
Removal also clears that module's diagnostic subscriptions and resolver registrations in existing children. Retained module APIs cannot register new subscriptions or resolvers after removal. Closing a child clears its own registrations without closing shared modules.
918+
919+
Constructor modules run setup after initial locale, defaults, and datafile state are ready, and before `sdk_initialized` is reported. Setup failure reports `module_setup_error`, cleans up partial registration, and does not throw from construction.
920+
921+
Java completes the failed module's synchronous cleanup before delivering `module_setup_error`, so a diagnostic observer may safely close the SDK. The original setup failure remains the diagnostic's cause even if partial cleanup also fails.
922+
904923
### Registering at project level
905924

906925
It is recommended that the same modules you use with the SDK in your application are also registered in your Messagevisor project configuration.
@@ -980,6 +999,8 @@ m.close();
980999
m.closeAsync().join();
9811000
```
9821001

1002+
Concurrent and repeated `closeAsync()` calls share the same completion future, including any aggregate cleanup failure. A concurrent `close()` waits for that cleanup too. Modules are closed once, in reverse registration order.
1003+
9831004
## Translation lookup
9841005

9851006
`translate` and `getRawTranslation` resolve a source string first, then run registered modules on that string. Understanding lookup helps when debugging overrides, fallbacks, and missing-translation diagnostics.
@@ -1031,6 +1052,8 @@ After the source string is resolved, `translate` runs modules in registration or
10311052

10321053
The Java CLI reuses the JavaScript Messagevisor CLI for source-project truth. It shells out to `npx messagevisor` for built datafiles, tests, segments, and examples, then evaluates them with the Java SDK.
10331054

1055+
The examples command compares every Java result with `expectedByRuntime.java` when present, otherwise with the JavaScript `evaluatedTranslation`. Mismatches make the command fail. Use `--onlyFailures` for compact output and `--normalizeSpaces` to treat only ordinary spaces, no break spaces, and narrow no break spaces as equivalent. Both reference project Make targets use only the ICU module, matching the project configuration.
1056+
10341057
The SDK test suite also executes the canonical language-neutral `conformance/sdk-v1.json` contract. That contract covers conditions and segments, combined override matching, empty and fallback translations, deprecation diagnostics, locale-keyed datafile merge/replacement, module lifecycle, diagnostic envelopes, and event ordering with change sources. The bundled fixture is checked against the monorepo copy when both repositories are available.
10351058

10361059
```sh
@@ -1079,7 +1102,7 @@ make verify-artifacts
10791102

10801103
1. Update `gradle.properties`, the installation versions in this README, and `CHANGELOG.md` to the same release version.
10811104
2. Merge the release commit into `main`.
1082-
3. Create and publish a GitHub release using a semantic version tag with a `v` prefix, such as `v0.2.0`.
1105+
3. Create and publish a GitHub release using a semantic version tag with a `v` prefix, such as `v0.3.0`.
10831106
4. GitHub Actions validates the tag, builds and tests every artifact, checks the publication boundaries, and publishes the signed artifacts to the configured Maven repository.
10841107
5. Verify the published POMs, JARs, source JARs, Javadoc JARs, signatures, and checksums from a clean consumer project.
10851108

@@ -1101,7 +1124,7 @@ Where Messagevisor project fixtures need cross-runtime expectations, prefer `exp
11011124

11021125
Do not add Java branches for specific locales, named time zones, unit labels, or compact suffixes. Use ICU4J locale APIs such as number, measure, date/time, relative, list, and display-name formatting, then document unavoidable differences in fixtures.
11031126

1104-
The ICU module uses ICU4J classic `MessageFormat`. ICU MessageFormat 2 is intentionally not used for this first Java port because it is still treated as a newer technical-preview style API in the ICU ecosystem.
1127+
The ICU module uses ICU4J's classic message parser and native plural rules and formatters. It traverses the parsed message to evaluate only selected branches, while ICU handles apostrophe quoting, number styles, and skeletons. ICU MessageFormat 2 is not used.
11051128

11061129
## Related
11071130

‎cli/src/main/java/com/messagevisor/cli/MessagevisorJavaCli.java‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,12 @@ static final class ExamplesCommand implements Callable<Integer> {
308308
@Option(names = "--pretty")
309309
boolean pretty;
310310

311+
@Option(names = "--normalizeSpaces", description = "Normalize only ordinary, no-break and narrow no-break spaces")
312+
boolean normalizeSpaces;
313+
314+
@Option(names = "--onlyFailures")
315+
boolean onlyFailures;
316+
311317
@Override
312318
public Integer call() throws Exception {
313319
List<String> args =
@@ -317,22 +323,38 @@ public Integer call() throws Exception {
317323
"--includeEvaluationInput",
318324
locale == null ? null : "--locale=" + locale);
319325
Map<String, Object> result = readJson(runMessagevisor(shared.projectDirectoryPath, args), new TypeReference<>() {});
320-
reevaluateExamples(listOfMaps(result.get("locales")));
321-
reevaluateExamples(listOfMaps(result.get("messages")));
326+
List<String> failures = new ArrayList<>();
327+
List<Map<String, Object>> examples = new ArrayList<>(listOfMaps(result.get("locales")));
328+
examples.addAll(listOfMaps(result.get("messages")));
329+
for (Map<String, Object> example : examples) {
330+
failures.addAll(verifyExample(example, shared.modules(), normalizeSpaces));
331+
}
322332
removeEvaluationInputs(result);
333+
result.put("failures", failures);
323334
if (json) {
324335
printJson(result, pretty);
325336
} else {
326-
printExamplesPlain(result);
337+
if (!onlyFailures) printExamplesPlain(result);
338+
failures.forEach(System.out::println);
339+
System.out.println("Examples: " + examples.size() + " evaluated, " + failures.size() + " failed");
327340
}
328-
return 0;
341+
return failures.isEmpty() ? 0 : 1;
329342
}
343+
}
330344

331-
private void reevaluateExamples(List<Map<String, Object>> examples) {
332-
for (Map<String, Object> example : examples) {
333-
reevaluateExample(example, shared.modules());
334-
}
335-
}
345+
static List<String> verifyExample(Map<String, Object> example, List<MessagevisorModule> modules, boolean normalizeSpaces) {
346+
Map<String, Object> expected = new LinkedHashMap<>(example);
347+
expected.put("expectedTranslation", example.get("evaluatedTranslation"));
348+
reevaluateExample(example, modules);
349+
List<String> failures = new ArrayList<>();
350+
compareTranslation(expected, stringValue(example.get("target")), stringValue(example.get("locale")),
351+
stringValue(example.get("evaluatedTranslation")), normalizeSpaces, failures);
352+
Map<String, Object> identity = new LinkedHashMap<>(expected);
353+
identity.remove("evaluationInput");
354+
identity.remove("expectedTranslation");
355+
identity.remove("evaluatedTranslation");
356+
identity.remove("expectedByRuntime");
357+
return failures.stream().map(failure -> "Example: " + identity + "\n" + failure).toList();
336358
}
337359

338360
static void reevaluateExample(Map<String, Object> example, List<MessagevisorModule> modules) {

‎cli/src/test/java/com/messagevisor/cli/MessagevisorJavaCliTest.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,23 @@ void examplesUseIncludedEvaluationDatafileAndFormats() {
8383
assertThat(rawExample.get("evaluatedTranslation")).isEqualTo("1.2");
8484
}
8585

86+
@Test
87+
void examplesCompareEveryNativeResultAndPreferExplicitRuntimeExpectations() {
88+
Map<String, Object> example = map("locale", "en-US", "description", "literal",
89+
"rawMessage", "native", "evaluatedTranslation", "JavaScript", "evaluationInput", map());
90+
assertThat(MessagevisorJavaCli.verifyExample(example, List.of(), true)).singleElement()
91+
.asString().contains("description=literal", "expected: JavaScript", "actual: native");
92+
example.put("evaluatedTranslation", "JavaScript");
93+
example.put("expectedByRuntime", Map.of("java", "native"));
94+
assertThat(MessagevisorJavaCli.verifyExample(example, List.of(), true)).isEmpty();
95+
example.put("rawMessage", "A\u00a0B\u202fC");
96+
example.put("expectedByRuntime", Map.of("java", "A B C"));
97+
assertThat(MessagevisorJavaCli.verifyExample(example, List.of(), true)).isEmpty();
98+
assertThat(MessagevisorJavaCli.verifyExample(example, List.of(), false)).hasSize(1);
99+
example.put("rawMessage", "A\u200e B C");
100+
assertThat(MessagevisorJavaCli.verifyExample(example, List.of(), true)).hasSize(1);
101+
}
102+
86103
private static Map<String, Object> map(Object... entries) {
87104
Map<String, Object> result = new LinkedHashMap<>();
88105
for (int i = 0; i < entries.length; i += 2) {

‎gradle.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version=0.2.0
1+
version=0.3.0

‎module-icu/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Published releases use the `com.messagevisor` group:
1414

1515
```kotlin
1616
dependencies {
17-
implementation("com.messagevisor:messagevisor-sdk:0.2.0")
18-
implementation("com.messagevisor:messagevisor-module-icu:0.2.0")
17+
implementation("com.messagevisor:messagevisor-sdk:0.3.0")
18+
implementation("com.messagevisor:messagevisor-module-icu:0.3.0")
1919
}
2020
```
2121

‎module-icu/build.gradle.kts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ dependencies {
1313
testRuntimeOnly(libs.junit.platform.launcher)
1414
testImplementation(libs.assertj.core)
1515
}
16+
17+
sourceSets.test {
18+
resources.srcDir("../sdk/src/test/resources")
19+
}

0 commit comments

Comments
 (0)