diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 0000000..52263c2 --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,99 @@ +name: Parity + +# Scheduled conformance report: neokapi's Go ports head-to-head against this +# bridge, using a released kapi and a plugin built from this checkout. The +# suite lives under parity/ and compiles against a released neokapi module. +# +# This workflow reports; it gates nothing. It has no pull_request trigger, so +# it never blocks a change here, and nothing in neokapi/neokapi consults it. +# +# Security: the dispatch inputs are consumed only through env vars, never +# inlined into `run:` strings. + +on: + schedule: + # Weekly, Monday 05:00 UTC. + - cron: "0 5 * * 1" + workflow_dispatch: + inputs: + okapi_version: + description: "Okapi Framework version to build the plugin for" + required: false + default: "1.48.0" + kapi_version: + description: "kapi release to test ('latest' = newest stable, or e.g. 1.3.0-rc9)" + required: false + default: "latest" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + parity: + name: parity-test + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + OKAPI_VERSION: ${{ inputs.okapi_version || '1.48.0' }} + steps: + - uses: actions/checkout@v6 + + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: maven + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: parity/go.mod + cache-dependency-path: parity/go.sum + + - name: Install a released kapi + uses: neokapi/setup-kapi@v1 + with: + version: ${{ inputs.kapi_version || 'latest' }} + plugins: "" + cache-tm: "false" + + - name: Regenerate the version-specific POM + # okapi-releases//pom.xml references the parent version at the time + # it was committed; regenerate so it matches the root POM at HEAD. + run: make generate-pom V="$OKAPI_VERSION" + + - name: Install the parent POM into the local Maven repository + # The version module declares neokapi:okapi-bridge-parent as its + # parent and `mvn package` does not install it first. + run: mvn -B install -N -DskipTests -q + + - name: Build the plugin tarball + run: | + make build V="$OKAPI_VERSION" + make plugin-v2 V="$OKAPI_VERSION" + + - name: Run the parity suite + run: make parity-test + + - name: Write the per-filter summary + if: always() + run: | + if [ -f .parity/test-comparison.json ]; then + cd parity && go run ./cmd/testcompare -in ../.parity/test-comparison.json -out ../.parity/parity-report.json + fi + + - name: Upload the parity report + if: always() + uses: actions/upload-artifact@v6 + with: + name: parity-report + path: | + .parity/test-comparison.json + .parity/parity-report.json + if-no-files-found: warn + retention-days: 90 diff --git a/.gitignore b/.gitignore index 06258d1..d3de921 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ dist/ # Transient fleet-authoring manifest (per-id gap list) .gap-manifest.json + +# Parity sandbox (make parity-sandbox) +.parity/ diff --git a/CLAUDE.md b/CLAUDE.md index d51060f..d2735f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,3 +155,25 @@ The parent `pom.xml` declares all infrastructure dependencies (gRPC, Gson, Snake - Schema `version` is an integer N that increments on content change; `$id` includes the version - CI on push to main: regenerates composites and verifies schemas are up-to-date (fails if out of date) - CI on tag push: matrix build across all 11 Okapi versions + +## Parity suite (`parity/`) + +`parity/` is a separate Go module (`github.com/neokapi/okapi-bridge/parity`) +that runs neokapi's Go ports head-to-head against this bridge. It requires a +released `github.com/neokapi/neokapi` and nothing from neokapi's `host` or +`cli` modules; the daemon launcher is the plugin protocol's Mode C handshake +implemented locally. Format specs (`core/formats//spec.yaml`) are read from +that module's source directory (`go list -m`), so specs and readers come from +one version. Per-fixture divergence annotations live in `parity/annotations/`. + +```bash +make parity-vet # compile + vet the suite, no sandbox needed +make parity-test # sandbox (released kapi + fresh plugin tarball) + full suite +make parity-publish # suite + per-filter summary under .parity/ +``` + +The suite is behind the `parity` build tag, so `make test-go` never runs it. +`.github/workflows/parity.yml` runs it weekly and on dispatch, installs kapi +with `neokapi/setup-kapi@v1`, and uploads the report as an artifact. It is a +report, not a gate: it has no pull_request trigger, and nothing in the neokapi +repository consults it. See `parity/README.md`. diff --git a/Makefile b/Makefile index f8cedc5..ceac472 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,14 @@ help: @echo "Release:" @echo " make release Release current version (bump, tag, push, snapshot)" @echo "" + @echo "Parity (parity/, neokapi's Go ports against this bridge):" + @echo " make parity-sandbox Build .parity/ from a released kapi + a fresh plugin tarball" + @echo " make parity-test Run the parity suite against the sandbox" + @echo " make parity-publish Suite + per-filter summary (.parity/parity-report.json)" + @echo " make parity-vet Compile and vet the suite without a sandbox" + @echo " make parity-clean Remove the sandbox" + @echo " make regen-okapi-fixtures OKAPI_REPO=... Re-harvest fixtures from the Okapi Java tests" + @echo "" @echo "Supported versions: $(SUPPORTED_VERSIONS)" @echo "Latest version: $(LATEST_VERSION)" @@ -417,3 +425,6 @@ release: @echo "Release complete: v$(CURRENT_VERSION)" @echo " Tag: v$(CURRENT_VERSION)" @echo " Next dev: $(NEXT)-SNAPSHOT" + +# The parity suite (parity/): neokapi's Go ports against this bridge. +include parity/parity.mk diff --git a/parity/README.md b/parity/README.md new file mode 100644 index 0000000..3c2518c --- /dev/null +++ b/parity/README.md @@ -0,0 +1,112 @@ +# Parity suite + +This module runs neokapi's Go ports of Okapi filters and steps head-to-head +against the Okapi Framework, through this repository's bridge plugin and, when +a build is reachable, the tikal CLI. It answers one question per filter: given +the same input and the same semantic configuration, do the two implementations +produce the same content? + +The suite is a scheduled conformance report, not a gate. It runs weekly from +`.github/workflows/parity.yml`, which installs a released kapi, builds the +plugin from this checkout, runs the suite and uploads the report as an +artifact. No pull request here or in `neokapi/neokapi` waits on it. + +## Layout + +| Path | Contents | +| --- | --- | +| `*.go` | The harness: sandbox resolution, daemon launch, native and bridge runners, the canonical comparison, the report collector | +| `spec/` | `ParityRunner`, which drives a format's `spec.yaml` through both implementations | +| `formats/` | One spec test per filter, the harvested fixtures, and the `bridge_config` translators | +| `roundtrip/` | The three-engine round-trip harness (see `docs/roundtrip-testing.md`) | +| `tools/` | The step table: every bridge step run for stability | +| `annotations/` | Per-format fixture annotations: severity, issue link and skip directives for known divergences | +| `cmd/testcompare` | Turns the raw report into the per-filter summary | +| `cmd/okapi-test-scan` | Harvests `@Test` snippets from the Okapi Java sources into `formats/fixtures_*_generated.go` | +| `pseudobench/` | Pseudo-translation throughput of kapi with and without the bridge over the Okapi corpus | +| `scripts/sandbox.sh` | Builds the sandbox the suite runs against | + +## Dependencies + +`go.mod` requires a released `github.com/neokapi/neokapi` and nothing from the +`host` or `cli` modules: the suite compiles from the module proxy, and the +daemon launcher is the plugin protocol's Mode C handshake implemented here. +Format specs (`core/formats//spec.yaml`) are read from that module's +source directory, which `NeokapiDir` resolves with `go list -m`, so the specs +and the readers under test come from one version. + +Bump the pin when a new kapi release is out: + +```bash +cd parity && go get github.com/neokapi/neokapi@v1.3.0 && go mod tidy +``` + +## Running locally + +```bash +make parity-test # sandbox + full suite +make parity-publish # suite + per-filter summary under .parity/ +KAPI_BIN=/path/to/kapi make parity-test +OKAPI_VERSION=1.47.0 make parity-test +PARITY_FORCE=1 make parity-sandbox # rebuild the sandbox +``` + +The sandbox lives under `.parity/` (gitignored): + +``` +.parity/ +├── bin/kapi the kapi under test (copied from $KAPI_BIN or PATH) +├── plugins/okapi-bridge/ the plugin tarball built by `make plugin-v2`, unpacked +├── okapi-testdata// the okapi-testdata release (binary fixtures) +└── test-comparison.json the raw report +``` + +Tests resolve the sandbox from `KAPI_PARITY_SANDBOX`, or by walking up from +the working directory to a `.parity/bin/kapi`, and fail when there is none. +`KAPI_PARITY_SKIP=1` turns that failure into a skip for runs that do not care +about parity. Nothing in the suite reads the user's installed plugins. + +One format: + +```bash +cd parity && KAPI_PARITY_SANDBOX=$PWD/../.parity \ + go test -tags "fts5,parity" -count=1 -run TestParityHtmlSpec ./formats/ +``` + +Every tag the suite needs is spelled in one `-tags` flag; `go` keeps only the +last occurrence of the flag. + +## Reading the report + +`test-comparison.json` holds one row per filter or step and pass: + +- `format`: the native reader against the bridge reader on the same input, + compared on the canonical projection (part types, block ids, translatable + flag, source and target text with structural placeholders for inline codes). +- `format-roundtrip`: read then write, compared on bytes, where both sides + declare a writer. +- `format-tikal`: the native round trip against an extract-then-merge run of + the Okapi CLI, when `OKAPI_TIKAL` points at a launcher. +- `format-fixture`: one row per harvested upstream `@Test` fixture. +- `step`: a bridge step run against a fixed input for stability. + +`expected_fail` and `parity_warn` are documented divergences, not regressions; +the annotation under `annotations/.yaml` or the `expected_fail` entry +in the format's `spec.yaml` says why. `cmd/testcompare` folds the rows into +`parity-report.json`: one row per filter or step with totals. + +## Adding a parity case + +1. Add or extend the format's `spec.yaml` in neokapi (`core/formats//`): + the bridge filter class, the tikal corner and the parity skips are declared + there. +2. Add `formats/_spec_test.go` here, wiring the native reader for a + head-to-head comparison or leaving it unset for a bridge-only snapshot. Add + `_bridge_config.go` when the bridge's parameter shape differs. +3. For a step, add a row to the table in `tools/spec.go`. +4. Run the suite. A pure default-only difference is converged with explicit + configuration in the bridge config translator, not recorded as an expected + failure. + +The harvested fixtures are regenerated with `make regen-okapi-fixtures +OKAPI_REPO=/path/to/Okapi`; review the diff before committing. diff --git a/parity/annotations/csv.yaml b/parity/annotations/csv.yaml new file mode 100644 index 0000000..112a751 --- /dev/null +++ b/parity/annotations/csv.yaml @@ -0,0 +1,20 @@ +format: csv +fixtures: + computer_science_article.csv: + severity: fixture-bug + summary: "Source contains Windows-1252 byte 0x96 (en-dash) with no explicit charset marker. Native preserves the raw byte; okapi treats it as invalid UTF-8 and replaces with U+FFFD. Neither is 'right' for parity without an explicit encoding annotation." + skip: + engines: [okapi] + reason: "fixture has Windows-1252 0x96 with no charset marker; engines disagree on invalid-byte handling" + some_blank_columns.csv: + severity: cosmetic + summary: "Source rows have layout `one,,four,,seven` (empty translatable column). With sourceColumns=2 okapi falls back to writing column-1's translation into the empty column-2 slot; native leaves the empty cell empty. Two defensible behaviours." + skip: + engines: [okapi] + reason: "okapi writes col-1 translation into adjacent empty cell; native leaves empty cells empty" + test2cols.csv: + severity: fixture-bug + summary: "Fixture is intentionally malformed CSV (unmatched double-quotes spanning newlines, embedded `\"\"` inside quoted regions). Native and okapi recover from the broken quote state differently — both are defensible readings of broken input." + skip: + engines: [okapi] + reason: "fixture is malformed CSV (quotes spanning lines); engines recover from broken state differently" diff --git a/parity/annotations/html.yaml b/parity/annotations/html.yaml new file mode 100644 index 0000000..0e02893 --- /dev/null +++ b/parity/annotations/html.yaml @@ -0,0 +1,6 @@ +format: html +fixtures: + France_Culture_fr.html: + severity: cosmetic + summary: "Non-deterministic html-canonical divergence, PRE-EXISTING and unrelated to the openxml faithful-writer work (html reader/writer + html-canonical normalizer untouched). The fixture flips between canonical-equal and divergent across identical-code runs: when divergent, native emits ~837 extra bytes around the document's Open Graph tags (e.g. article:publisher) in word/head reconstruction. The difference is document metadata only — it renders identically — so it is cosmetic. The non-determinism (native html-writer head/meta emission ordering) is a separate test-stability issue to investigate; annotated here so the dashboard documents it whichever way the flake lands." + spec_ref: "HTML5 §4.2.5 (the meta element); §13 (parsing)" diff --git a/parity/annotations/json.yaml b/parity/annotations/json.yaml new file mode 100644 index 0000000..b428b8e --- /dev/null +++ b/parity/annotations/json.yaml @@ -0,0 +1,8 @@ +format: json +fixtures: + invalid_by_most_processors.json: + severity: fixture-bug + summary: "Intentionally-malformed JSON5 fixture with bare keys and single-quoted strings. Both engines emit lenient output; json-canonical normalizer can't parse either side, so there's no useful tier comparison." + skip: + engines: [okapi] + reason: "JSON5 fixture; both engines emit lenient output, json-canonical can't parse either side" diff --git a/parity/annotations/odf.yaml b/parity/annotations/odf.yaml new file mode 100644 index 0000000..32b8c3b --- /dev/null +++ b/parity/annotations/odf.yaml @@ -0,0 +1,20 @@ +format: odf +# Measured over the 15 upstream okapi/filters/openoffice fixtures: +# +# odf | native | 15 total | 0 byte | 15 canon (15 faithful) | 0 divergent +# odf | bridge | 15 total | 15 byte | 0 canon | 0 divergent +# +# Native reaches canonical-equal rather than byte-equal because okapi +# re-serializes every stream on round-trip — reordering attributes, rewriting +# the declaration, expanding empty elements — while native writes each stream +# from the source's own bytes. Native is the more faithful side, which is why +# the format is classified CanonFaithful and why driving these to byte-equal +# against okapi would be a regression: an untranslated .odt/.ods/.odp/.odg +# comes back byte for byte against the SOURCE, on all 15 +# (core/formats/odf/package_roundtrip_test.go). +# +# Bridge is byte-equal on all 15 (it literally invokes Okapi's +# OpenOfficeFilter via okapi-bridge#11's two-pass pipeline). +# +# No per-fixture divergences. +fixtures: {} diff --git a/parity/annotations/openxml.yaml b/parity/annotations/openxml.yaml new file mode 100644 index 0000000..a2e715f --- /dev/null +++ b/parity/annotations/openxml.yaml @@ -0,0 +1,264 @@ +# Per-fixture parity annotations for openxml. +# +# Consumed by parity/roundtrip's annotation loader to enrich the +# /parity/fixtures dashboard with severity, summary, issue link, and +# spec citations. The annotation system is read-only metadata — it does +# NOT change tier comparison results. See PARITY_NOTES.md for the full +# spec/Okapi/neokapi three-way framing. +# +# severity: +# bug Real data-loss or correctness bug in neokapi. +# Must be fixed; fixture currently fails. +# cosmetic Output differs only in attribute placement, +# run granularity, or other choices that render +# identically. Spec-permissible on both sides. +# native-more-correct Native output is more spec-compliant than the +# Okapi reference. Divergence is a "win" — the +# comparison flags it only because Okapi is the +# reference, not because native is wrong. +# fixture-bug The upstream fixture itself is malformed or +# otherwise unusable for parity comparison. +# unknown Not yet triaged. +# +# skip: optional. When present, the parity harness skips the listed +# engines for this fixture. Replaces the legacy fileSkip map. +format: openxml +fixtures: + 830-7.docx: + severity: cosmetic + summary: "#598 RESOLVED. Both complex-field data-loss gaps are fixed: (a) pre-fldChar body text in the field-OPENING run (#598 preFieldBody guard), and (b) #598a — translatable body text BETWEEN a and a following in one source is split out as a translatable run while field markers stay opaque (parseRunWithFieldState flushOpaqueSegment/reengageRawCapture; the writer re-fuses into the original single ; confirmed via TestComplexFieldEndTextBeginBodyText + round-trip byte-identity). The #598b run-level strip is also gone (faithful-writer default preserves source rPr). Only residual byte-divergence is the §17.3.2.26 content-category class shared by the other cosmetic fixtures: native faithfully preserves moot complex-script rFonts attrs (e.g. cs=\"Arial\" on a run with no complex-script text) that Okapi clarifies away — both render identically; not reconstructable symmetrically in the comparator without the deferred RunFonts content-category port." + spec_ref: "ECMA-376-1 §17.3.2.1 (CT_R), §17.16.5 (complex fields), §17.3.2.26 (rFonts content categories)" + notes_anchor: residual-7-divergent-fixtures + + # 847-3.docx is now CANON: the faithful-writer default (OptimiseWordStyles + # =false) removes WSO's monotonic synth-style ID counter — the documented + # "architectural blocker" — and the effective-rPr parity normalizer + # resolves the style indirection symmetrically. Annotation removed because + # the divergence is cleared (per the fail-new gate's grandfathering rule). + + 851.docx: + severity: cosmetic + summary: "East-Asian segment rFonts inheritance differs; same translatable content. Per §17.3.2.26, rFonts categories only apply to characters in their script range, so attribute placement is moot for the rendered text." + spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + StartsWithLineSeparator.docx: + severity: cosmetic + summary: "Bare-run rFonts strip + synth on hAnsi remainder. Native splits one source run into two — same content, more granular markup." + spec_ref: "ECMA-376-1 §17.3.2.26" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + br2.docx: + severity: cosmetic + summary: "Font-subset rename (ONGPN A+ Garamond) via style chain. Native emits the embedded-subset alias unresolved; reference resolves to the public family name. Both render identically." + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + multiple_tabs.docx: + severity: cosmetic + summary: "Font-category fragmentation + tab fusion. Native and reference both carry the same 4 translatable text spans — rFonts placement differs." + spec_ref: "ECMA-376-1 §17.3.2.26" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + delTextAmp.docx: + severity: cosmetic + summary: "#597 (lost ; injected ) is RESOLVED by the faithful-writer default (OptimiseWordStyles=false): the source rPr is preserved verbatim, so the single-char run keeps and no docDefaults rFonts overlay is applied (verified: source spacing count 1 → output 1; source rFonts cs=Helvetica count 1 → output 1, no over-injection). The residual divergence vs Okapi is the §17.3.2.26 content-category rFonts/szCs class shared with 851/multiple_tabs/br2 — Okapi drops complex-script properties (rFonts cs, szCs) that are moot for the runs' Latin text; native preserves them. Both render identically; not style indirection." + spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts content categories); §17.3.2.35 (w:spacing — now preserved)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 956.docx: + severity: native-more-correct + summary: "Native preserves translatable text before in the same source ; Okapi drops it. Functionally a win — flagged as divergent only because Okapi is the byte-equality reference." + spec_ref: "ECMA-376-1 §17.16.5" + notes_anchor: status-2026-05-16 + + N_001_Auswertung_Part2.docx: + severity: native-more-correct + summary: "Native preserves + text before in the same source ; Okapi drops them. Functionally a win." + spec_ref: "ECMA-376-1 §17.16.5" + notes_anchor: status-2026-05-16 + + neverendingloop.docx: + severity: native-more-correct + summary: "Native preserves translatable text before in the same source ; Okapi drops it. Functionally a win." + spec_ref: "ECMA-376-1 §17.16.5" + notes_anchor: status-2026-05-16 + + # ── Faithful-writer residual class (faithful default + effective-rPr + # parity normalizer, 2026-05-22) ──────────────────────────────────────── + # + # With OptimiseWordStyles=false (native is faithful) and the parity + # effective-rPr normalizer (parity/roundtrip/normalizers_openxml.go) + # resolving ECMA-376-1 §17.7 style indirection symmetrically, every pure + # style-indirection fixture (pStyle/rStyle/docDefaults → effective rPr) + # now reaches CANON. The fixtures below remain divergent on Okapi's + # §17.3.2.26 content-category clarification (dropping complex-script + # bCs/iCs/szCs/rFonts-cs on runs whose text has no complex-script + # characters, and the non-complex b/i/sz mirror) — a text-driven + # RunFonts decision Okapi applies BEFORE building its synth styles, which + # cannot be reconstructed symmetrically in the comparator without dropping + # properties Okapi keeps as genuine overrides. These render identically + # (the dropped properties govern only characters the run does not contain) + # and are NOT style indirection. See PARITY_NOTES.md and the design note + # docs/internals/research/openxml-faithful-writer-design.md. + + 1341-textbox-with-a-hyperlink.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves on a Latin-text textbox run; Okapi drops it (complex-script bold is moot for non-complex text). Style indirection (pStyle NF…) resolves to canon; this residual is the RunFonts content-category class." + spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts); §17.3.2.16 (CT_OnOff bCs)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + large-attribute.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves on a non-complex-script run; Okapi drops it as moot. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.16" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + MissingPara.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves (complex-script font size) on Latin-text runs; Okapi drops it as moot. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38 (CT_HpsMeasure szCs)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 888.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves the source while Okapi clarifies complex-script font size to the regular for the run's non-complex text. The szCs governs only complex-script glyphs the run does not contain; renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + AlternateContentTest.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves source / values while Okapi clarifies them per the run's detected content category. Same translatable content; renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + EndGroup.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: rFonts attribute placement differs (native preserves source ascii/cs/eastAsia/hAnsi; Okapi emits hint=eastAsia). rFonts categories apply only to characters in their script range, so the difference is moot for the rendered text." + spec_ref: "ECMA-376-1 §17.3.2.26 (CT_Fonts)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + graphicdata.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: rFonts attribute placement differs between native (source-preserved) and Okapi (content-category resolved). Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + Hangs.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: rFonts/kern/sz and b placement differ between native (source-preserved inline) and Okapi (content-category resolved). Same translatable content; renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + NoStylesXml.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: with no styles.xml, native preserves the source run's rFonts/sz inline while Okapi resolves the run's content-category fonts. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 899.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves on runs whose text has no complex-script (RTL) characters; Okapi drops it as moot. only affects bidirectional layout of complex-script glyphs the run does not contain; renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.30 (CT_OnOff rtl)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 947-cs.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: for complex-script text Okapi drops the non-complex toggles / (moot for complex-script glyphs) and keeps ; native preserves the source's inline toggles. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.1 (CT_R)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 947-non-cs-and-cs.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: Okapi drops non-complex toggles / on complex-script runs as moot; native preserves them. Same translatable content; renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.1" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + content_category_test.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves / and the source form; Okapi resolves the content category and clarifies color auto→000000 (the parity normalizer folds auto→000000 but the residual b/sz content-category drop remains). Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.6 (CT_Color)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 992.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves source // while Okapi resolves them per the run's content category. Same translatable content; renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.38; §17.3.2.40 (CT_Underline)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + Practice2.docx: + severity: cosmetic + summary: "Content-category §17.3.2.26: native preserves a redundant that Okapi drops (the run's pre-combined chain already bolds it, so the explicit toggle is moot). Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.26; §17.3.2.1" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 1311.docx: + severity: cosmetic + summary: "Color/theme-color clarification + content-category: native preserves the source token and inline / while Okapi resolves the theme color to its concrete RGB and drops the moot complex-script bold. Both denote the same rendered colour and weight." + spec_ref: "ECMA-376-1 §17.3.2.6 (CT_Color); §17.3.2.26" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 834.docx: + severity: cosmetic + summary: "Underline resolution §17.3.2.40: native preserves the source while Okapi resolves the style chain to for this footnote run. A genuine style-chain underline resolution difference; the dominant content compares clean and renders per each engine's resolution." + spec_ref: "ECMA-376-1 §17.3.2.40 (CT_Underline)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + TestLTinsideBoxFails.docx: + severity: cosmetic + summary: "Underline resolution §17.3.2.40: native preserves while Okapi resolves to inside a textbox run. Style-chain underline resolution difference; same translatable content." + spec_ref: "ECMA-376-1 §17.3.2.40" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 830-2.docx: + severity: cosmetic + summary: "Highlight clarification §17.3.2.15: native preserves a source on an empty placeholder run inside a complex field; Okapi drops it (white highlight ≡ no highlight). Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.15 (CT_Highlight)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 830-3.docx: + severity: cosmetic + summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.15" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 830-4.docx: + severity: cosmetic + summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.15" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 830-5.docx: + severity: cosmetic + summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.15" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 830-6.docx: + severity: cosmetic + summary: "Highlight clarification §17.3.2.15: native preserves on a field placeholder run; Okapi drops it. Renders identically." + spec_ref: "ECMA-376-1 §17.3.2.15" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + highlights_block.docx: + severity: cosmetic + summary: "Highlight + content-category §17.3.2.15/§17.3.2.26: native and Okapi differ on a placement and the moot / toggles for the run's text. Same translatable content; renders identically." + spec_ref: "ECMA-376-1 §17.3.2.15; §17.3.2.26" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + 859.docx: + severity: native-more-correct + summary: "Okapi auto-injects onto a drawing-bearing paragraph mark (suppressing spellcheck for the anchored shape); native faithfully omits it (the source did not author it). is a proofing hint with no rendered effect; native preserves the source verbatim per §17.3.2.1." + spec_ref: "ECMA-376-1 §17.3.2.24 (CT_OnOff noProof); §17.3.2.1 (CT_R)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + document-style-definitions.docx: + severity: cosmetic + summary: "Run-granularity §17.3.2.1: native splits a styled span into more elements than Okapi (preserving the source's per-run rPr inline after §17.7 resolution), and the byte shape of the styled text differs accordingly. Same translatable content; both are spec-valid run partitions that render identically." + spec_ref: "ECMA-376-1 §17.3.2.1 (CT_R); §17.7 (style resolution)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 + + HiddenExcluded.docx: + severity: native-more-correct + summary: "Hidden-text extraction policy: native extracts and pseudo-translates text carrying (and resolves the source's hidden-style runs) while Okapi leaves the hidden text untranslated and re-emits a the native faithful path resolves differently. Native surfaces the hidden translatable content; the divergence is an extraction-policy difference, not style indirection." + spec_ref: "ECMA-376-1 §17.3.2.42 (CT_OnOff vanish)" + notes_anchor: verification-of-cosmetic-divergences-2026-05-16 diff --git a/parity/annotations/tmx.yaml b/parity/annotations/tmx.yaml new file mode 100644 index 0000000..98faf8c --- /dev/null +++ b/parity/annotations/tmx.yaml @@ -0,0 +1,14 @@ +format: tmx +fixtures: + code_fail.tmx: + severity: fixture-bug + summary: "Intentionally-malformed test fixture; okf_tmx rejects with 'no set to source language'." + skip: + engines: [okapi] + reason: "intentionally-malformed test fixture; okf_tmx rejects with 'no set to source language'" + code_id_difference.tmx: + severity: fixture-bug + summary: "Intentionally-malformed test fixture for code-id mismatch detection." + skip: + engines: [okapi] + reason: "intentionally-malformed test fixture for code-id mismatch detection" diff --git a/parity/annotations/ts.yaml b/parity/annotations/ts.yaml new file mode 100644 index 0000000..20b24a2 --- /dev/null +++ b/parity/annotations/ts.yaml @@ -0,0 +1,8 @@ +format: ts +fixtures: + issue531.ts: + severity: fixture-bug + summary: "okapi's pseudo round-trip emits malformed XML (a element closed by instead of ). Likely an okapi-side bug in the regression fixture for okapi GitHub issue #531. Native parses fine." + skip: + engines: [okapi] + reason: "okapi pseudo round-trip emits malformed XML (unclosed ); reference unusable" diff --git a/parity/annotations/xliff.yaml b/parity/annotations/xliff.yaml new file mode 100644 index 0000000..4b3c726 --- /dev/null +++ b/parity/annotations/xliff.yaml @@ -0,0 +1,8 @@ +format: xliff +fixtures: + lqiTest.xlf: + severity: fixture-bug + summary: "okf_xliff needs lqiTestIssues.xml in the same dir; harness now copies companions but okapi still rejects this fixture." + skip: + engines: [okapi] + reason: "okf_xliff needs lqiTestIssues.xml in the same dir; harness now copies companions but okapi still rejects this fixture" diff --git a/parity/annotations/yaml.yaml b/parity/annotations/yaml.yaml new file mode 100644 index 0000000..3fb8cbc --- /dev/null +++ b/parity/annotations/yaml.yaml @@ -0,0 +1,20 @@ +format: yaml +fixtures: + no-children-1-pretty.yaml: + severity: fixture-bug + summary: "Okapi YAML parser rejects !!timestamp tag." + skip: + engines: [okapi] + reason: "Okapi YAML parser rejects !!timestamp tag" + Test03.yml: + severity: fixture-bug + summary: "Okapi YAML parser rejects !!timestamp tag." + skip: + engines: [okapi] + reason: "Okapi YAML parser rejects !!timestamp tag" + emoji1.yaml: + severity: fixture-bug + summary: "Okapi YAML writer emits 4-byte UTF-8 emoji (U+1F60E 😎) as a lone low surrogate \\ude0e (missing high surrogate \\ud83d) — invalid UTF-16. yaml-canonical fails to parse the okapi reference." + skip: + engines: [okapi] + reason: "okapi YAML writer emits lone low surrogate for 4-byte emoji; invalid UTF-16" diff --git a/parity/bridge.go b/parity/bridge.go new file mode 100644 index 0000000..b074664 --- /dev/null +++ b/parity/bridge.go @@ -0,0 +1,407 @@ +//go:build parity + +package parity + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/neokapi/neokapi/core/model" + pb "github.com/neokapi/neokapi/core/plugin/proto/v2" + "github.com/neokapi/neokapi/core/plugin/protoconvert" +) + +// BridgeRequest configures one Process RPC against the okapi-bridge +// daemon. Either InputBytes or InputPath must be set. +type BridgeRequest struct { + // FilterClass is the Java filter class name (e.g. okf_html, okf_json). + FilterClass string + + // InputBytes is the inline document content. Mutually exclusive with + // InputPath. + InputBytes []byte + + // InputPath is an absolute path the daemon can read directly. Mutually + // exclusive with InputBytes. + InputPath string + + // SourceLocale and TargetLocale default to "en" / "fr" when empty. + SourceLocale string + TargetLocale string + + // Encoding defaults to "UTF-8" when empty. + Encoding string + + // MimeType is optional. + MimeType string + + // FilterParams maps the filter's flat parameters (Okapi convention) + // to string values; the bridge applies suffixes (.b, .i) based on + // schema metadata. + FilterParams map[string]string + + // ConfigID names a built-in Okapi filter configuration to apply before + // opening (e.g. "okf_xmlstream-dita"). The bridge loads that config's + // parameters; FilterParams override on top. Empty = filter defaults. + ConfigID string + + // SubscribeParts narrows which PartType values flow back over the + // RPC. Empty (the default) streams every event. + SubscribeParts []int32 + + // Transform is an optional hook that mutates each Block before + // it's echoed back to the daemon during a round-trip. Only used + // by RunBridgeRoundTrip; ignored by RunBridge (read-only). When + // nil the round-trip echoes parts unchanged. Non-Block parts + // (Layer, Data, etc.) always echo through untouched. + Transform func(b *model.Block) +} + +// RunBridge drives a read-only Process RPC against the okapi-bridge +// daemon and returns the streamed parts. Failures are fatal — the +// caller is asserting parity. Use TryRunBridge when failures should +// surface as errors instead (Informational fixtures). +func RunBridge(t *testing.T, req BridgeRequest) []*model.Part { + t.Helper() + parts, err := TryRunBridge(t, req) + if err != nil { + t.Fatalf("RunBridge: %v", err) + } + return parts +} + +// TryRunBridge is the non-fatal variant of RunBridge. The daemon must +// still be acquireable (set-up failures stay fatal — every parity +// test needs the daemon) but per-call gRPC errors come back as +// `error` so the caller can record an outcome instead of failing the +// test. Used by Informational fixtures so a daemon-side error on one +// fixture doesn't break the whole CI run. +func TryRunBridge(t *testing.T, req BridgeRequest) ([]*model.Part, error) { + t.Helper() + if req.FilterClass == "" { + return nil, errors.New("FilterClass is required") + } + if len(req.InputBytes) == 0 && req.InputPath == "" { + return nil, errors.New("InputBytes or InputPath must be set") + } + client := AcquireBridgeDaemon(t) + bridgeClient := pb.NewBridgeServiceClient(client.Conn) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + stream, err := bridgeClient.Process(ctx) + if err != nil { + return nil, fmt.Errorf("open Process stream: %w", err) + } + + header := &pb.ProcessHeader{ + FilterClass: req.FilterClass, + SourceLocale: defaultStr(req.SourceLocale, "en"), + TargetLocale: defaultStr(req.TargetLocale, "fr"), + Encoding: defaultStr(req.Encoding, "UTF-8"), + MimeType: req.MimeType, + FilterParams: req.FilterParams, + ConfigId: req.ConfigID, + SubscribeParts: req.SubscribeParts, + } + if req.InputPath != "" { + header.Input = &pb.ContentRef{Location: &pb.ContentRef_Path{Path: req.InputPath}} + } else { + header.Input = &pb.ContentRef{Location: &pb.ContentRef_Inline{Inline: req.InputBytes}} + } + if err := stream.Send(&pb.ProcessRequest{Request: &pb.ProcessRequest_Header{Header: header}}); err != nil { + return nil, fmt.Errorf("send header: %w", err) + } + if err := stream.CloseSend(); err != nil { + return nil, fmt.Errorf("CloseSend: %w", err) + } + + parts, err := drainProcessResponses(stream) + if err != nil { + return nil, fmt.Errorf("drain stream: %w", err) + } + return parts, nil +} + +// BridgeRoundTripResult holds both the events streamed during the +// round-trip and the final output bytes the daemon wrote. +type BridgeRoundTripResult struct { + Parts []*model.Part + Output []byte +} + +// RunBridgeRoundTrip drives a read+write Process RPC: feeds the source +// into the daemon, echoes every part back unchanged so the writer +// applies them, and returns both the part stream and the rewritten +// document bytes. +func RunBridgeRoundTrip(t *testing.T, req BridgeRequest) BridgeRoundTripResult { + t.Helper() + if req.FilterClass == "" { + t.Fatal("RunBridgeRoundTrip: FilterClass is required") + } + if len(req.InputBytes) == 0 && req.InputPath == "" { + t.Fatal("RunBridgeRoundTrip: InputBytes or InputPath must be set") + } + client := AcquireBridgeDaemon(t) + bridgeClient := pb.NewBridgeServiceClient(client.Conn) + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + stream, err := bridgeClient.Process(ctx) + if err != nil { + t.Fatalf("RunBridgeRoundTrip: open Process stream: %v", err) + } + // The daemon's GenericFilterWriter expects a real path; an empty + // string trips a FileNotFoundException. Use a per-test temp file + // and read the bytes back when ProcessComplete arrives. + outPath := filepath.Join(t.TempDir(), "bridge-roundtrip.out") + target := defaultStr(req.TargetLocale, "fr") + header := &pb.ProcessHeader{ + FilterClass: req.FilterClass, + SourceLocale: defaultStr(req.SourceLocale, "en"), + TargetLocale: target, + OutputLocale: target, + Encoding: defaultStr(req.Encoding, "UTF-8"), + MimeType: req.MimeType, + FilterParams: req.FilterParams, + ConfigId: req.ConfigID, + SubscribeParts: req.SubscribeParts, + Output: &pb.OutputRef{Destination: &pb.OutputRef_Path{Path: outPath}}, + } + if req.InputPath != "" { + header.Input = &pb.ContentRef{Location: &pb.ContentRef_Path{Path: req.InputPath}} + } else { + header.Input = &pb.ContentRef{Location: &pb.ContentRef_Inline{Inline: req.InputBytes}} + } + if err := stream.Send(&pb.ProcessRequest{Request: &pb.ProcessRequest_Header{Header: header}}); err != nil { + t.Fatalf("RunBridgeRoundTrip: send header: %v", err) + } + + // Echo every read part back so the daemon's writer thread has + // something to apply. We capture each read part for caller + // inspection. + var parts []*model.Part + var output []byte + for { + resp, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatalf("RunBridgeRoundTrip: recv: %v", err) + } + // EOF from Send means the daemon already half-closed the stream + // (it has a Complete{Error} or ReadDone in flight that we will + // pick up on the next Recv). Stop trying to send echoes — keep + // receiving so the trailing message reaches the Complete handler + // and surfaces the real diagnostic instead of a generic EOF. + sendEcho := func(req *pb.ProcessRequest) { + if err := stream.Send(req); err != nil && !errors.Is(err, io.EOF) { + t.Fatalf("RunBridgeRoundTrip: echo: %v", err) + } + } + switch m := resp.Response.(type) { + case *pb.ProcessResponse_Part: + parts = append(parts, protoconvert.ProtoToPart(m.Part)) + outPart := maybeTransformPart(m.Part, req.Transform) + sendEcho(&pb.ProcessRequest{Request: &pb.ProcessRequest_Part{Part: outPart}}) + case *pb.ProcessResponse_PartBatch: + for _, p := range m.PartBatch.Parts { + parts = append(parts, protoconvert.ProtoToPart(p)) + outPart := maybeTransformPart(p, req.Transform) + sendEcho(&pb.ProcessRequest{Request: &pb.ProcessRequest_Part{Part: outPart}}) + } + case *pb.ProcessResponse_ContentBatch: + for _, cb := range m.ContentBatch.Blocks { + parts = append(parts, protoconvert.ContentBlockToPart(cb)) + outCb := maybeTransformContentBlock(cb, req.Transform) + sendEcho(&pb.ProcessRequest{Request: &pb.ProcessRequest_ContentBlock{ContentBlock: outCb}}) + } + case *pb.ProcessResponse_ReadDone: + if err := stream.CloseSend(); err != nil { + t.Fatalf("RunBridgeRoundTrip: CloseSend after ReadDone: %v", err) + } + case *pb.ProcessResponse_Complete: + if m.Complete.Error != "" { + t.Fatalf("RunBridgeRoundTrip: daemon error: %s", m.Complete.Error) + } + // Inline output is empty when OutputRef is used (per + // neokapi_bridge.proto). Fall back to reading the on-disk + // artifact the daemon wrote at outPath. + output = m.Complete.Output + if len(output) == 0 { + bytes, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("RunBridgeRoundTrip: read output %s: %v", outPath, err) + } + output = bytes + } + } + } + return BridgeRoundTripResult{Parts: parts, Output: output} +} + +// maybeTransformPart applies the BridgeRequest.Transform hook to a +// PartMessage's Block payload (if present) and returns a fresh +// PartMessage to echo back. When transform is nil the original +// proto is returned unchanged — the daemon-side writer thread sees +// exactly what its reader emitted. +func maybeTransformPart(in *pb.PartMessage, transform func(*model.Block)) *pb.PartMessage { + if transform == nil || in == nil || in.Block == nil { + return in + } + part := protoconvert.ProtoToPart(in) + if part == nil { + return in + } + block, ok := part.Resource.(*model.Block) + if !ok || block == nil { + return in + } + transform(block) + return protoconvert.PartToProto(part) +} + +// maybeTransformContentBlock is the lightweight-encoding sibling of +// maybeTransformPart for the bridge's ContentBlock fast path. +func maybeTransformContentBlock(in *pb.ContentBlock, transform func(*model.Block)) *pb.ContentBlock { + if transform == nil || in == nil { + return in + } + part := protoconvert.ContentBlockToPart(in) + if part == nil { + return in + } + block, ok := part.Resource.(*model.Block) + if !ok || block == nil { + return in + } + transform(block) + return protoconvert.PartToContentBlock(part) +} + +// StepRequest configures one ProcessStep RPC. +type StepRequest struct { + StepClass string + StepParams map[string]string + ParamTypes map[string]string + SourceLocale string + TargetLocale string + // Parts is the input stream to feed into the step. The harness + // sends them after the StepHeader. + Parts []*model.Part +} + +// RunBridgeStep drives a ProcessStep RPC, feeding `Parts` into the +// daemon's pipeline step and returning whatever the step emits. +func RunBridgeStep(t *testing.T, req StepRequest) []*model.Part { + t.Helper() + if req.StepClass == "" { + t.Fatal("RunBridgeStep: StepClass is required") + } + client := AcquireBridgeDaemon(t) + bridgeClient := pb.NewBridgeServiceClient(client.Conn) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + stream, err := bridgeClient.ProcessStep(ctx) + if err != nil { + t.Fatalf("RunBridgeStep: open ProcessStep stream: %v", err) + } + + header := &pb.StepHeader{ + StepClass: req.StepClass, + StepParams: req.StepParams, + SourceLocale: defaultStr(req.SourceLocale, "en"), + TargetLocale: defaultStr(req.TargetLocale, "fr"), + ParamTypes: req.ParamTypes, + } + if err := stream.Send(&pb.StepRequest{Request: &pb.StepRequest_Header{Header: header}}); err != nil { + t.Fatalf("RunBridgeStep: send header: %v", err) + } + + // Fan parts in concurrently so the daemon can pipeline. + sendErr := make(chan error, 1) + go func() { + defer func() { _ = stream.CloseSend() }() + for _, p := range req.Parts { + if err := stream.Send(&pb.StepRequest{Request: &pb.StepRequest_Part{Part: protoconvert.PartToProto(p)}}); err != nil { + sendErr <- fmt.Errorf("send part: %w", err) + return + } + } + sendErr <- nil + }() + + var out []*model.Part + for { + resp, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatalf("RunBridgeStep: recv: %v", err) + } + switch m := resp.Response.(type) { + case *pb.StepResponse_Part: + out = append(out, protoconvert.ProtoToPart(m.Part)) + case *pb.StepResponse_Complete: + // Continue draining until the stream ends. + _ = m + } + } + if err := <-sendErr; err != nil { + t.Fatalf("RunBridgeStep: %v", err) + } + return out +} + +// drainProcessResponses pulls every part off a Process stream until the +// daemon emits ProcessComplete or the stream ends. +func drainProcessResponses(stream pb.BridgeService_ProcessClient) ([]*model.Part, error) { + var out []*model.Part + for { + resp, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + return out, nil + } + return out, err + } + switch m := resp.Response.(type) { + case *pb.ProcessResponse_Part: + out = append(out, protoconvert.ProtoToPart(m.Part)) + case *pb.ProcessResponse_PartBatch: + for _, p := range m.PartBatch.Parts { + out = append(out, protoconvert.ProtoToPart(p)) + } + case *pb.ProcessResponse_ContentBatch: + for _, cb := range m.ContentBatch.Blocks { + out = append(out, protoconvert.ContentBlockToPart(cb)) + } + case *pb.ProcessResponse_ReadDone: + // Read-only mode closes after the next ProcessComplete. + case *pb.ProcessResponse_Complete: + if m.Complete.Error != "" { + return out, fmt.Errorf("daemon: %s", m.Complete.Error) + } + return out, nil + } + } +} + +func defaultStr(v, fallback string) string { + if v == "" { + return fallback + } + return v +} diff --git a/parity/cmd/okapi-test-scan/main.go b/parity/cmd/okapi-test-scan/main.go new file mode 100644 index 0000000..c6bd56a --- /dev/null +++ b/parity/cmd/okapi-test-scan/main.go @@ -0,0 +1,380 @@ +// Command okapi-test-scan extracts inline-string fixture material from +// Okapi filter test sources so the parity harness can drive every +// extractable Java @Test through the bridge with a known input. +// +// The scanner is intentionally lossy: it walks Java with regular +// expressions, not an AST, and only recovers tests whose input shape +// matches one of the dominant patterns in upstream Okapi: +// +// @Test +// public void testFoo() { +// String snippet = "..."; // single literal +// String snippet = "..." // multi-line string concat +// + "..."; +// ... +// } +// +// Tests that build inputs programmatically, load resource files, or +// whose snippet variable is named something other than `snippet` are +// skipped — the survey upstream put that population at ~10–15% per +// filter, which is acceptable for a coverage pass that reports its +// own gaps. +// +// Output is a build-tagged Go file (see -out) carrying one +// []formats.FormatInput per scanned test class. Hand-curated fixtures +// in spec.go remain authoritative; the auto-generated set is +// referenced explicitly per FormatSpec. +// +// Usage: +// +// go run ./cmd/okapi-test-scan \ +// -src /path/to/okapi-java/okapi/filters/html/src/test/java \ +// -class HtmlSnippetsTest \ +// -package html \ +// -out formats/fixtures_html_generated.go +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +func main() { + src := flag.String("src", "", "Java test source root (directory walked recursively)") + classFilter := flag.String("class", "", "Comma-separated short class names to include (e.g. HtmlSnippetsTest). Empty = all *Test.java.") + pkg := flag.String("package", "formats", "Go package to write into") + out := flag.String("out", "", "Output Go file path (required)") + flag.Parse() + + if *src == "" || *out == "" { + die("must set -src and -out") + } + classes := splitCSV(*classFilter) + classMatch := func(name string) bool { + if len(classes) == 0 { + return true + } + for _, c := range classes { + if c == name { + return true + } + } + return false + } + + var scanned []scannedClass + err := filepath.WalkDir(*src, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || !strings.HasSuffix(path, "Test.java") { + return nil + } + short := strings.TrimSuffix(filepath.Base(path), ".java") + if !classMatch(short) { + return nil + } + c, err := scanFile(path, short) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if len(c.Tests) > 0 { + scanned = append(scanned, c) + } + return nil + }) + if err != nil { + die("scan: %v", err) + } + + sort.SliceStable(scanned, func(i, j int) bool { return scanned[i].ClassName < scanned[j].ClassName }) + + body, err := emit(*pkg, scanned) + if err != nil { + die("emit: %v", err) + } + if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil { + die("mkdir: %v", err) + } + if err := os.WriteFile(*out, body, 0o644); err != nil { + die("write: %v", err) + } + + totalTests := 0 + totalScanned := 0 + for _, c := range scanned { + totalTests += c.SeenAtTests + totalScanned += len(c.Tests) + fmt.Fprintf(os.Stderr, " %s: %d/%d tests extracted (%d had unsupported input shapes)\n", + c.ClassName, len(c.Tests), c.SeenAtTests, c.SkippedReasons["unsupported"]) + } + fmt.Fprintf(os.Stderr, "okapi-test-scan: %d/%d tests across %d classes → %s\n", + totalScanned, totalTests, len(scanned), *out) +} + +// scannedClass aggregates per-test results for one Java test class. +type scannedClass struct { + ClassName string // short class name (e.g. HtmlSnippetsTest) + SourceFile string // path to the .java file (relative to scan root) + Tests []scannedTest // successfully extracted tests + SeenAtTests int // total @Test methods seen + SkippedReasons map[string]int // reason → count +} + +// scannedTest is one extractable @Test method. +type scannedTest struct { + Method string // testFoo + Snippet string // inline content (already unescaped from Java string literal form) +} + +var ( + // `@Test` with optional parameter list, followed by the next + // `public void (...)` declaration. We intentionally accept + // `@Test(expected = ...)` because the input still matters even if + // the assertion is "throws". + testHeaderRE = regexp.MustCompile(`(?m)^\s*@Test(?:\([^)]*\))?\s*$`) + methodDeclRE = regexp.MustCompile(`^\s*public\s+void\s+(\w+)\s*\(`) + // `String snippet = "..."`, optionally with `+ "..."` continuation + // lines until a terminating `;`. We extract the concatenation as + // one logical literal. + snippetStartRE = regexp.MustCompile(`^\s*(?:final\s+)?String\s+snippet\s*=\s*(.+)$`) +) + +func scanFile(path, className string) (scannedClass, error) { + data, err := os.ReadFile(path) + if err != nil { + return scannedClass{}, err + } + c := scannedClass{ + ClassName: className, + SourceFile: path, + SkippedReasons: map[string]int{}, + } + lines := strings.Split(string(data), "\n") + + for i := 0; i < len(lines); i++ { + if !testHeaderRE.MatchString(lines[i]) { + continue + } + // Find the next public-void declaration (allowing other + // annotations or whitespace in between). + j := i + 1 + var method string + for j < len(lines) && j < i+10 { + if m := methodDeclRE.FindStringSubmatch(lines[j]); m != nil { + method = m[1] + break + } + j++ + } + if method == "" { + c.SkippedReasons["no-method"]++ + continue + } + c.SeenAtTests++ + + // Find the end of the method body (matching brace) so we can + // scan only that region for the snippet declaration. Cheap + // brace counter starting from the `{` after the signature. + bodyStart := j + for bodyStart < len(lines) && !strings.Contains(lines[bodyStart], "{") { + bodyStart++ + } + bodyEnd := scanForMethodEnd(lines, bodyStart) + + snippet, ok := extractSnippet(lines[bodyStart : bodyEnd+1]) + if !ok { + c.SkippedReasons["unsupported"]++ + continue + } + c.Tests = append(c.Tests, scannedTest{Method: method, Snippet: snippet}) + i = bodyEnd + } + return c, nil +} + +// scanForMethodEnd finds the closing `}` of the method body whose +// opening brace is on or after `start`. Returns the index of the line +// containing the closing brace. +func scanForMethodEnd(lines []string, start int) int { + depth := 0 + for k := start; k < len(lines); k++ { + for _, ch := range lines[k] { + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return k + } + } + } + } + return len(lines) - 1 +} + +// extractSnippet pulls the body of `String snippet = "..."` from a +// method body, supporting multi-line concatenation. Returns the +// unescaped snippet content and a boolean indicating success. +func extractSnippet(body []string) (string, bool) { + for i, line := range body { + m := snippetStartRE.FindStringSubmatch(line) + if m == nil { + continue + } + // Stitch continuation lines until we find the terminating `;`. + raw := m[1] + for !strings.HasSuffix(strings.TrimSpace(raw), ";") && i+1 < len(body) { + i++ + raw += " " + strings.TrimSpace(body[i]) + } + raw = strings.TrimSuffix(strings.TrimSpace(raw), ";") + return parseJavaConcat(raw) + } + return "", false +} + +// parseJavaConcat joins a Java string-concatenation expression like +// +// "a" + "b" + "c" +// +// into the single decoded literal `abc`. Returns false if any term +// isn't a simple double-quoted Java string (e.g. method calls, vars). +func parseJavaConcat(expr string) (string, bool) { + var out strings.Builder + i := 0 + for i < len(expr) { + // Skip whitespace. + for i < len(expr) && (expr[i] == ' ' || expr[i] == '\t' || expr[i] == '+' || expr[i] == '\n') { + i++ + } + if i >= len(expr) { + break + } + if expr[i] != '"' { + return "", false // non-literal term + } + end, lit, ok := readJavaString(expr[i:]) + if !ok { + return "", false + } + out.WriteString(lit) + i += end + } + return out.String(), true +} + +// readJavaString reads a Java double-quoted string literal starting at +// s[0]=='"'. Returns the consumed length, the decoded contents, and +// success. +func readJavaString(s string) (int, string, bool) { + if len(s) == 0 || s[0] != '"' { + return 0, "", false + } + var buf strings.Builder + i := 1 + for i < len(s) { + c := s[i] + if c == '"' { + return i + 1, buf.String(), true + } + if c == '\\' && i+1 < len(s) { + switch s[i+1] { + case 'n': + buf.WriteByte('\n') + case 'r': + buf.WriteByte('\r') + case 't': + buf.WriteByte('\t') + case '"': + buf.WriteByte('"') + case '\\': + buf.WriteByte('\\') + case '\'': + buf.WriteByte('\'') + case 'u': + if i+5 >= len(s) { + return 0, "", false + } + code, err := strconv.ParseInt(s[i+2:i+6], 16, 32) + if err != nil { + return 0, "", false + } + buf.WriteRune(rune(code)) + i += 4 + default: + buf.WriteByte(s[i+1]) + } + i += 2 + continue + } + buf.WriteByte(c) + i++ + } + return 0, "", false // unterminated +} + +// emit produces a build-tagged Go file declaring one +// []formats.FormatInput per class. +func emit(pkg string, classes []scannedClass) ([]byte, error) { + var b bytes.Buffer + fmt.Fprintln(&b, "//go:build parity") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "// Code generated by cmd/okapi-test-scan. DO NOT EDIT.") + fmt.Fprintln(&b) + fmt.Fprintf(&b, "package %s\n\n", pkg) + + for _, c := range classes { + varName := "Generated" + c.ClassName + "Inputs" + fmt.Fprintf(&b, "// %s holds %d auto-extracted @Test fixtures from %s.\n", + varName, len(c.Tests), c.ClassName) + fmt.Fprintf(&b, "var %s = []FormatInput{\n", varName) + for _, t := range c.Tests { + fmt.Fprintf(&b, "\t{Name: %q, Content: ttext(%s), OkapiTest: %q, Informational: true},\n", + "gen-"+t.Method, goRawOrQuoted(t.Snippet), c.ClassName+"#"+t.Method) + } + fmt.Fprintln(&b, "}") + fmt.Fprintln(&b) + } + return format.Source(b.Bytes()) +} + +// goRawOrQuoted picks the most readable Go literal form for the +// snippet content. Backtick-quoted is preferred when the input has no +// backticks of its own; otherwise we fall back to %q which handles +// arbitrary bytes including embedded newlines and quotes. +func goRawOrQuoted(s string) string { + if !strings.ContainsRune(s, '`') { + return "`" + s + "`" + } + return strconv.Quote(s) +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "okapi-test-scan: "+format+"\n", args...) + os.Exit(1) +} diff --git a/parity/cmd/testcompare/main.go b/parity/cmd/testcompare/main.go new file mode 100644 index 0000000..e9fd252 --- /dev/null +++ b/parity/cmd/testcompare/main.go @@ -0,0 +1,148 @@ +// Command testcompare reads .parity/test-comparison.json (the raw +// parity report written by the parity test packages) and emits a +// narrower per-filter summary at .parity/parity-report.json. +// +// The summary is a local maintainer artifact, excluded from the documentation +// site. It contains one row per filter or step, plus totals. +// +// { +// "generated_at": "2026-04-29T...", +// "totals": { +// "format": {"pass": 51, "fail": 1, "skip": 0, "total": 52}, +// "step": {"pass": 119, "fail": 0, "skip": 3, "total": 122} +// }, +// "rows": [ +// {"kind": "format", "id": "okf_html", "status": "pass", "mode": "head-to-head", "duration_ms": 412, "detail": ""}, +// ... +// ] +// } +// +// Usage: +// +// go run ./cmd/testcompare \ +// -in .parity/test-comparison.json \ +// -out .parity/parity-report.json +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +type rawRow struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Mode string `json:"mode,omitempty"` + Detail string `json:"detail,omitempty"` + DurationMS int64 `json:"duration_ms"` + Timestamp string `json:"timestamp"` +} + +type publishedRow struct { + Kind string `json:"kind"` + ID string `json:"id"` + Status string `json:"status"` + Mode string `json:"mode,omitempty"` + Detail string `json:"detail,omitempty"` + DurationMS int64 `json:"duration_ms"` +} + +type totals struct { + Pass int `json:"pass"` + Fail int `json:"fail"` + Skip int `json:"skip"` + Error int `json:"error"` + Total int `json:"total"` +} + +type published struct { + GeneratedAt string `json:"generated_at"` + Totals map[string]*totals `json:"totals"` + Rows []publishedRow `json:"rows"` +} + +func main() { + in := flag.String("in", ".parity/test-comparison.json", "input parity report path") + out := flag.String("out", ".parity/parity-report.json", "output summary JSON path") + flag.Parse() + + data, err := os.ReadFile(*in) + if err != nil { + fmt.Fprintf(os.Stderr, "testcompare: read %s: %v\n", *in, err) + os.Exit(1) + } + var rows []rawRow + if err := json.Unmarshal(data, &rows); err != nil { + fmt.Fprintf(os.Stderr, "testcompare: parse %s: %v\n", *in, err) + os.Exit(1) + } + + out_, totals_ := transform(rows) + doc := published{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Totals: totals_, + Rows: out_, + } + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "testcompare: marshal: %v\n", err) + os.Exit(1) + } + if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "testcompare: mkdir %s: %v\n", filepath.Dir(*out), err) + os.Exit(1) + } + if err := os.WriteFile(*out, body, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "testcompare: write %s: %v\n", *out, err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "testcompare: %d rows → %s\n", len(rows), *out) +} + +// transform reduces the raw rows to the published shape and computes +// per-kind totals. Rows are sorted by (kind, id) so the dashboard +// output is deterministic across runs. +func transform(rows []rawRow) ([]publishedRow, map[string]*totals) { + out := make([]publishedRow, 0, len(rows)) + tot := map[string]*totals{} + for _, r := range rows { + out = append(out, publishedRow{ + Kind: r.Kind, + ID: r.ID, + Status: r.Status, + Mode: r.Mode, + Detail: r.Detail, + DurationMS: r.DurationMS, + }) + t, ok := tot[r.Kind] + if !ok { + t = &totals{} + tot[r.Kind] = t + } + t.Total++ + switch r.Status { + case "pass": + t.Pass++ + case "fail": + t.Fail++ + case "skip": + t.Skip++ + case "error": + t.Error++ + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].ID < out[j].ID + }) + return out, tot +} diff --git a/parity/cmd/testcompare/main_test.go b/parity/cmd/testcompare/main_test.go new file mode 100644 index 0000000..80cc99d --- /dev/null +++ b/parity/cmd/testcompare/main_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestTransformSortsAndAggregates(t *testing.T) { + rows := []rawRow{ + {Kind: "format", ID: "okf_xml", Status: "pass", Mode: "head-to-head", DurationMS: 100}, + {Kind: "format", ID: "okf_html", Status: "fail", Mode: "head-to-head", Detail: "diverged on x", DurationMS: 200}, + {Kind: "step", ID: "word-count", Status: "skip", Mode: "bridge-only", Detail: "needs testdata"}, + {Kind: "format", ID: "okf_json", Status: "pass", Mode: "head-to-head"}, + {Kind: "step", ID: "char-count", Status: "pass", Mode: "head-to-head"}, + } + + got, tot := transform(rows) + + wantOrder := []string{"okf_html", "okf_json", "okf_xml", "char-count", "word-count"} + if len(got) != len(wantOrder) { + t.Fatalf("got %d rows, want %d", len(got), len(wantOrder)) + } + for i, want := range wantOrder { + if got[i].ID != want { + t.Errorf("row[%d].ID = %q, want %q", i, got[i].ID, want) + } + } + + wantTotals := map[string]*totals{ + "format": {Pass: 2, Fail: 1, Skip: 0, Error: 0, Total: 3}, + "step": {Pass: 1, Fail: 0, Skip: 1, Error: 0, Total: 2}, + } + if !reflect.DeepEqual(tot, wantTotals) { + t.Errorf("totals mismatch:\n got: %#v\nwant: %#v", tot, wantTotals) + } +} diff --git a/parity/compare.go b/parity/compare.go new file mode 100644 index 0000000..99ac223 --- /dev/null +++ b/parity/compare.go @@ -0,0 +1,205 @@ +//go:build parity + +package parity + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/neokapi/neokapi/core/model" +) + +// CompareEvents asserts that two part streams agree on their canonical +// projection (see CanonicalPart). It logs a structured diff on mismatch +// and returns whether the streams matched, so callers can choose +// between t.Fatal and t.Error. +func CompareEvents(t *testing.T, native, bridge []*model.Part) bool { + t.Helper() + gotNative := Canonicalize(native) + gotBridge := Canonicalize(bridge) + + if diff := canonicalDiff(gotBridge, gotNative); diff != "" { + t.Errorf("event-level parity mismatch (-bridge +native):\n%s\n%s", diff, partSummary(native, bridge)) + return false + } + return true +} + +// canonicalDiff returns a multi-line diff between two CanonicalPart +// slices. Returns "" when they match. +func canonicalDiff(want, got []CanonicalPart) string { + if len(want) != len(got) { + var buf strings.Builder + fmt.Fprintf(&buf, "len mismatch: bridge=%d native=%d\n", len(want), len(got)) + n := max(len(got), len(want)) + for i := range n { + var w, g string + if i < len(want) { + w = canonicalLine(want[i]) + } else { + w = "" + } + if i < len(got) { + g = canonicalLine(got[i]) + } else { + g = "" + } + marker := " " + if w != g { + marker = "* " + } + fmt.Fprintf(&buf, "%s[%d]\n - %s\n + %s\n", marker, i, w, g) + } + return buf.String() + } + var buf strings.Builder + mismatched := 0 + for i := range want { + w, g := canonicalLine(want[i]), canonicalLine(got[i]) + if w != g { + mismatched++ + fmt.Fprintf(&buf, "[%d]\n - %s\n + %s\n", i, w, g) + } + } + if mismatched == 0 { + return "" + } + return buf.String() +} + +// canonicalLine renders a CanonicalPart on a single line for diff output. +func canonicalLine(c CanonicalPart) string { + var fields []string + add := func(name, value string) { + if value == "" { + return + } + fields = append(fields, fmt.Sprintf("%s=%q", name, value)) + } + addBool := func(name string, value bool) { + if !value { + return + } + fields = append(fields, fmt.Sprintf("%s=%t", name, value)) + } + addInt := func(name string, value int) { + if value == 0 { + return + } + fields = append(fields, fmt.Sprintf("%s=%d", name, value)) + } + add("BlockID", c.BlockID) + addBool("Translatable", c.Translatable) + add("Source", c.Source) + add("Targets", c.Targets) + add("GroupID", c.GroupID) + add("GroupType", c.GroupType) + add("LayerID", c.LayerID) + add("LayerName", c.LayerName) + add("DataID", c.DataID) + add("MediaMime", c.MediaMime) + addInt("MediaSize", c.MediaSize) + if len(fields) == 0 { + return c.Type.String() + } + return c.Type.String() + " " + strings.Join(fields, " ") +} + +// CompareBytes asserts byte-exact equality, with a hex-style diff on +// mismatch. +func CompareBytes(t *testing.T, want, got []byte) bool { + t.Helper() + if bytes.Equal(want, got) { + return true + } + t.Errorf("byte-level parity mismatch:\n want: %d bytes (sha=%s)\n got: %d bytes (sha=%s)\n first diff: %s", + len(want), shortHash(want), len(got), shortHash(got), firstDiff(want, got)) + return false +} + +// CompareBlockText asserts that the two streams contain the same +// rendered translatable text in the same order, ignoring everything +// else. Useful when implementations differ on segmentation but must +// agree on extracted content. +func CompareBlockText(t *testing.T, native, bridge []*model.Part) bool { + t.Helper() + wantText := joinBlockText(native) + gotText := joinBlockText(bridge) + if wantText == gotText { + return true + } + t.Errorf("block-text parity mismatch:\n bridge: %q\n native: %q", gotText, wantText) + return false +} + +// CompareBlockTextSoft is the non-failing variant of CompareBlockText. +// It logs the mismatch but doesn't call t.Errorf, so the surrounding +// subtest still passes as far as `go test` is concerned. Use for +// auto-generated fixtures that explore divergence without blocking +// CI; the boolean return drives the parity-report status so the +// dashboard still shows the truth. +func CompareBlockTextSoft(t *testing.T, native, bridge []*model.Part) bool { + t.Helper() + wantText := joinBlockText(native) + gotText := joinBlockText(bridge) + if wantText == gotText { + return true + } + t.Logf("block-text parity mismatch (informational):\n bridge: %q\n native: %q", gotText, wantText) + return false +} + +// joinBlockText concatenates rendered block text in stream order. +func joinBlockText(parts []*model.Part) string { + var buf strings.Builder + for _, p := range parts { + if p.Type != model.PartBlock { + continue + } + b, ok := p.Resource.(*model.Block) + if !ok || !b.Translatable { + continue + } + buf.WriteString(renderBlockSource(b)) + buf.WriteByte('\n') + } + return buf.String() +} + +func partSummary(native, bridge []*model.Part) string { + var buf strings.Builder + buf.WriteString("native parts:\n") + for i, p := range native { + fmt.Fprintf(&buf, " [%d] %s\n", i, p.Type) + } + buf.WriteString("bridge parts:\n") + for i, p := range bridge { + fmt.Fprintf(&buf, " [%d] %s\n", i, p.Type) + } + return buf.String() +} + +func firstDiff(a, b []byte) string { + n := min(len(b), len(a)) + for i := range n { + if a[i] != b[i] { + lo := max(i-8, 0) + hi := min(i+24, n) + return fmt.Sprintf("offset %d: want %q got %q", i, a[lo:hi], b[lo:hi]) + } + } + return fmt.Sprintf("length differs at %d", n) +} + +func shortHash(b []byte) string { + const fnvOffset uint64 = 14695981039346656037 + const fnvPrime uint64 = 1099511628211 + h := fnvOffset + for _, c := range b { + h ^= uint64(c) + h *= fnvPrime + } + return fmt.Sprintf("%016x", h) +} diff --git a/parity/daemon.go b/parity/daemon.go new file mode 100644 index 0000000..0a9e79b --- /dev/null +++ b/parity/daemon.go @@ -0,0 +1,257 @@ +//go:build parity + +package parity + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials/insecure" +) + +// daemonStartupTimeout bounds the wait for the daemon's handshake line and +// for its socket to accept connections. A cold JVM start on a CI runner is +// well inside this. +const daemonStartupTimeout = 60 * time.Second + +// maxGRPCMsgSize is the per-message limit for the daemon connection. The +// bridge streams whole documents inline, so the default 4 MB cap is too low. +const maxGRPCMsgSize = 256 << 20 + +// Handshake is the JSON envelope a plugin daemon prints as its first stdout +// line (plugin protocol v1, Mode C). The daemon keeps stdout open afterwards +// and writes only logs to it. +type Handshake struct { + // Socket is the absolute path to the Unix socket the daemon bound to. + Socket string `json:"socket"` + // Version is the daemon's own version string. + Version string `json:"version"` +} + +// DaemonClient is a running okapi-bridge daemon and the gRPC connection to it. +type DaemonClient struct { + // Conn is the gRPC client connection to the daemon's Unix socket. + Conn *grpc.ClientConn + // Socket is the path the daemon announced in its handshake. + Socket string + // Version is the version string from the handshake. + Version string + + cmd *exec.Cmd + drainDone chan struct{} +} + +// PID returns the daemon's process id. +func (c *DaemonClient) PID() int { + if c.cmd == nil || c.cmd.Process == nil { + return 0 + } + return c.cmd.Process.Pid +} + +// Close terminates the daemon: SIGTERM, a grace period, then SIGKILL. +func (c *DaemonClient) Close() { + if c.Conn != nil { + _ = c.Conn.Close() + } + if c.cmd == nil || c.cmd.Process == nil { + return + } + _ = c.cmd.Process.Signal(os.Interrupt) + done := make(chan struct{}) + go func() { + <-c.drainDone + _ = c.cmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + _ = c.cmd.Process.Kill() + <-done + } + if c.Socket != "" { + _ = os.Remove(c.Socket) + } +} + +// daemonState owns the singleton daemon shared by every parity test in one +// binary invocation. +type daemonState struct { + once sync.Once + client *DaemonClient + initErr error + shutdown sync.Once +} + +var bridgeDaemon = &daemonState{} + +// AcquireBridgeDaemon returns the shared okapi-bridge daemon client. The +// daemon is spawned from the sandbox the first time this is called and reused +// by every later caller in the same test binary. +func AcquireBridgeDaemon(t *testing.T) *DaemonClient { + t.Helper() + s := RequireSandbox(t) + + bridgeDaemon.once.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), daemonStartupTimeout) + defer cancel() + client, err := StartDaemon(ctx, s) + if err != nil { + bridgeDaemon.initErr = err + return + } + bridgeDaemon.client = client + if testing.Verbose() { + fmt.Fprintf(os.Stderr, "[parity-daemon] spawned okapi-bridge at %s (pid %d)\n", client.Socket, client.PID()) + } + }) + if bridgeDaemon.initErr != nil { + t.Fatalf("acquire bridge daemon: %v", bridgeDaemon.initErr) + } + return bridgeDaemon.client +} + +// ShutdownBridgeDaemon stops the shared daemon. Each test package's TestMain +// calls it after m.Run so the JVM is released between `go test` invocations. +func ShutdownBridgeDaemon() { + bridgeDaemon.shutdown.Do(func() { + if bridgeDaemon.client != nil { + bridgeDaemon.client.Close() + } + }) +} + +// StartDaemon launches the okapi-bridge launcher installed in the sandbox with +// the `daemon` argument, reads its handshake, and dials the announced socket. +// The subprocess is started without a context so the daemon outlives the +// startup deadline; Close tears it down. +func StartDaemon(ctx context.Context, s *Sandbox) (*DaemonClient, error) { + if _, err := os.Stat(s.OkapiBridgeBinary); err != nil { + return nil, fmt.Errorf("okapi-bridge launcher missing at %s: %w", s.OkapiBridgeBinary, err) + } + cmd := exec.Command(s.OkapiBridgeBinary, "daemon") //nolint:noctx // long-lived daemon subprocess + cmd.Env = append(os.Environ(), + "KAPI_PLUGIN_DIR="+s.OkapiBridgeDir, + "KAPI_PLUGIN_NAME=okapi-bridge", + ) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("daemon stdout pipe: %w", err) + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start %s: %w", s.OkapiBridgeBinary, err) + } + + type handshakeResult struct { + hs Handshake + err error + } + br := bufio.NewReader(stdout) + hsCh := make(chan handshakeResult, 1) + go func() { + line, err := br.ReadString('\n') + if err != nil && line == "" { + hsCh <- handshakeResult{err: fmt.Errorf("read handshake: %w", err)} + return + } + var hs Handshake + if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &hs); err != nil { + hsCh <- handshakeResult{err: fmt.Errorf("parse handshake %q: %w", strings.TrimSpace(line), err)} + return + } + hsCh <- handshakeResult{hs: hs} + }() + + kill := func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + var hs Handshake + select { + case <-ctx.Done(): + kill() + return nil, fmt.Errorf("okapi-bridge daemon: handshake timeout after %s", daemonStartupTimeout) + case res := <-hsCh: + if res.err != nil { + kill() + return nil, fmt.Errorf("okapi-bridge daemon: %w", res.err) + } + hs = res.hs + } + if hs.Socket == "" { + kill() + return nil, errors.New("okapi-bridge daemon: handshake missing socket") + } + + // Everything the daemon prints after the handshake is a log line. + drainDone := make(chan struct{}) + go func() { + defer close(drainDone) + for { + line, err := br.ReadString('\n') + if line != "" { + fmt.Fprintf(os.Stderr, "[okapi-bridge] %s", line) + } + if err != nil { + return + } + } + }() + + conn, err := dialUnixSocket(ctx, hs.Socket) + if err != nil { + kill() + return nil, fmt.Errorf("okapi-bridge daemon: dial %s: %w", hs.Socket, err) + } + return &DaemonClient{ + Conn: conn, + Socket: hs.Socket, + Version: hs.Version, + cmd: cmd, + drainDone: drainDone, + }, nil +} + +// dialUnixSocket opens a gRPC connection over a Unix socket and waits until +// it is ready, so a daemon that announced a socket it does not serve fails +// here rather than on the first RPC. +func dialUnixSocket(ctx context.Context, socket string) (*grpc.ClientConn, error) { + conn, err := grpc.NewClient("unix://"+socket, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(maxGRPCMsgSize), + grpc.MaxCallSendMsgSize(maxGRPCMsgSize), + ), + ) + if err != nil { + return nil, err + } + conn.Connect() + for { + st := conn.GetState() + switch st { + case connectivity.Ready: + return conn, nil + case connectivity.TransientFailure, connectivity.Shutdown: + _ = conn.Close() + return nil, fmt.Errorf("connection entered %s", st) + } + if !conn.WaitForStateChange(ctx, st) { + _ = conn.Close() + return nil, ctx.Err() + } + } +} diff --git a/parity/doc.go b/parity/doc.go new file mode 100644 index 0000000..288f942 --- /dev/null +++ b/parity/doc.go @@ -0,0 +1,58 @@ +// Package parity is the conformance suite that runs neokapi's Go ports of +// Okapi filters and steps head-to-head against the Okapi Framework, through +// this repository's bridge plugin and, where a build is reachable, the tikal +// CLI. +// +// The suite compiles against a released github.com/neokapi/neokapi module and +// drives a released kapi binary. It is gated behind the build tag "parity" +// and needs a sandbox: +// - a kapi binary at $KAPI_PARITY_SANDBOX/bin/kapi +// - the okapi-bridge plugin unpacked at $KAPI_PARITY_SANDBOX/plugins/okapi-bridge +// - optionally, the okapi-testdata release tree under $KAPI_PARITY_SANDBOX/okapi-testdata/ +// +// `make parity-sandbox` in this repository builds that layout from the kapi +// on PATH (or $KAPI_BIN) and a freshly built plugin tarball; `make +// parity-test` runs the suite against it. Tests called outside this +// orchestration fail with an explanatory message rather than measuring a +// system-installed plugin. +// +// # Architecture +// +// ┌─────────────────────────────────────────────────────────────────────┐ +// │ parity test (build tag: parity) │ +// │ │ +// │ RunNative(...) ──────────────► neokapi format reader (in-process) │ +// │ │ │ +// │ []model.Part │ +// │ ▼ │ +// │ RunBridge(...) ──────────────► daemon Process RPC │ +// │ │ │ │ +// │ └─► StartDaemon ─────────► kapi-okapi-bridge daemon (JVM) │ +// │ []model.Part │ +// │ ▼ │ +// │ CompareEvents(t, native, bridge) │ +// │ CompareBytes(t, want, got) │ +// └─────────────────────────────────────────────────────────────────────┘ +// +// # Environment isolation +// +// The harness never reads the user's $XDG_DATA_HOME/kapi/plugins/ or honours +// an inherited $KAPI_PLUGINS_DIR. Every test resolves the sandbox through +// RequireSandbox and fails when there is none, so a run always measures the +// plugin that was just built. +// +// # Format specs +// +// The per-format parity knowledge (bridge filter class, tikal corner, skips) +// lives in core/formats//spec.yaml inside the neokapi module. NeokapiDir +// locates that module's source directory, so the specs and the readers under +// test come from one version. Per-fixture divergence annotations live in this +// module under annotations/.yaml. +// +// # Reporting +// +// Tests opt in to the JSON report via the [Report] helper. The collector +// writes one record per filter or step into $KAPI_PARITY_REPORT (default +// $KAPI_PARITY_SANDBOX/test-comparison.json); cmd/testcompare turns that into +// the per-filter summary. +package parity diff --git a/parity/docs/roundtrip-testing.md b/parity/docs/roundtrip-testing.md new file mode 100644 index 0000000..bca11d9 --- /dev/null +++ b/parity/docs/roundtrip-testing.md @@ -0,0 +1,482 @@ +# Three-Engine Round-Trip Testing + +This note describes the round-trip test harness in +`parity/roundtrip/`, which exercises every neokapi format end-to-end +through three different filter engines and asserts they all agree on +the result. + +## Why this exists + +The `spec.yaml` runners in `core/format/spec/` and `parity/spec/` +verify reader contracts: how many Blocks come out of an input, what +text each one carries, what their IDs are. They never run a writer. +That coverage proves "we can read these formats" but not "we can read, +modify, and write them back coherently". + +Round-trip testing closes that gap. It's the canonical Okapi +integration-test pattern (cf. upstream's `IFilterRoundTripIt` / +`RoundTripUtils`) and is what "extract → translate → merge" reduces to +in practice. The harness exercises: + + - The reader (parses the source). + - The model layer (carries Source + Target through the pipeline). + - The writer (re-serializes the document with translations applied). + - The skeleton path (preserves untranslatable structure verbatim). + +Anything that breaks one of those (broken whitespace handling, a +writer that drops attributes, a skeleton that misses a region) +surfaces as a divergence between the engines or a re-extraction that +doesn't yield the expected text. + +## The three engines + +Three engines drive the same input through their own toolchain. Each +implements the `Engine` interface in `parity/roundtrip/engine.go`: + +``` +Engine interface { + Name() string + Available() error + RoundTrip(t *testing.T, in Input, spec PseudoSpec) []byte +} +``` + +### `NativeEngine` (always runs) + +Wires the format's registered `DataFormatReader` through an inline +pseudo-translation goroutine into the format's registered +`DataFormatWriter`. All in-process. No subprocess, no gRPC, no +external binary. This is the same plumbing as `kapi pseudo-translate` +on the CLI. + +### `BridgeEngine` (runs when okapi-bridge daemon is reachable) + +Drives the okapi-bridge daemon's `Process` RPC in **read-write mode** +(`OutputRef` set in `ProcessHeader`). The daemon reads the input via +the requested `okf_` filter, streams every Block back over +gRPC, this side overwrites the target (via the `Transform` hook on +`parity.BridgeRequest`), and the daemon's writer thread merges the +modified parts back into the output document at the configured +`OutputRef.Path`. + +The transform hook is engine-agnostic: it receives a `*model.Block` +and is free to write its target editions. The bridge engine wires it to the +same `applyPseudoToBlock` helper the native engine uses (in +`parity/roundtrip/pseudo.go`). + +### `OkapiEngine` (the comparator, hard-required) + +Shells out to the okapi-bridge launcher's `pseudo` subcommand +(implemented at `bridge-core/.../PseudoCommand.java`), which composes +upstream Okapi's pipeline in-process: + +``` +RawDocumentToFilterEventsStep → TextModificationStep → FilterEventsToRawDocumentStep + (input via okf_) (TYPE_EXTREPLACE, (write via the same filter) + SCRIPT_EXT_LATIN) +``` + +`TextModificationStep` skips inline-code marker chars during +substitution, so paired codes (``/``), placeholders +(``), and arbitrary inline runs survive the round-trip +unchanged. The Go side mirrors the same Latin-extended substitution +rune-by-rune in `pseudo.go::applyPseudoToBlock`, leaving +non-`Text` runs (Ph / PcOpen / PcClose / Sub / Plural / Select) +verbatim. Same transform, three engines. + +When a fixture sets `okapiParamConfig` (e.g. VTT/TTML disabling +`mergeCaptions`), the harness passes it as `--fprm ` to the +launcher, where Okapi loads it via `IParameters.fromString()`. + +We deliberately avoid `tikal -psd` (Okapi's built-in pseudo-translate +configured through tikal's CLI). That step has its own driver +parameters and the tikal CLI's two-pass extract→XLIFF→merge +architecture mishandles inline ``/`` markers when the +harness rewrites XLIFF targets by hand. Driving +`TextModificationStep` directly via the bridge launcher is one +in-process pipeline with no XLIFF round-trip in between. + +The okapi engine is the **comparator**, not a tested engine. The +harness runs the okapi engine once per fixture to obtain the live +reference output, then byte-compares native and bridge against its +bytes. There is no "okapi" subtest, because asserting +okapi-against-itself would be circular. + +## The pseudo-translation transform + +The single transform every engine applies, defined in +`parity/roundtrip/pseudo.go`. For each translatable Block, the +target is built as a copy of the source's segments where every +`TextRun` has its text rune-substituted via the Latin-extended +table; non-text runs (Ph / PcOpen / PcClose / Sub / Plural / Select) +are copied as-is so inline codes survive intact. + +The substitution table mirrors Okapi's `TextModificationStep` +(`SCRIPT_EXT_LATIN`): + +``` +A→À a→à B→ß b→ƀ C→Ć c→ć D→Ď d→ď E→Ē e→ē F→Ƒ f→ƒ +G→Ĝ g→ĝ H→Ĥ h→ĥ I→Ĩ i→ĩ J→ĵ j→Ĵ K→Ķ k→ķ L→Ĺ l→ĺ +N→Ń n→ń O→Ō o→ō P→Ƥ p→ƥ Q→Ǫ q→ǫ R→Ŕ r→ŕ S→Ś s→ś +T→Ţ t→ţ U→Ũ u→ũ W→Ŵ w→ŵ Y→Ŷ y→ŷ Z→Ź z→ź +``` + +(M, V, X have no entries and pass through unchanged, the same quirks as +upstream `oldChars[0]`.) + +The accented variants are deliberate: + + - Survives every text-bearing format (no XML/JSON/CSV/YAML escape + issues, since every replacement codepoint is in the Latin-Extended + Unicode block). + - Visually distinct from the source so failure dumps clearly show + "this is the translated text". + - Locale-agnostic: no MT, no model dependency, deterministic + bytes. + +## Comparison strategy + +For each fixture the harness runs the **okapi engine end-to-end +inline** to obtain the live reference output (Okapi's pipeline: +read → `TextModificationStep` → write), then runs each tested +engine (native, bridge) and byte-compares its output against the +okapi reference. The okapi engine is the comparator; native and +bridge are the engines under test. There are no committed golden +files. Upstream Okapi's behavior IS the reference, captured fresh +every run, so there is no risk of "the golden is from Okapi v1.47, +but we're on v1.48 now" drift. + +For formats where the okapi engine itself can't produce a usable +reference (intentionally-malformed test fixtures, files referencing +sibling resources that aren't copied to the harness's tempdir, +read-only filters like `okf_xliff2`), per-file `skip` entries with +`Engines: ["okapi"]` skip the whole sub-test: no reference, no +engine assertions. We deliberately do not fall back to the native +engine here: that would make native judge itself, the exact +circularity this whole approach was built to remove. These rows are +placeholders documenting that the format exists; the case starts +asserting once the upstream pipeline can handle the input. + +Compound zip formats (idml, openxml, epub) compare per-entry: +byte-equal across uncompressed entry contents, ignoring zip +metadata (mtime, central-directory order, compression level) that +two correct round-trippers can legitimately differ on. Mark a +fixture with `isZip: true` to opt into per-entry comparison. + +This approach avoids the trap of using one engine's reader as the +judge of another engine's writer. Every engine is held to the same +external yardstick. A divergence is concrete and actionable: "your +writer produced different bytes from the reference at offset 251." + +## Hard requirements + +The **parity sandbox** is the only mandatory dependency, with +no "skip if missing" path: + + - The `okapi-bridge` launcher binary inside the sandbox is checked + at `TestMain` (it backs both the bridge daemon engine AND the + okapi reference engine via the `pseudo` subcommand). + - The bridge daemon process itself is acquired through + `parity.AcquireBridgeDaemon`, which `t.Fatal`s if it can't + spawn. + +Build the sandbox from the repo root: + +```bash +make parity-test +# or to force a rebuild including a fresh okapi-bridge plugin tarball: +PARITY_FORCE=1 make parity-test +``` + +Failing fast at startup means a missing dependency surfaces as one +loud error, not a swarm of identical "engine unavailable" skips. + +## Test wiring + +All test invocations come from `coverage_test.go`, which scans +upstream Okapi fixtures and constructs one `RunThreeWay` call per +file. The shape of one call: + +```go +roundtrip.RunThreeWay(t, roundtrip.Case{ + Name: base, // file basename + FormatID: "plaintext", // neokapi registry key + Input: roundtrip.Input{Bytes: body, Filename: base}, +}, + &roundtrip.NativeEngine{FormatID: "plaintext"}, + &roundtrip.BridgeEngine{FilterClass: "okf_plaintext"}, + &roundtrip.OkapiEngine{FilterClass: "okf_plaintext"}, +) +``` + +`RunThreeWay` runs each engine in its own sub-test. Engines listed +in `c.ExpectedSkipped` are recorded as `t.Skip`; the remaining +engines must produce output equivalent to the okapi reference. The +harness reports every disagreement at once instead of bailing on the +first. + +For formats where the bridge filter expects different parameter names +than neokapi's canonical config, supply `BridgeEngine.FilterParams` +in already-translated form, the same approach as the spec runner's +`BridgeConfig` hook (see `parity/formats/csv_bridge_config.go`). +For Okapi-side parameter overrides on the reference engine (e.g. +disabling `mergeCaptions` on subtitle filters), supply +`OkapiEngine.ParamConfig` as raw `.fprm` content. + +## File layout + +``` +parity/roundtrip/ +├── doc.go # Package overview +├── engine.go # Engine interface, Input, Result, PseudoSpec +├── pseudo.go # applyPseudoToBlock — shared Latin-extended substitution +├── native.go # NativeEngine — in-process reader → transform → writer +├── bridge.go # BridgeEngine — gRPC read-write with Transform hook +├── okapi.go # OkapiEngine — bridge launcher's `pseudo` subcommand (the comparator) +├── compare.go # Byte / per-zip-entry comparators + Divergence +├── harness.go # RunThreeWay — runs the okapi engine as comparator +├── main_test.go # TestMain — hard-requires the okapi-bridge launcher +├── coverage_test.go # Per-format scans (formatScan) + discovery loop +└── coverage_skips_test.go # Per-file bridge skip maps (idml, openxml, html, markdown, po, csv, ts, mif, icml) +``` + +All files build-tagged `parity` to match the surrounding harness. + +The `pseudo` subcommand itself lives in okapi-bridge: +`bridge-core/src/main/java/neokapi/bridge/PseudoCommand.java`. It's +wired into `OkapiBridgeServer.main`'s subcommand dispatcher +(alongside `daemon`, `version`, `--list-filters`, …). + +## Build and run + +Build the parity sandbox once from the repo root: + +```bash +make parity-test +# or, after editing okapi-bridge sources: +PARITY_FORCE=1 make parity-test +``` + +Then run the suite: + +```bash +KAPI_PARITY_SANDBOX="$(pwd)/.parity" \ + go test -tags parity ./parity/roundtrip/ -v +``` + +Without the sandbox, `TestMain` aborts the binary immediately with +a clear error, and no test runs. The bridge daemon is acquired through +the same `parity.AcquireBridgeDaemon` used by the spec runner; a +single daemon process is shared across the whole `go test` run and +torn down in `TestMain`. + +## Adding a new format + +The harness mirrors upstream Okapi: each `formatScan` points at the +same resource directory the corresponding `RoundTripIT.java` uses, +and every file with a matching extension becomes one sub-test. + +1. Add a `formatScan` entry to `coverage_test.go::coverageScans()`: + + ```go + { + formatID: "myformat", // neokapi registry key + filterClass: "okf_myformat", // upstream Okapi filter class + sources: []string{"integration-tests/okapi/src/test/resources/myformat"}, + extensions: []string{".myext"}, + }, + ``` + + The harness scans `sources` for files matching `extensions` and + creates one sub-test per file (`TestRoundTrip_Coverage/myformat/`). + Use `explicitFiles` instead of `sources` when the format's + fixtures are mixed in with sibling-filter fixtures and need to be + cherry-picked (see splicedlines / paraplaintext / tsv / fixedwidth + for examples). + +2. If the bridge filter takes parameters with different names than + neokapi's spec config, populate `bridgeParams` (see + `parity/formats/csv_bridge_config.go` for the translation + pattern). + +3. If the okapi reference engine needs non-default filter + parameters (e.g. disabling `mergeCaptions` on subtitle filters), + set `okapiParamConfig` to the raw `.fprm` content: + + ```go + okapiParamConfig: `#v1 + mergeCaptions.b=false + `, + ``` + + The harness forwards it to the `pseudo` subcommand as + `--fprm `, where Okapi loads it via + `IParameters.fromString()` against the configured filter. See + `okapi/filters/` in the Okapi source for which fields each + filter accepts. + +4. If the format is a zip-based compound (idml, openxml, epub, …), + set `isZip: true` to switch the comparator to per-entry mode. + +5. Run the test. The first run will surface every divergence as a + real failure. For each one, characterize the cause and add a + `skip` entry: + + - **Format-default skip** (most cases): if the same root cause + affects most/all files, set `formatDefaultSkip` with the + engines + a one-line reason. Per-file overrides extend it. + - **Per-file skip**: keyed by basename in the `skip` map. Use + `Engines: ["okapi"]` to mark a file where the okapi reference + engine can't produce a usable output (intentionally-malformed + test fixture, missing linked resource, upstream parser bug); + the whole sub-test then skips with the reason. + - For long per-file maps (idml: 46 entries, openxml: 124), + extract them into a helper in `coverage_skips_test.go` to + keep `coverage_test.go` readable; see `idmlBridgeSkips()`. + + Treat every skip the way `expected_fail:` is treated in + spec.yaml: a tracked, real divergence, not a workaround for a + config you forgot to set. + +## Relationship to the spec runner + +The spec runner (`spec.yaml` + `NativeRunner` / `ParityRunner`) and +the round-trip harness are complementary, not redundant: + +| Aspect | Spec runner | Round-trip harness | +| ------------------- | --------------------------------- | ---------------------------------------------------- | +| Phase covered | Read only | Read → modify → write → re-read | +| Engines | Native + Bridge | Native + Bridge (okapi engine is the inline comparator) | +| What it asserts | Block count / text / IDs | Engine output matches okapi reference byte-for-byte | +| Driver format | Per-format `spec.yaml` (declarative) | Per-format Go test row (programmatic) | +| Failure granularity | Per-example | Per-engine | +| Catches | Reader bugs, parity-of-reads | Writer bugs, skeleton bugs, target-merge bugs | + +Both should pass before a format is considered shipped. A format that +passes spec parity but fails round-trip almost certainly has a writer +or skeleton bug. + +## Coverage status + +The harness mirrors upstream's `RoundTripIT.java` set: **30 +formats × ~25 files each ≈ 1100+ sub-tests** in ~10 minutes. With +the parity sandbox built the suite is fully green (0 fail, 765 +engine assertions pass, ~990 documented engine skips). + +After the okapi-bridge per-field code-hydrate fix (the daemon +clones source `Code` metadata across the wire, `outerData`, +`originalId` and `referenceFlag`, instead of rebuilding from the +FragmentDTO), inline-code-bearing formats fully recovered: idml +70/70 bridge, openxml 185/185, mif 41/41, icml 9/9, xml 199/199. +The remaining 51 known divergences cluster in three buckets: PO/TS +property round-trip + target-vs-source pseudo (26), CSV +segmented-cell handling (8), HTML/markdown code-id reconstruction +on the way back (17). See `coverage_skips_test.go` for the per-file +list. + +Highlights of the upstream-mirroring discovery: + + - **html** runs against all 69 fixtures from + `integration-tests/okapi/src/test/resources/html/`, the same set as + upstream's `RoundTripHtmlIT`. + - **json** 70 (all pass bridge), **idml** 70, **openxml** 185, the + same fixtures upstream roundtrips with their own `RoundTripIT`. + json got bumped from "all skipped" to "all bridge passes" as a + direct result of switching the comparator to the in-process + okapi pipeline. + - **xml** uses the ITS-based `okf_xml` filter (it lives in + `okapi/filters/its/.../XMLFilter.java`, not a dedicated `xml` + filter directory) and roundtrips the 22 files from + `integration-tests/okapi/src/test/resources/xml/`. Bridge passes + 14 of 22, with 8 inline-code marker divergences flagged + per-file. + - **paraplaintext** / **splicedlines** / **mosestext** are + sub-filters of `okf_plaintext` with no dedicated upstream + integration-test directory; cherry-picked via `explicitFiles` + against the canonical fixtures referenced by their unit tests + (`combined_lines.txt`, `test_paragraphs1.txt`, `Test01.txt`). + - **vtt** / **ttml** use `okapiParamConfig` to disable + `mergeCaptions` (matching the bridge's `mergeCaptions:false` + knob), avoiding the upstream default that mangles cue text on + round-trip. + +Formats intentionally **omitted** from coverage: + + - `txml`, `rtf`, `epub`: upstream Okapi can't produce a usable + reference (txml NPE on merge, rtf only ships as `okf_tradosrtf`, + no `okf_epub` in this distribution). + - `jsx-kbf`, `versifiedtext`, `messageformat`: neokapi-only + formats with no upstream Okapi peer to compare against. + - `srt`: needs SRT-specific regex rules loaded as a sizable + `.fprm` against the bridge's `okf_regex` filter; wire that in + when there's a real signal worth catching. + - `odf`: no test fixtures committed in the framework yet. + +These are covered by per-format unit tests under `core/formats//` +instead. + +### Reading the skip map + +Each `formatScan.skip` entry is **one tracked engine bug**, not a +workaround. The skip mechanism has three layers: + + - `formatDefaultSkip` applies to every file in the scan. Use it + when the same root cause affects most/all files (e.g. native + yaml writer doesn't preserve quoting style → every yaml file + skips native). + - Per-file `skip` entries extend the format default with file- + specific engines/reasons. Use them for outliers (a single file + where the okapi reference also fails, or where bridge has a + different bug). + - `Engines: ["okapi"]` on a per-file entry means the okapi + reference engine itself can't produce a usable reference for + that file, so the whole sub-test skips. Common causes: + intentionally-malformed test fixtures (`tmx/code_fail.tmx`), + files referencing sibling resources Okapi can't find when + invoked on a single file (`xml/Translate2.xml`, + `xliff/lqiTest.xlf`), upstream parser rejects (`yaml/Test03.yml` + `!!timestamp`), read-only filters (`okf_xliff2`). + +Common bug classes the skips encode: + + - **Native writer byte-shape divergence** (most formats): + serialization choices differ from upstream Okapi in XML + attribute order, YAML quoting style, properties escape case, + trailing newlines. + - **Native skips target on merge** (html, openxml): writer doesn't + splice the translated text back into the document. + - **Bridge inline-code marker divergence** (xml, xliff, html, + markdown, po, mif, …): the bridge daemon's + `StreamingTranslationApplier` rebuilds `TextFragment` from Go- + side fragments via `OkapiCodeConverter`, and the result diverges + from what an in-process Okapi pipeline would have written for + the same source, usually around inline-code id assignment, + paired-code restoration, or alt-trans/extension element + placement. + - **Native YAML reader infinite loop** (4 files with self- + referencing anchors): a real reader bug worth fixing. + +When an engine bug gets fixed, the corresponding skip entry can be +deleted, and the next run then asserts against the okapi reference +byte-for-byte, catching any regression. + +## Formats not yet covered + +Some registered formats don't fit the generic harness today: + + - **regex**: needs an explicit rule set; native + bridge configs + don't line up enough to share a fixture. + - **vignette**: requires the `` shape with + a paired source/target instance via `SOURCE_ID` / `LOCALE_ID`, + which is too verbose for inline fixtures and doesn't work without + config aliasing. + - **ttx**: UTF-16-encoded; the harness's re-extraction pass uses + Go's `encoding/xml` without a CharsetReader. + - **odf**: no `core/formats/odf/testdata/*.odt` fixture committed + to the framework yet. + - **pdf**: no native writer (extract-only). + - **mo**: write-only stub on the read side. + +Adding any of these is a follow-up, usually one fixture row plus, +for the harder cases, a small bit of per-format wiring (charset +reader for ttx, BridgeConfig hook for regex, fixture commit for odf). diff --git a/parity/env.go b/parity/env.go new file mode 100644 index 0000000..c985ebd --- /dev/null +++ b/parity/env.go @@ -0,0 +1,210 @@ +//go:build parity + +package parity + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +// Sandbox describes the locally built kapi + plugin install used by the +// parity harness. It is populated once per test binary by Acquire, by +// reading the KAPI_PARITY_SANDBOX env var that `make parity-test` +// exports. +type Sandbox struct { + // Root is the absolute path to the sandbox root (e.g. /.parity). + Root string + + // KapiBinary is the absolute path to the locally built kapi binary. + KapiBinary string + + // PluginsDir is the absolute path to the plugin install root that + // pluginhost.Discover should scan. Same as Root + "/plugins". + PluginsDir string + + // OkapiBridgeDir is the absolute path to the okapi-bridge plugin + // install (PluginsDir + "/okapi-bridge"). + OkapiBridgeDir string + + // OkapiBridgeBinary is the absolute path to the daemon launcher + // inside OkapiBridgeDir, derived from manifest.binary. + OkapiBridgeBinary string + + // OkapiTestDataDir is the absolute path to the unpacked Okapi + // test resources tarball published as the + // `okapi-testdata-` GitHub release on + // neokapi/okapi-bridge (built by that repo's + // `scripts/publish-okapi-testdata.sh`). The tree preserves the + // original Okapi layout: `okapi/filters//src/test/ + // resources/...`. Round-trip tests resolve upstream Okapi binary + // fixtures from here so neokapi doesn't have to vendor them. + // May be empty if the fetch failed at sandbox-build time. + OkapiTestDataDir string +} + +var ( + sandboxOnce sync.Once + sandbox *Sandbox + sandboxErr error +) + +// LoadSandbox returns the parity sandbox or an error explaining why it +// could not be loaded. The lookup order is: +// +// 1. $KAPI_PARITY_SANDBOX (set by `make parity-test` and CI). +// 2. Auto-discovery: walk up from cwd looking for a `.parity/` dir +// containing `bin/kapi`. Lets `go test -tags parity ./parity/...` +// work from inside any subdirectory of a repo whose sandbox is +// already built, with no env var. +// +// Callers usually go through RequireSandbox, which fails the test on a +// missing sandbox by default — silent skips on missing sandbox have +// caused multiple incidents where local agent runs claimed parity-test +// passed while CI failed. +func LoadSandbox() (*Sandbox, error) { + sandboxOnce.Do(func() { + root := os.Getenv("KAPI_PARITY_SANDBOX") + if root == "" { + discovered, err := discoverSandbox() + if err != nil { + sandboxErr = fmt.Errorf("KAPI_PARITY_SANDBOX is not set and no `.parity/` directory was found in any parent of cwd. Run `make parity-test` from the repo root: %w", err) + return + } + root = discovered + } + root, err := filepath.Abs(root) + if err != nil { + sandboxErr = fmt.Errorf("resolve KAPI_PARITY_SANDBOX %q: %w", root, err) + return + } + s := &Sandbox{ + Root: root, + KapiBinary: filepath.Join(root, "bin", "kapi"), + PluginsDir: filepath.Join(root, "plugins"), + OkapiBridgeDir: filepath.Join(root, "plugins", "okapi-bridge"), + } + if _, err := os.Stat(s.KapiBinary); err != nil { + sandboxErr = fmt.Errorf("sandbox kapi binary missing at %s: %w", s.KapiBinary, err) + return + } + manifestPath := filepath.Join(s.OkapiBridgeDir, "manifest.json") + bin, err := bridgeBinaryFromManifest(manifestPath) + if err != nil { + sandboxErr = fmt.Errorf("read bridge manifest %s: %w", manifestPath, err) + return + } + s.OkapiBridgeBinary = filepath.Join(s.OkapiBridgeDir, bin) + if _, err := os.Stat(s.OkapiBridgeBinary); err != nil { + sandboxErr = fmt.Errorf("sandbox bridge binary missing at %s: %w", s.OkapiBridgeBinary, err) + return + } + // OkapiTestDataDir is optional — only populated when + // parity/scripts/sandbox.sh successfully fetched and + // extracted the okapi-testdata- release tarball. + // Tests that need it should fail loudly via + // RequireOkapiTestData rather than silently skipping. + // Probe both the versioned dir and a stable "current" + // alias; we use the versioned path because the sandbox + // script extracts there. + entries, _ := os.ReadDir(filepath.Join(root, "okapi-testdata")) + for _, e := range entries { + if !e.IsDir() { + continue + } + candidate := filepath.Join(root, "okapi-testdata", e.Name()) + // The tarball preserves the upstream "okapi/" prefix on + // every path (okapi/filters/..., okapi/integration-tests/...); + // confirm by checking for that subdir. + if _, err := os.Stat(filepath.Join(candidate, "okapi")); err == nil { + s.OkapiTestDataDir = candidate + break + } + } + sandbox = s + }) + return sandbox, sandboxErr +} + +// discoverSandbox walks up from cwd looking for a `.parity/bin/kapi`, +// returning the absolute path to the `.parity` dir. Returns an error +// when no candidate is found before hitting the filesystem root. Lets +// `go test -tags parity ./parity/...` find an already-built sandbox +// without requiring callers to set $KAPI_PARITY_SANDBOX. +func discoverSandbox() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getwd: %w", err) + } + for { + candidate := filepath.Join(dir, ".parity") + kapi := filepath.Join(candidate, "bin", "kapi") + if _, err := os.Stat(kapi); err == nil { + return candidate, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("no .parity/bin/kapi in any ancestor of cwd") + } + dir = parent + } +} + +// skipEnv lets developers opt out of the loud failure when they +// genuinely don't have or want a sandbox locally. CI never sets this. +const skipEnv = "KAPI_PARITY_SKIP" + +// RequireSandbox loads the sandbox and either returns it, fails the +// test (default), or skips (when KAPI_PARITY_SKIP=1). +// +// Why fail by default: the previous skip-by-default behavior caused +// repeated incidents where local agent runs reported parity-test green +// while CI surfaced real divergences, because the silent skip looked +// indistinguishable from a passing run. Failing forces a build of the +// sandbox (`make parity-test` from the repo root) before anyone — human +// or agent — claims parity is passing locally. +// +// Set KAPI_PARITY_SKIP=1 to opt out (only useful for cross-cutting test +// runs that don't care about parity, e.g. `go test ./...` from a fresh +// checkout before the sandbox exists). +// RequireOkapiTestData returns the absolute path to a fixture +// inside the unpacked okapi-testdata- release tarball. +// The path is in the original Okapi layout, e.g. +// "okapi/filters/idml/src/test/resources/06-hello-world-12.idml". +// +// Use this for upstream Okapi binary fixtures the round-trip suite +// needs but neokapi shouldn't vendor. The tarball is published by +// okapi-bridge's scripts/publish-okapi-testdata.sh as the +// `okapi-testdata-` GitHub release and fetched on demand +// by parity/scripts/sandbox.sh. This lookup is a hard fail when +// the tree is missing — a silent skip would mask the fact that +// nothing is being asserted. +func RequireOkapiTestData(t *testing.T, rel string) string { + t.Helper() + s := RequireSandbox(t) + if s.OkapiTestDataDir == "" { + t.Fatalf("okapi test resources tarball not present in sandbox: parity/scripts/sandbox.sh failed to fetch it (network? GitHub release missing?). Rerun with PARITY_FORCE=1 make parity-test.") + } + abs := filepath.Join(s.OkapiTestDataDir, rel) + if _, err := os.Stat(abs); err != nil { + t.Fatalf("okapi test resource %q not found at %s: %v", rel, abs, err) + } + return abs +} + +func RequireSandbox(t *testing.T) *Sandbox { + t.Helper() + s, err := LoadSandbox() + if err != nil { + if os.Getenv(skipEnv) == "1" { + t.Skipf("parity harness unavailable (KAPI_PARITY_SKIP=1): %v", err) + } + t.Fatalf("parity harness unavailable: %v\n\n"+ + "Build the sandbox with `make parity-test` (from the repo root) before running parity tests.\n"+ + "Set KAPI_PARITY_SKIP=1 to skip these tests instead of failing.", err) + } + return s +} diff --git a/parity/env_test.go b/parity/env_test.go new file mode 100644 index 0000000..e11eb98 --- /dev/null +++ b/parity/env_test.go @@ -0,0 +1,70 @@ +//go:build parity + +package parity + +import ( + "os" + "path/filepath" + "testing" +) + +// TestDiscoverSandbox verifies the upward-walk lookup finds a +// `.parity/bin/kapi` ancestor and returns the absolute `.parity/` +// path. Builds a tiny fake sandbox in a temp dir and chdir's into a +// nested subdir so the walk has to traverse at least one parent. +func TestDiscoverSandbox(t *testing.T) { + tmp := t.TempDir() + parityDir := filepath.Join(tmp, ".parity") + binDir := filepath.Join(parityDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(binDir, "kapi"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake kapi: %v", err) + } + nested := filepath.Join(tmp, "a", "b", "c") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + if err := os.Chdir(nested); err != nil { + t.Fatalf("chdir nested: %v", err) + } + + got, err := discoverSandbox() + if err != nil { + t.Fatalf("discoverSandbox: %v", err) + } + wantSuffix := filepath.Base(parityDir) + if filepath.Base(got) != wantSuffix { + t.Fatalf("got %q, want a path ending in %q", got, wantSuffix) + } + // macOS resolves /private/var via /var so EvalSymlinks both sides. + gotResolved, _ := filepath.EvalSymlinks(got) + wantResolved, _ := filepath.EvalSymlinks(parityDir) + if gotResolved != wantResolved { + t.Fatalf("got %q, want %q", gotResolved, wantResolved) + } +} + +// TestDiscoverSandboxNotFound asserts the walk returns an error when +// no `.parity/bin/kapi` exists in any ancestor. +func TestDiscoverSandboxNotFound(t *testing.T) { + tmp := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir tmp: %v", err) + } + if _, err := discoverSandbox(); err == nil { + t.Fatalf("discoverSandbox: expected error in empty tmp") + } +} diff --git a/parity/formats/bridge_config_test.go b/parity/formats/bridge_config_test.go new file mode 100644 index 0000000..7cb92e5 --- /dev/null +++ b/parity/formats/bridge_config_test.go @@ -0,0 +1,244 @@ +//go:build parity + +package formats + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCsvBridgeConfig pins the neokapi→Okapi csv config translation. +// The translator emits one `fprmContent` Okapi ParametersString blob; +// these assertions verify each spec key lands as the right field line. +func TestCsvBridgeConfig(t *testing.T) { + t.Run("defaults_with_no_overrides", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{}) + require.NoError(t, err) + fprm := requireFprm(t, out) + // neokapi-canonical CSV defaults. + assert.Contains(t, fprm, "#v1\n") + assert.Contains(t, fprm, "fieldDelimiter=,\n") + assert.Contains(t, fprm, "textQualifier=\"\n") + assert.Contains(t, fprm, "columnNamesLineNum.i=1\n") + assert.Contains(t, fprm, "valuesStartLineNum.i=2\n") + assert.Contains(t, fprm, "sendHeaderMode.i=0\n") + assert.Contains(t, fprm, "sendColumnsMode.i=2\n") // SEND_COLUMNS_ALL + assert.Contains(t, fprm, "trimMode.i=0\n") + assert.Contains(t, fprm, "targetColumns=\n") + assert.Contains(t, fprm, "parametersClass=net.sf.okapi.filters.table.csv.Parameters\n") + }) + + t.Run("separator_to_fieldDelimiter", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"separator": "\t"}) + require.NoError(t, err) + assert.Contains(t, requireFprm(t, out), "fieldDelimiter=\t\n") + }) + + t.Run("textQualifier_verbatim", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"textQualifier": "'"}) + require.NoError(t, err) + assert.Contains(t, requireFprm(t, out), "textQualifier='\n") + }) + + t.Run("hasHeader_true", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"hasHeader": true}) + require.NoError(t, err) + fprm := requireFprm(t, out) + assert.Contains(t, fprm, "columnNamesLineNum.i=1\n") + assert.Contains(t, fprm, "valuesStartLineNum.i=2\n") + assert.Contains(t, fprm, "sendHeaderMode.i=0\n") + }) + + t.Run("hasHeader_false", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"hasHeader": false}) + require.NoError(t, err) + fprm := requireFprm(t, out) + assert.Contains(t, fprm, "columnNamesLineNum.i=0\n") + assert.Contains(t, fprm, "valuesStartLineNum.i=1\n") + }) + + t.Run("columnNamesRow_and_valuesStartRow", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"columnNamesRow": 2, "valuesStartRow": 4}) + require.NoError(t, err) + fprm := requireFprm(t, out) + assert.Contains(t, fprm, "columnNamesLineNum.i=2\n") + assert.Contains(t, fprm, "valuesStartLineNum.i=4\n") + }) + + t.Run("translatableColumns_zero_based_to_one_based_listed", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"translatableColumns": []any{0, 2}}) + require.NoError(t, err) + fprm := requireFprm(t, out) + assert.Contains(t, fprm, "sourceColumns=1,3\n") + assert.Contains(t, fprm, "sendColumnsMode.i=1\n") // SEND_COLUMNS_LISTED + }) + + t.Run("keyColumns_to_sourceIdColumns", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"keyColumns": []any{0, 1}}) + require.NoError(t, err) + assert.Contains(t, requireFprm(t, out), "sourceIdColumns=1,2\n") + }) + + t.Run("commentColumns_one_based", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"commentColumns": []any{1}}) + require.NoError(t, err) + assert.Contains(t, requireFprm(t, out), "commentColumns=2\n") + }) + + t.Run("trimValues_true", func(t *testing.T) { + out, err := csvBridgeConfig(map[string]any{"trimValues": true}) + require.NoError(t, err) + fprm := requireFprm(t, out) + assert.Contains(t, fprm, "trimMode.i=2\n") + assert.Contains(t, fprm, "trimLeading.b=true\n") + assert.Contains(t, fprm, "trimTrailing.b=true\n") + }) + + t.Run("unknown_key_errors", func(t *testing.T) { + _, err := csvBridgeConfig(map[string]any{"bogus": true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown spec key") + }) + + t.Run("wrong_type_errors", func(t *testing.T) { + _, err := csvBridgeConfig(map[string]any{"hasHeader": "yes"}) + require.Error(t, err) + }) +} + +// TestXmlstreamBridgeConfig pins the neokapi→Okapi xmlstream config +// translation: shorthand camelCase keys expand into the long-form +// Okapi YAML keys (elements / attributes / exclude_by_default / +// preserve_whitespace) that the bridge's YAML deep-merge applies. +func TestXmlstreamBridgeConfig(t *testing.T) { + t.Run("translatableElements_to_include_with_exclude_by_default", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{ + "translatableElements": []any{"title", "description"}, + }) + require.NoError(t, err) + assert.Equal(t, true, out["exclude_by_default"], + "translatableElements is an opt-in whitelist → exclude_by_default") + elements, ok := out["elements"].(map[string]any) + require.True(t, ok, "elements map present") + assert.Equal(t, ruleTypes("INCLUDE"), elements["title"]) + assert.Equal(t, ruleTypes("INCLUDE"), elements["description"]) + }) + + t.Run("excludeByDefault_to_underscore", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{"excludeByDefault": true}) + require.NoError(t, err) + assert.Equal(t, true, out["exclude_by_default"]) + }) + + t.Run("excludeByDefault_false_is_emitted", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{"excludeByDefault": false}) + require.NoError(t, err) + v, ok := out["exclude_by_default"] + require.True(t, ok, "explicit false must be emitted") + assert.Equal(t, false, v) + }) + + t.Run("explicit_excludeByDefault_wins_over_translatableElements_implication", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{ + "translatableElements": []any{"title"}, + "excludeByDefault": false, + }) + require.NoError(t, err) + assert.Equal(t, false, out["exclude_by_default"], + "an explicit excludeByDefault must override the translatableElements implication") + }) + + t.Run("preserveWhitespace_to_underscore", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{"preserveWhitespace": true}) + require.NoError(t, err) + assert.Equal(t, true, out["preserve_whitespace"]) + }) + + t.Run("parser_preserveWhitespace_subkey", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{ + "parser": map[string]any{"preserveWhitespace": true, "assumeWellformed": true}, + }) + require.NoError(t, err) + assert.Equal(t, true, out["preserve_whitespace"]) + // assumeWellformed is intentionally dropped. + _, hasAssume := out["assumeWellformed"] + assert.False(t, hasAssume) + }) + + t.Run("element_shorthands_map_to_rule_types", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{ + "inlineElements": []any{"b"}, + "excludedElements": []any{"pre"}, + "preserveWhitespaceElements": []any{"code"}, + "groupElements": []any{"ul"}, + }) + require.NoError(t, err) + elements, ok := out["elements"].(map[string]any) + require.True(t, ok) + assert.Equal(t, ruleTypes("INLINE"), elements["b"]) + assert.Equal(t, ruleTypes("EXCLUDE"), elements["pre"]) + assert.Equal(t, ruleTypes("PRESERVE_WHITESPACE"), elements["code"]) + assert.Equal(t, ruleTypes("GROUP"), elements["ul"]) + // excludeByDefault is NOT implied by these shorthands. + _, hasExclude := out["exclude_by_default"] + assert.False(t, hasExclude) + }) + + t.Run("translatableAttributes_to_attribute_trans", func(t *testing.T) { + out, err := xmlstreamBridgeConfig(map[string]any{ + "translatableAttributes": []any{"title", "alt"}, + }) + require.NoError(t, err) + attrs, ok := out["attributes"].(map[string]any) + require.True(t, ok) + assert.Equal(t, ruleTypes("ATTRIBUTE_TRANS"), attrs["title"]) + assert.Equal(t, ruleTypes("ATTRIBUTE_TRANS"), attrs["alt"]) + }) + + t.Run("explicit_elements_and_attributes_pass_through", func(t *testing.T) { + elementRule := map[string]any{"ruleTypes": []any{"EXCLUDE"}} + out, err := xmlstreamBridgeConfig(map[string]any{ + "elements": map[string]any{"pre": elementRule}, + }) + require.NoError(t, err) + elements, ok := out["elements"].(map[string]any) + require.True(t, ok) + assert.Equal(t, elementRule, elements["pre"]) + }) + + t.Run("unknown_key_errors", func(t *testing.T) { + _, err := xmlstreamBridgeConfig(map[string]any{"bogus": true}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown spec key") + }) + + t.Run("wrong_type_errors", func(t *testing.T) { + _, err := xmlstreamBridgeConfig(map[string]any{"preserveWhitespace": "yes"}) + require.Error(t, err) + }) +} + +// TestRegexBridgeConfig pins the neokapi→Okapi okf_regex config +// translation: the rule list is serialised to the reserved +// `regexRulesJson` parameter, the escape discriminator folds into the +// two bridge booleans, and regexOptions is forced to 0 (RE2-equivalent, +// no global DOTALL/MULTILINE). +func requireFprm(t *testing.T, out map[string]any) string { + t.Helper() + require.Len(t, out, 1, "csvBridgeConfig emits exactly one key") + v, ok := out["fprmContent"] + require.True(t, ok, "fprmContent key present") + s, ok := v.(string) + require.True(t, ok, "fprmContent is a string") + require.True(t, strings.HasPrefix(s, "#v1\n"), "fprm starts with #v1 header") + return s +} + +// ruleTypes builds the {ruleTypes:[t]} rule object the xmlstream +// translator emits for a shorthand element/attribute rule. +func ruleTypes(t string) map[string]any { + return map[string]any{"ruleTypes": []any{t}} +} diff --git a/parity/formats/csv_bridge_config.go b/parity/formats/csv_bridge_config.go new file mode 100644 index 0000000..d2490e9 --- /dev/null +++ b/parity/formats/csv_bridge_config.go @@ -0,0 +1,269 @@ +//go:build parity + +package formats + +import ( + "fmt" + "strconv" + "strings" +) + +// csvBridgeConfig translates a neokapi-keyed csv spec config map into +// the parameter shape the okapi-bridge daemon's CommaSeparatedValuesFilter +// (okf_commaseparatedvalues) expects. +// +// The upstream okf_table family (csv/tsv/fwc) reads its behaviour from +// the public fields of its Okapi `Parameters` object +// (`net.sf.okapi.filters.table.csv.Parameters` extending +// `…table.base.Parameters`). Those fields are populated ONLY by +// `Parameters.load(buffer)`, which runs inside `fromString(...)`. The +// bridge's per-key `params.setString("sourceColumns", "1")` path writes +// the StringParameters buffer but never re-runs `load()`, so the public +// field stays stale and the filter ignores the override (see #530 and +// the identical quirk documented in fixedwidth_bridge_config.go). +// +// To sidestep this, the translator renders the ENTIRE configuration as +// a single Okapi `#v1` ParametersString blob and ships it under the +// reserved `fprmContent` key. The bridge calls +// `filterParameters.fromString(fprmContent)` once, which clears the +// buffer and re-runs `load()` — syncing every field in one shot. All +// per-feature convergence forces collapse into that single payload. +// +// The blob always carries the neokapi-canonical CSV defaults (comma +// delimiter, double-quote qualifier, header on row 1 + data on row 2 + +// header not extracted, every column translatable, no trim); the spec +// config overrides individual fields on top. This matches the parity +// contract "same semantic config → same results": examples that depend +// on neokapi defaults MUST set explicit config (they do). +// +// Field/value mappings (verified against +// net.sf.okapi.filters.table.{base,csv}.Parameters): +// +// separator (string) → fieldDelimiter (string, verbatim) +// textQualifier (string) → textQualifier (string, verbatim) +// hasHeader=true (bool) → columnNamesLineNum=1, valuesStartLineNum=2, +// sendHeaderMode=0 (SEND_HEADER_NONE) +// hasHeader=false → columnNamesLineNum=0, valuesStartLineNum=1, +// sendHeaderMode=0 +// columnNamesRow (int) → columnNamesLineNum (1-based, verbatim) +// valuesStartRow (int) → valuesStartLineNum (1-based, verbatim) +// translatableColumns ([]int0) → sourceColumns ("1,3" 1-based CSV) + +// sendColumnsMode=1 (SEND_COLUMNS_LISTED) +// keyColumns ([]int0) → sourceIdColumns ("1,2" 1-based CSV) +// commentColumns ([]int0) → commentColumns ("2" 1-based CSV) +// trimValues=true (bool) → trimMode=2 (TRIM_ALL) + trimLeading/ +// trimTrailing=true +// trimValues=false → trimMode=0 (TRIM_NONE) +// +// targetColumns is forced empty: the upstream default ("2") would treat +// the second column as a localised target (multilingual mode) rather +// than translatable source, which neokapi has no notion of. +// +// The translator never mutates its input; it returns a fresh map. +func csvBridgeConfig(cfg map[string]any) (map[string]any, error) { + // Defaults mirror neokapi's CSV semantics (see core/formats/csv/ + // config.go Reset()): comma delimiter, double-quote qualifier, + // header present (row 1, data row 2, header not extracted), every + // column translatable, no trim. + p := csvParams{ + fieldDelimiter: ",", + textQualifier: "\"", + removeQualifiers: true, + escapingMode: 1, // ESCAPING_MODE_DUPLICATION + columnNamesLineNum: 1, // header on row 1 + valuesStartLineNum: 2, // data on row 2 + sendHeaderMode: 0, // SEND_HEADER_NONE — header is skeleton, not a Block + sendColumnsMode: 2, // SEND_COLUMNS_ALL — every cell is source + trimMode: 0, // TRIM_NONE + trimLeading: false, + trimTrailing: false, + preserveWS: true, + useCodeFinder: false, + } + + for key, val := range cfg { + switch key { + case "separator": + s, ok := val.(string) + if !ok { + return nil, fmt.Errorf("csvBridgeConfig: separator: expected string, got %T", val) + } + p.fieldDelimiter = s + + case "textQualifier": + s, ok := val.(string) + if !ok { + return nil, fmt.Errorf("csvBridgeConfig: textQualifier: expected string, got %T", val) + } + p.textQualifier = s + + case "hasHeader": + b, ok := val.(bool) + if !ok { + return nil, fmt.Errorf("csvBridgeConfig: hasHeader: expected bool, got %T", val) + } + if b { + p.columnNamesLineNum = 1 + p.valuesStartLineNum = 2 + } else { + p.columnNamesLineNum = 0 + p.valuesStartLineNum = 1 + } + p.sendHeaderMode = 0 + + case "columnNamesRow": + n, err := asInt(val, "columnNamesRow") + if err != nil { + return nil, err + } + p.columnNamesLineNum = n + + case "valuesStartRow": + n, err := asInt(val, "valuesStartRow") + if err != nil { + return nil, err + } + p.valuesStartLineNum = n + + case "translatableColumns": + cols, err := intSliceToOneBasedCSV(val) + if err != nil { + return nil, fmt.Errorf("csvBridgeConfig: translatableColumns: %w", err) + } + p.sourceColumns = cols + p.sendColumnsMode = 1 // SEND_COLUMNS_LISTED — only listed columns + + case "keyColumns": + cols, err := intSliceToOneBasedCSV(val) + if err != nil { + return nil, fmt.Errorf("csvBridgeConfig: keyColumns: %w", err) + } + p.sourceIDColumns = cols + + case "commentColumns": + cols, err := intSliceToOneBasedCSV(val) + if err != nil { + return nil, fmt.Errorf("csvBridgeConfig: commentColumns: %w", err) + } + p.commentColumns = cols + + case "trimValues": + b, ok := val.(bool) + if !ok { + return nil, fmt.Errorf("csvBridgeConfig: trimValues: expected bool, got %T", val) + } + if b { + p.trimMode = 2 // TRIM_ALL — trim qualified + non-qualified + p.trimLeading = true + p.trimTrailing = true + } else { + p.trimMode = 0 + p.trimLeading = false + p.trimTrailing = false + } + + case "useCodeFinder": + b, ok := val.(bool) + if !ok { + return nil, fmt.Errorf("csvBridgeConfig: useCodeFinder: expected bool, got %T", val) + } + p.useCodeFinder = b + + default: + return nil, fmt.Errorf("csvBridgeConfig: unknown spec key %q", key) + } + } + + return map[string]any{"fprmContent": p.toFprm()}, nil +} + +// csvParams is the in-translator view of the bridge filter's CSV +// Parameters object. Mirrors the public-field names of +// net.sf.okapi.filters.table.csv.Parameters (and its base classes). +type csvParams struct { + fieldDelimiter string + textQualifier string + removeQualifiers bool + escapingMode int + columnNamesLineNum int + valuesStartLineNum int + sendHeaderMode int + sendColumnsMode int + trimMode int + trimLeading bool + trimTrailing bool + preserveWS bool + useCodeFinder bool + sourceColumns string + sourceIDColumns string + commentColumns string +} + +// toFprm renders the params as an Okapi ParametersString `#v1` blob. +// targetColumns is forced empty so the upstream filter does not enter +// multilingual (source/target) mode. +func (p csvParams) toFprm() string { + var b strings.Builder + b.WriteString("#v1\n") + // plaintext base + table base booleans / ints. + fmt.Fprintf(&b, "preserveWS.b=%t\n", p.preserveWS) + fmt.Fprintf(&b, "useCodeFinder.b=%t\n", p.useCodeFinder) + fmt.Fprintf(&b, "trimLeading.b=%t\n", p.trimLeading) + fmt.Fprintf(&b, "trimTrailing.b=%t\n", p.trimTrailing) + fmt.Fprintf(&b, "columnNamesLineNum.i=%d\n", p.columnNamesLineNum) + fmt.Fprintf(&b, "valuesStartLineNum.i=%d\n", p.valuesStartLineNum) + fmt.Fprintf(&b, "sendHeaderMode.i=%d\n", p.sendHeaderMode) + fmt.Fprintf(&b, "sendColumnsMode.i=%d\n", p.sendColumnsMode) + fmt.Fprintf(&b, "trimMode.i=%d\n", p.trimMode) + fmt.Fprintf(&b, "sourceColumns=%s\n", p.sourceColumns) + fmt.Fprintf(&b, "sourceIdColumns=%s\n", p.sourceIDColumns) + fmt.Fprintf(&b, "commentColumns=%s\n", p.commentColumns) + b.WriteString("targetColumns=\n") + // csv subclass fields. + fmt.Fprintf(&b, "fieldDelimiter=%s\n", p.fieldDelimiter) + fmt.Fprintf(&b, "textQualifier=%s\n", p.textQualifier) + fmt.Fprintf(&b, "removeQualifiers.b=%t\n", p.removeQualifiers) + fmt.Fprintf(&b, "escapingMode.i=%d\n", p.escapingMode) + b.WriteString("parametersClass=net.sf.okapi.filters.table.csv.Parameters\n") + return b.String() +} + +// intSliceToOneBasedCSV converts a 0-based int column list (from the +// spec) into a 1-based comma-separated string (Okapi's +// sourceColumns / sourceIdColumns / commentColumns shape). +func intSliceToOneBasedCSV(v any) (string, error) { + var ints []int + switch x := v.(type) { + case []int: + ints = x + case []any: + ints = make([]int, len(x)) + for i, item := range x { + n, err := asInt(item, "column index") + if err != nil { + return "", err + } + ints[i] = n + } + default: + return "", fmt.Errorf("expected int slice, got %T", v) + } + parts := make([]string, len(ints)) + for i, n := range ints { + parts[i] = strconv.Itoa(n + 1) + } + return strings.Join(parts, ","), nil +} + +func asInt(v any, label string) (int, error) { + switch x := v.(type) { + case int: + return x, nil + case int64: + return int(x), nil + case float64: + return int(x), nil + default: + return 0, fmt.Errorf("%s: expected int, got %T", label, v) + } +} diff --git a/parity/formats/csv_spec_test.go b/parity/formats/csv_spec_test.go new file mode 100644 index 0000000..44d0471 --- /dev/null +++ b/parity/formats/csv_spec_test.go @@ -0,0 +1,30 @@ +//go:build parity + +package formats + +import ( + "testing" + + "github.com/neokapi/neokapi/core/format" + "github.com/neokapi/neokapi/core/formats/csv" + parityspec "github.com/neokapi/okapi-bridge/parity/spec" +) + +// TestParityCsvSpec drives the CSV spec.yaml through the bridge AND +// the native reader, validating both against the spec contract and +// against each other. The same spec file drives the always-on native +// test (core/formats/csv/spec_test.go), so a single source of truth +// describes CSV behavior across implementations. +func TestParityCsvSpec(t *testing.T) { + specPath := nativeSpecPath(t, "csv") + s, err := parityspec.LoadSpec(specPath) + if err != nil { + t.Fatalf("load spec %s: %v", specPath, err) + } + r := &parityspec.ParityRunner{ + Spec: s, + NewReader: func(_ string) format.DataFormatReader { return csv.NewReader() }, + BridgeConfig: csvBridgeConfig, + } + r.Run(t) +} diff --git a/parity/formats/epub_spec_test.go b/parity/formats/epub_spec_test.go new file mode 100644 index 0000000..8a8a0b8 --- /dev/null +++ b/parity/formats/epub_spec_test.go @@ -0,0 +1,32 @@ +//go:build parity + +package formats + +import ( + "testing" + + "github.com/neokapi/neokapi/core/format" + "github.com/neokapi/neokapi/core/formats/epub" + parityspec "github.com/neokapi/okapi-bridge/parity/spec" +) + +// TestParityEpubSpec drives the EPUB spec.yaml through the parity +// runner. Every example carries `expected_fail:` because the +// okapi-bridge does NOT ship the okf_epub filter — the bridge v2 +// manifest has no `okf_epub` entry, so dispatch errors with +// "FilterClass: okf_epub not found". Native still satisfies the +// assertions; the runner records each example as expected_fail rather +// than green parity. Drop the `expected_fail:` blocks once the bridge +// adds EPUB coverage. +func TestParityEpubSpec(t *testing.T) { + specPath := nativeSpecPath(t, "epub") + s, err := parityspec.LoadSpec(specPath) + if err != nil { + t.Fatalf("load spec %s: %v", specPath, err) + } + r := &parityspec.ParityRunner{ + Spec: s, + NewReader: func(_ string) format.DataFormatReader { return epub.NewReader() }, + } + r.Run(t) +} diff --git a/parity/formats/fixtures_html_generated.go b/parity/formats/fixtures_html_generated.go new file mode 100644 index 0000000..2806c2f --- /dev/null +++ b/parity/formats/fixtures_html_generated.go @@ -0,0 +1,184 @@ +//go:build parity + +// Code generated by cmd/okapi-test-scan. DO NOT EDIT. + +package formats + +// GeneratedHtmlConfigurationSupportTestInputs holds 20 auto-extracted @Test fixtures from HtmlConfigurationSupportTest. +var GeneratedHtmlConfigurationSupportTestInputs = []FormatInput{ + {Name: "gen-test_collapse_whitespace", Content: ttext(`

t1 +t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_collapse_whitespace", Informational: true}, + {Name: "gen-test_PRESERVE_WHITESPACE", Content: ttext(`

t1 +t2

 t3  
+t4  
`), OkapiTest: "HtmlConfigurationSupportTest#test_PRESERVE_WHITESPACE", Informational: true}, + {Name: "gen-test_GLOBAL_PRESERVE_WHITESPACE", Content: ttext(`

t1 +t2

 t3  
+t4  
`), OkapiTest: "HtmlConfigurationSupportTest#test_GLOBAL_PRESERVE_WHITESPACE", Informational: true}, + {Name: "gen-test_EXCLUDE", Content: ttext(`
t1

t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_EXCLUDE", Informational: true}, + {Name: "gen-test_INCLUDE", Content: ttext(`
t1t2t3

t4

`), OkapiTest: "HtmlConfigurationSupportTest#test_INCLUDE", Informational: true}, + {Name: "gen-test_EXCLUDE_with_positive_condition", Content: ttext(`
t1

t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_EXCLUDE_with_positive_condition", Informational: true}, + {Name: "gen-test_INLINE_with_positive_condition", Content: ttext(`

t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_INLINE_with_positive_condition", Informational: true}, + {Name: "gen-test_INLINE_without_condition", Content: ttext(`t2test`), OkapiTest: "HtmlConfigurationSupportTest#test_INLINE_without_condition", Informational: true}, + {Name: "gen-test_INLINE_with_negative_condition", Content: ttext(`

t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_INLINE_with_negative_condition", Informational: true}, + {Name: "gen-test_EXCLUDE_with_negative_condition", Content: ttext(`
t1

t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_EXCLUDE_with_negative_condition", Informational: true}, + {Name: "gen-test_ATTRIBUTE_ID", Content: ttext(`

t1

t2
`), OkapiTest: "HtmlConfigurationSupportTest#test_ATTRIBUTE_ID", Informational: true}, + {Name: "gen-test_idAttributes", Content: ttext(`

t1

t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_idAttributes", Informational: true}, + {Name: "gen-test_MATCHES", Content: ttext(`

t1

t2

`), OkapiTest: "HtmlConfigurationSupportTest#test_MATCHES", Informational: true}, + {Name: "gen-test_allElementsExcept", Content: ttext(`t2t4t6`), OkapiTest: "HtmlConfigurationSupportTest#test_allElementsExcept", Informational: true}, + {Name: "gen-test_onlyTheseElements", Content: ttext(`t2t4t6`), OkapiTest: "HtmlConfigurationSupportTest#test_onlyTheseElements", Informational: true}, + {Name: "gen-test_translatableAttributes_withCondition", Content: ttext(`

t2

t4

`), OkapiTest: "HtmlConfigurationSupportTest#test_translatableAttributes_withCondition", Informational: true}, + {Name: "gen-test_translatableAttributes_with2ORConditions", Content: ttext(`

t2

t4

`), OkapiTest: "HtmlConfigurationSupportTest#test_translatableAttributes_with2ORConditions", Informational: true}, + {Name: "gen-test_ATTRIBUTE_WRITABLE", Content: ttext(`

t1

t2
`), OkapiTest: "HtmlConfigurationSupportTest#test_ATTRIBUTE_WRITABLE", Informational: true}, + {Name: "gen-test_regex_ATTRIBUTE_WRITABLE", Content: ttext(`

t1

t2
`), OkapiTest: "HtmlConfigurationSupportTest#test_regex_ATTRIBUTE_WRITABLE", Informational: true}, + {Name: "gen-quoteMode", Content: ttext(`" '`), OkapiTest: "HtmlConfigurationSupportTest#quoteMode", Informational: true}, +} + +// GeneratedHtmlEventTestInputs holds 20 auto-extracted @Test fixtures from HtmlEventTest. +var GeneratedHtmlEventTestInputs = []FormatInput{ + {Name: "gen-testHtmlKeywordsNotExtracted", Content: ttext(``), OkapiTest: "HtmlEventTest#testHtmlKeywordsNotExtracted", Informational: true}, + {Name: "gen-baseTag", Content: ttext(``), OkapiTest: "HtmlEventTest#baseTag", Informational: true}, + {Name: "gen-testMetaTagContent", Content: ttext(``), OkapiTest: "HtmlEventTest#testMetaTagContent", Informational: true}, + {Name: "gen-testPWithAttributes", Content: ttext(`

Text of p

`), OkapiTest: "HtmlEventTest#testPWithAttributes", Informational: true}, + {Name: "gen-testLang", Content: ttext(``), OkapiTest: "HtmlEventTest#testLang", Informational: true}, + {Name: "gen-testIdOnP", Content: ttext(`

`), OkapiTest: "HtmlEventTest#testIdOnP", Informational: true}, + {Name: "gen-testXmlLang", Content: ttext(``), OkapiTest: "HtmlEventTest#testXmlLang", Informational: true}, + {Name: "gen-testComplexEmptyElement", Content: ttext(``), OkapiTest: "HtmlEventTest#testComplexEmptyElement", Informational: true}, + {Name: "gen-testPWithInlines", Content: ttext(`

Before bold after.

`), OkapiTest: "HtmlEventTest#testPWithInlines", Informational: true}, + {Name: "gen-testPWithInlineAnchorAndAmpersand", Content: ttext(`

Before after.

`), OkapiTest: "HtmlEventTest#testPWithInlineAnchorAndAmpersand", Informational: true}, + {Name: "gen-testPWithComment", Content: ttext(`

Before after.

`), OkapiTest: "HtmlEventTest#testPWithComment", Informational: true}, + {Name: "gen-testPWithProcessingInstruction", Content: ttext(`

Before after.

`), OkapiTest: "HtmlEventTest#testPWithProcessingInstruction", Informational: true}, + {Name: "gen-testMETATagWithLanguage", Content: ttext(``), OkapiTest: "HtmlEventTest#testMETATagWithLanguage", Informational: true}, + {Name: "gen-testMETATagWithEncoding", Content: ttext(``), OkapiTest: "HtmlEventTest#testMETATagWithEncoding", Informational: true}, + {Name: "gen-testMetaWithCharsetAttribute", Content: ttext(``), OkapiTest: "HtmlEventTest#testMetaWithCharsetAttribute", Informational: true}, + {Name: "gen-testPWithInlines2", Content: ttext(`

Before bold text after.

`), OkapiTest: "HtmlEventTest#testPWithInlines2", Informational: true}, + {Name: "gen-testTableGroups", Content: ttext(`
text
`), OkapiTest: "HtmlEventTest#testTableGroups", Informational: true}, + {Name: "gen-testGroupInPara", Content: ttext(`

Text before list:

  • Text of item 1
  • Text of item 2
and text after the list.

`), OkapiTest: "HtmlEventTest#testGroupInPara", Informational: true}, + {Name: "gen-testPropertyInEmptyParagraph", Content: ttext(`

+`), OkapiTest: "HtmlEventTest#testPropertyInEmptyParagraph", Informational: true}, + {Name: "gen-testPreserveWhitespace", Content: ttext(`
	whitespace is preserved
`), OkapiTest: "HtmlEventTest#testPreserveWhitespace", Informational: true}, +} + +// GeneratedHtmlSnippetsTestInputs holds 93 auto-extracted @Test fixtures from HtmlSnippetsTest. +var GeneratedHtmlSnippetsTestInputs = []FormatInput{ + {Name: "gen-testMETA_Issue_1098", Content: ttext(` + + + + +`), OkapiTest: "HtmlSnippetsTest#testMETA_Issue_1098", Informational: true}, + {Name: "gen-testMultipleMETA", Content: ttext(`

Text8

`), OkapiTest: "HtmlSnippetsTest#testMultipleMETA", Informational: true}, + {Name: "gen-testHref", Content: ttext(`see
yahoo`), OkapiTest: "HtmlSnippetsTest#testHref", Informational: true}, + {Name: "gen-testButton", Content: ttext(`

[sp][sp][sp][sp]

`), OkapiTest: "HtmlSnippetsTest#testButton", Informational: true}, + {Name: "gen-testCleanupHtmlOption", Content: ttext(`
`), OkapiTest: "HtmlSnippetsTest#testCleanupHtmlOption", Informational: true}, + {Name: "gen-testInlineCodesStorage", Content: ttext(`

Before bold after.

`), OkapiTest: "HtmlSnippetsTest#testInlineCodesStorage", Informational: true}, + {Name: "gen-testTitleInP", Content: ttext(`

Text2

`), OkapiTest: "HtmlSnippetsTest#testTitleInP", Informational: true}, + {Name: "gen-testAltInImg", Content: ttext(`Text1Text2.`), OkapiTest: "HtmlSnippetsTest#testAltInImg", Informational: true}, + {Name: "gen-imgStartTagOnlyHandledWithWellFormedConfiguration", Content: ttext(`

`), OkapiTest: "HtmlSnippetsTest#imgStartTagOnlyHandledWithWellFormedConfiguration", Informational: true}, + {Name: "gen-paramStartTagOnlyHandledWithWellFormedConfiguration", Content: ttext(`

`), OkapiTest: "HtmlSnippetsTest#paramStartTagOnlyHandledWithWellFormedConfiguration", Informational: true}, + {Name: "gen-areaStartTagOnlyHandledWithWellFormedConfiguration", Content: ttext(`

Area 1

`), OkapiTest: "HtmlSnippetsTest#areaStartTagOnlyHandledWithWellFormedConfiguration", Informational: true}, + {Name: "gen-testNoExtractValueInInput", Content: ttext(`.`), OkapiTest: "HtmlSnippetsTest#testNoExtractValueInInput", Informational: true}, + {Name: "gen-testExtractValueInInput", Content: ttext(`.`), OkapiTest: "HtmlSnippetsTest#testExtractValueInInput", Informational: true}, + {Name: "gen-testLabelInOption", Content: ttext(`Text1