diff --git a/README.md b/README.md index 5670134..889095e 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,13 @@ Every default provider fails closed. No adapter calls the trusted in-process ren Context contains bounded immutable JSON-like data, including the authorized selected `record`. A script may add context objects but cannot replace that reserved original record. Credentials, QQQ sessions, raw host objects and lazy entities are rejected. Source-record permissions are checked before context preparation, before rendering, before intake and before printing. Asset access is rechecked by the isolated renderer adapter, and retained-version access by retained/printing adapters. +### What a host must configure for label previews + +The "Label preview" widget and the "Preview layout" action arrive with the QBit's registration and need no host code. Two things are the host's: + +- **Send `Cache-Control: no-store` for widget responses.** The widget's answer holds the picture of a label, which in the action can show the values of a record. QQQ's widget route sets no `Cache-Control` header and a widget renderer cannot set response headers, so the host, or the proxy in front of it, must send `no-store` for widget responses. Nothing in this QBit can. +- **Know what your request log holds.** QQQ's widget route writes the parameters of a widget request that fails or takes longer than one second to the application log. For this widget those parameters hold no record identity and no sample name: from a template's record screen they are the table name and the template's identity, and from the action they are the identifier of the run (`processUUID`) with the plain process values a client adds to it (`previewTemplateId` or `previewRevisionId`, `sourceTable`, and whatever the client itself sent to start the run, which for the standard clients is its selection of the template or revision row). The chosen record, sample, stock and resolution stay in the state of the run and are not sent; this holds for a client that sends plain process values only, as the standard clients do. `DocumentPreviewService` itself logs one line per preview: the actor, the template or revision identity, the kind of data, the resolution, the outcome and the diagnostic codes. + ## Known boundaries before release Native table/record processes and source-editor forms are implemented through standard QQQ metadata. Results include record navigation and download/preview metadata consumed by generic frontend components. Authenticated delivery requires verified bytes and a completed owner receipt before preview. There is no visual designer. Final integrated browser workflow acceptance, a qualified worker deployment, production storage/restore and printing integration remain release work. Service-only fields cannot be edited through unrestricted generic CRUD. Domain actions use validated services. diff --git a/bridges/renderer/README.md b/bridges/renderer/README.md index a644e20..ecf204a 100644 --- a/bridges/renderer/README.md +++ b/bridges/renderer/README.md @@ -36,8 +36,10 @@ nothing of the scene or the record. - **Preview.** `preview(PreviewRequest)` is the print path stopped before retention: the same job with collected diagnostics, the same integrity checks and output policy, and a page decoded by `ZplRaster` from the accepted label bytes (`PreviewPage` is a one-bit PNG, one pixel for each dot). Refusals come back as diagnostics with - `printable: false`, never as a label: a refused scene has no output, and its page shows only the hatched - boxes of the refused elements. A page of more than sixteen million dots is `PREVIEW_TOO_LARGE`. PDF previews + `printable: false`, never as a label: a refused scene has no output, and its page shows only the outlined, + crossed boxes of the refused elements. A page of more than sixteen million dots is `PREVIEW_TOO_LARGE`, and so + is a label (or the boxes of a refused scene) that would paint more than sixteen times its page's dots; such a + label still prints, and the foundation's widget says so. PDF previews are not provided. The bridge writes and logs nothing for a preview. Three transports share the same bridge, asset, wire and output policy: diff --git a/bridges/renderer/pom.xml b/bridges/renderer/pom.xml index dca1b62..30633ca 100644 --- a/bridges/renderer/pom.xml +++ b/bridges/renderer/pom.xml @@ -14,6 +14,8 @@ com.fasterxml.jackson.corejackson-databind2.22.4 org.junit.jupiterjunit-jupiter6.0.1test + + com.kingsrook.qqqqqq-backend-module-rdbms${qqq.version}test diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java index 067cff7..f377852 100644 --- a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java @@ -47,6 +47,13 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; ** refused as `RENDERER_TOO_OLD` when the worker is older: the host asks the ** worker its version with a fixed, empty job before it sends such a scene, and ** an answer from an older worker to such a job is refused whatever it says. + ** + ** The worker's answer to that question is remembered, so asking first is not + ** a promise about every job. After a renderer image is rolled back, ONE such + ** job can reach the old (0.1.0) renderer, its scene and its record's values + ** with it, before the bridge knows. That job is refused as + ** `RENDERER_TOO_OLD`, and a job that ends in `RENDERER_TOO_OLD`, however it + ** came to, ends what was remembered: the next such job asks the version first. *******************************************************************************/ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRenderer { @@ -68,7 +75,11 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen //////////////////////////////////////////////////////////////////////////////////////////////// // whether the last verified answer came from a renderer that draws version 2 scenes. It only // - // saves the version question; every answer is still held to the version rule // + // saves the version question; every answer is still held to the version rule. It is what // + // the bridge last saw, not what the renderer is now: after a renderer image is rolled back, // + // ONE job that only 0.2.0 can do is sent to the old (0.1.0) renderer on the strength of it. // + // That job is refused as RENDERER_TOO_OLD, and every job that ends in RENDERER_TOO_OLD // + // clears this, so the window is that one job and the next asks the version again // //////////////////////////////////////////////////////////////////////////////////////////////// private volatile boolean structured; @@ -231,8 +242,12 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen ** ** A scene with a refused element has no label at all (the worker sends ** none), so there is nothing a host could print by mistake. Its page shows - ** only where the refused elements are, hatched, from the boxes in their - ** diagnostics; when no diagnostic has a box there is no page. + ** only where the refused elements are, outlined and crossed, from the boxes + ** in their diagnostics; when no diagnostic has a box there is no page. + ** + ** Drawing is bounded like the page: a label, or the boxes of a refused + ** scene, that would paint more than sixteen times the page's dots is + ** `PREVIEW_TOO_LARGE`, a result like any other named refusal. ***************************************************************************/ @Override @SuppressWarnings("try") // The admission slot is held for its scope only; its value is never read. @@ -271,7 +286,15 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen return (refused(printed, answer.diagnostics())); } byte[] bytes = output(answer, request.format(), maximum, printed); - ZplRaster page = ZplRaster.page(bytes, printed); + ZplRaster page; + try + { + page = ZplRaster.page(bytes, printed); + } + catch(IllegalArgumentException undrawn) + { + throw (painted(undrawn)); + } List diagnostics = new ArrayList<>(answer.diagnostics()); if(!page.exact()) { @@ -297,12 +320,15 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen ** host from the diagnostics' boxes, which are in dots from the top left of ** the printable area: the stock less its margins, placed as the renderer ** places it (the left and top margins rounded up to whole dots and moved - ** back only as far as keeps the area on the page). + ** back only as far as keeps the area on the page). Boxes that would paint + ** more than a page may be painted with are not drawn at all: there is no + ** page, and `PREVIEW_TOO_LARGE` joins the diagnostics. ***************************************************************************/ private static GenerationPorts.Preview refused(GenerationPorts.Stock printed, List diagnostics) throws Exception { List> boxes = diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Diagnostic.Severity.ERROR && diagnostic.box() != null).map(Diagnostic::box).toList(); List pages = List.of(); + List findings = diagnostics; if(!boxes.isEmpty()) { int dpi = printed.dpi(); @@ -310,10 +336,38 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen int height = dots(printed.heightMicrons(), dpi, 12700); int left = Math.min(dots(printed.leftMicrons(), dpi, 25399), width - dots(printed.widthMicrons() - printed.leftMicrons() - printed.rightMicrons(), dpi, 0)); int top = Math.min(dots(printed.topMicrons(), dpi, 25399), height - dots(printed.heightMicrons() - printed.topMicrons() - printed.bottomMicrons(), dpi, 0)); - ZplRaster page = ZplRaster.refused(width, height, left, top, boxes); - pages = List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height())); + try + { + ZplRaster page = ZplRaster.refused(width, height, left, top, boxes); + pages = List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height())); + } + catch(IllegalArgumentException undrawn) + { + if(!(painted(undrawn) instanceof GenerationPorts.RenderException large)) + { + throw (undrawn); + } + findings = new ArrayList<>(diagnostics); + findings.add(large.diagnostic()); + } } - return (new GenerationPorts.Preview(pages, false, false, diagnostics, 0L, RendererJson.hash(new byte[0]))); + return (new GenerationPorts.Preview(pages, false, false, findings, 0L, RendererJson.hash(new byte[0]))); + } + + /*************************************************************************** + ** What `ZplRaster` would not draw. A page that would be painted with more + ** than `ZplRaster.MAXIMUM_COATS` times its dots becomes the named refusal, + ** with the limit as a percentage of the page as the renderer states its + ** own; anything else it refuses stays what it was, an opaque failure. + ***************************************************************************/ + private static Exception painted(IllegalArgumentException undrawn) + { + if(!GenerationPorts.FailureCode.PREVIEW_TOO_LARGE.name().equals(undrawn.getMessage())) + { + return (undrawn); + } + return (new GenerationPorts.RenderException(GenerationPorts.FailureCode.PREVIEW_TOO_LARGE, new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, null, + Map.of("paintedPercent", 100 * ZplRaster.MAXIMUM_COATS)))); } /*************************************************************************** @@ -377,6 +431,14 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen ** target is sent only for a version 2 scene, and collected diagnostics only ** for a preview. A version 1 scene or a PDF asked for another resolution than ** its stock's is refused here, because a renderer would ignore the target. + ** + ** A job only renderer 0.2.0 can do is sent without the version question when + ** the last verified answer came from 0.2.0. If the renderer was replaced by + ** an older one since, this one job reaches it and is refused as + ** `RENDERER_TOO_OLD` whatever it answers. Any job that ends in + ** `RENDERER_TOO_OLD` (the worker is older, or a worker names the refusal + ** itself, alone or among collected diagnostics) forgets what was remembered, + ** so the next such job asks the version before it sends anything. *******************************************************************************/ private WorkerAnswer invoke(String id, String operation, GenerationPorts.Format format, GenerationPorts.Revision revision, Map context, List> resolved, int maximum, Integer targetDpi, boolean collect) throws Exception @@ -424,13 +486,32 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen int maximumFrame = (int) Math.min(room, 4L * ((config.maximumBytes() + 2) / 3) + ENVELOPE_BYTES); WorkerAnswer answer = WorkerAnswer.read(exchange.invoke(id, input, maximumFrame), maximumFrame, id, facts); structured = answer.version().structured(); - return (answer.settle(operation, collect, current)); + try + { + answer.settle(operation, collect, current); + } + catch(GenerationPorts.RenderException refusal) + { + if(refusal.code() == GenerationPorts.FailureCode.RENDERER_TOO_OLD) + { + structured = false; + } + throw (refusal); + } + if(answer.diagnostics().stream().anyMatch(diagnostic -> GenerationPorts.FailureCode.RENDERER_TOO_OLD.name().equals(diagnostic.code()))) + { + structured = false; + } + return (answer); } /*************************************************************************** ** Ask the worker its version before a job only renderer 0.2.0 can do, so - ** that nothing of a version 2 scene or its record reaches a renderer that - ** cannot draw it. The question is a constant. + ** that a renderer the bridge does not know to be 0.2.0 is sent nothing of a + ** version 2 scene or its record. The question is a constant. It is asked + ** when nothing is remembered, not before every job: after a renderer image + ** is rolled back, ONE job can reach an old (0.1.0) renderer unasked and is + ** then refused as `RENDERER_TOO_OLD`; the job after it asks here first. ***************************************************************************/ private void probe() throws Exception { diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java index 0db6ba5..9abfd2c 100644 --- a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java @@ -41,7 +41,14 @@ import com.kingsrook.qbits.documentgeneration.GenerationPorts; ** large to draw is refused by name. Graphic fields (`^GFA`) and boxes (`^GB`) ** are painted exactly. Printer font text (`^A0N`), which only a version 1 ** scene emits, cannot be drawn without the printer's font: its estimated area - ** is hatched and the page says it is not exact. + ** is outlined and crossed and the page says it is not exact. + ** + ** The work is bounded as well as the page. Every field is counted before it + ** is painted, an overlap each time, and the field that would take the page + ** past sixteen times its dots is refused as `PREVIEW_TOO_LARGE`. Nothing is + ** painted by testing every dot of an area: a box is at most four solid bars + ** and a placeholder is an outline and two diagonals, so a label of thousands + ** of fields costs what it inks, never fields times the page. ** ** No image or font is decoded here. The only image work is writing this ** class's own one-bit bitmap as a PNG. @@ -53,7 +60,16 @@ final class ZplRaster /////////////////////////////////////////////////////////////////////////////////////////////// static final long MAXIMUM_DOTS = 16_000_000L; - private static final int HATCH = 6; + //////////////////////////////////////////////////////////////////////////////////////////////// + // what one page may be painted with, in pages: the dots its fields paint, an overlap counted // + // each time, may total sixteen times the page. Sixteen is the renderer's own limit for what // + // a version 2 scene may paint, and the label of such a scene is its one composed canvas, so // + // none comes near it. A version 1 label that painted its page more than sixteen times over // + // would print and not be previewed. The limit holds the 6000 fields the output policy // + // allows to sixteen fills of the page where 6000 were possible // + //////////////////////////////////////////////////////////////////////////////////////////////// + static final int MAXIMUM_COATS = 16; + private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10}; private final int width; @@ -61,6 +77,7 @@ final class ZplRaster private final int stride; private final byte[] ink; private boolean exact = true; + private long painted; @@ -138,9 +155,11 @@ final class ZplRaster /*************************************************************************** ** A page that shows only where elements were refused: each box, given in - ** dots from the printable area's top left as a diagnostic gives it, hatched - ** at its place on the page and cut to the page. It is drawn from numbers - ** alone. No label exists for it, so nothing of it can be printed. + ** dots from the printable area's top left as a diagnostic gives it, outlined + ** and crossed at its place on the page and cut to the page. It is drawn from + ** numbers alone. No label exists for it, so nothing of it can be printed. + ** The boxes are counted like the fields of a label: more of them than the + ** page may be painted with is `PREVIEW_TOO_LARGE`. ***************************************************************************/ static ZplRaster refused(int width, int height, int originX, int originY, List> boxes) { @@ -152,10 +171,7 @@ final class ZplRaster long top = Math.max(0L, (long) originY + box.get(1)); long right = Math.min(width, (long) originX + box.get(0) + box.get(2)); long bottom = Math.min(height, (long) originY + box.get(1) + box.get(3)); - if(left < right && top < bottom) - { - page.hatch((int) left, (int) top, (int) (right - left), (int) (bottom - top)); - } + page.hatch((int) left, (int) top, (int) Math.max(0L, right - left), (int) Math.max(0L, bottom - top)); } return (page); } @@ -281,7 +297,8 @@ final class ZplRaster /*************************************************************************** ** Printer font text: the area the policy holds it to (one cell of the font - ** width for each character, one line of the font height), hatched. + ** width for each character, one line of the font height), outlined and + ** crossed. ***************************************************************************/ private void text(Reader reader, int x, int y) { @@ -324,21 +341,70 @@ final class ZplRaster { throw new IllegalArgumentException("RASTER_FIELD"); } - for(int row = 0; row < boxHeight; row++) + border(x, y, boxWidth, boxHeight, thickness); + } + + + + /*************************************************************************** + ** A border inside an area that is known to be on the page: the whole area + ** when the border meets itself, else four bars that do not overlap (the top + ** and bottom across the whole width, the sides between them). + ***************************************************************************/ + private void border(int x, int y, int areaWidth, int areaHeight, int thickness) + { + if(2L * thickness >= Math.min(areaWidth, areaHeight)) { - boolean border = row < thickness || row >= boxHeight - thickness; - for(int column = 0; column < boxWidth; column++) + fill(x, y, areaWidth, areaHeight); + return; + } + fill(x, y, areaWidth, thickness); + fill(x, y + areaHeight - thickness, areaWidth, thickness); + fill(x, y + thickness, thickness, areaHeight - 2 * thickness); + fill(x + areaWidth - thickness, y + thickness, thickness, areaHeight - 2 * thickness); + } + + + + /*************************************************************************** + ** Ink a solid rectangle that is known to be on the page, whole bytes at a + ** time: the first and last byte of each row are masked to the rectangle. + ***************************************************************************/ + private void fill(int x, int y, int areaWidth, int areaHeight) + { + admit((long) areaWidth * areaHeight); + int first = x / 8; + int last = (x + areaWidth - 1) / 8; + int head = 0xFF >> x % 8; + int tail = 0xFF << (7 - (x + areaWidth - 1) % 8); + for(int row = y; row < y + areaHeight; row++) + { + for(int index = first; index <= last; index++) { - if(border || column < thickness || column >= boxWidth - thickness) - { - set(x + column, y + row); - } + ink[row * stride + index] |= (byte) ((index == first ? head : 0xFF) & (index == last ? tail : 0xFF)); } } } + /*************************************************************************** + ** Count the dots a field is about to paint, or refuse the field that would + ** take the page past `MAXIMUM_COATS` times its dots. Every painter counts + ** before it paints, so the work a label can cause is bounded by its page + ** and not by the number of its fields. + ***************************************************************************/ + private void admit(long dots) + { + if(painted + dots > MAXIMUM_COATS * (long) width * height) + { + throw new IllegalArgumentException("PREVIEW_TOO_LARGE"); + } + painted += dots; + } + + + /*************************************************************************** ** An uncompressed graphic field: rows of whole bytes, most significant bit ** first. Bits that pad the last byte of a row past the page edge must be @@ -358,6 +424,7 @@ final class ZplRaster throw new IllegalArgumentException("RASTER_FIELD"); } inside(x, y, 1, total / rowBytes); + admit(8L * total); for(int index = 0; index < total; index++) { int value = reader.hex() * 16 + reader.hex(); @@ -393,20 +460,28 @@ final class ZplRaster /*************************************************************************** - ** An outline with diagonal lines across it: plainly a placeholder, never - ** mistaken for ink the label holds. + ** An outline with its two diagonals across it: plainly a placeholder, never + ** mistaken for ink the label holds. It costs its perimeter and the length + ** of its diagonals, not its area: each diagonal has one dot for each step + ** along the longer side. An area of no width or no height has no dots and + ** nothing is drawn for it: its outline and diagonals would otherwise be + ** placed one dot outside it. ***************************************************************************/ private void hatch(int x, int y, int areaWidth, int areaHeight) { - for(int row = 0; row < areaHeight; row++) + if(areaWidth < 1 || areaHeight < 1) { - for(int column = 0; column < areaWidth; column++) - { - if(row == 0 || column == 0 || row == areaHeight - 1 || column == areaWidth - 1 || (row + column) % HATCH == 0) - { - set(x + column, y + row); - } - } + return; + } + border(x, y, areaWidth, areaHeight, 1); + int steps = Math.max(areaWidth, areaHeight); + admit(2L * steps); + for(int step = 0; step < steps; step++) + { + int column = (int) ((long) step * (areaWidth - 1) / Math.max(1, steps - 1)); + int row = (int) ((long) step * (areaHeight - 1) / Math.max(1, steps - 1)); + set(x + column, y + row); + set(x + areaWidth - 1 - column, y + row); } } diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java index eb50152..d8b8798 100644 --- a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java @@ -29,6 +29,7 @@ import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import com.fasterxml.jackson.databind.JsonNode; import com.kingsrook.qbits.documentgeneration.GenerationPorts; @@ -49,7 +50,7 @@ final class FakeWorker implements IsolatedRendererBridge.Exchange final List jobs = new ArrayList<>(); final List questions = new ArrayList<>(); final List frameLimits = new ArrayList<>(); - private final String version; + private final Supplier version; private final Answer answer; @@ -72,6 +73,17 @@ final class FakeWorker implements IsolatedRendererBridge.Exchange ** A worker of a version that answers every job the same way. ***************************************************************************/ FakeWorker(String version, Answer answer) + { + this(() -> version, answer); + } + + + + /*************************************************************************** + ** A worker whose image can be replaced while a bridge uses it: it answers + ** the version question as the renderer that is running at that moment. + ***************************************************************************/ + FakeWorker(Supplier version, Answer answer) { this.version = version; this.answer = answer; @@ -90,7 +102,7 @@ final class FakeWorker implements IsolatedRendererBridge.Exchange if(IsolatedRendererBridge.PROBE_SCENE.equals(job.path("source").textValue())) { questions.add(job); - return (frame(validated(requestId, version))); + return (frame(validated(requestId, version.get()))); } jobs.add(job); frameLimits.add(maximumOutput); diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/LayoutPreviewServiceTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/LayoutPreviewServiceTest.java new file mode 100644 index 0000000..75a9c0e --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/LayoutPreviewServiceTest.java @@ -0,0 +1,218 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration.renderer; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kingsrook.qbits.documentgeneration.DocumentDefinitionService; +import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitConfig; +import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitProducer; +import com.kingsrook.qbits.documentgeneration.DocumentPreviewService; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/******************************************************************************* + ** The layout preview from the module's own service down to this bridge: what a + ** person sees is decoded from exactly the bytes the print path would validate + ** for the same template, data and resolution. + *******************************************************************************/ +class LayoutPreviewServiceTest +{ + @TempDir + Path directory; + + private final List previewed = new ArrayList<>(); + private DocumentGenerationQBitConfig config; + private IsolatedRendererBridge bridge; + private FakeWorker worker; + private Integer template; + + + + /*************************************************************************** + ** The QBit on a memory backend with this bridge as its renderer, and one + ** person who may author templates. + ***************************************************************************/ + @BeforeEach + void setup() throws Exception + { + MemoryRecordStore.getInstance().reset(); + QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.addTable(new QTableMetaData().withName("orders").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER))); + config = new DocumentGenerationQBitConfig().withBackendName("memory").withSourceTables(List.of("orders")).withMaximumOutputBytes(1024 * 1024) + .withRenderer(new GenerationPorts.IsolatedRenderer() + { + /*************************************************************************** + ** Publication is not part of this test. + ***************************************************************************/ + public void validate(String actor, GenerationPorts.Revision revision) + { + } + + /*************************************************************************** + ** The print path of the bridge. + ***************************************************************************/ + public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException + { + return (bridge.render(request)); + } + + /*************************************************************************** + ** The preview of the bridge, remembering the job the service built. + ***************************************************************************/ + @Override + public GenerationPorts.Preview preview(GenerationPorts.PreviewRequest request) throws QException + { + previewed.add(request); + return (bridge.preview(request)); + } + }); + new DocumentGenerationQBitProducer().withQBitConfig(config).produce(instance); + QContext.init(instance, new QSession().withUser(new QUser().withIdReference("author-1")).withPermissions("document-generation.view", "document-generation.configure")); + } + + + + /*************************************************************************** + ** Release the context and the in-memory records. + ***************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + MemoryRecordStore.getInstance().reset(); + } + + + + /*************************************************************************** + ** A draft with this scene on this stock. + ***************************************************************************/ + private void draft(String scene, GenerationPorts.Stock stock) throws Exception + { + DocumentDefinitionService definitions = new DocumentDefinitionService(config); + Integer type = definitions.createType("label"); + Integer stockId = definitions.createStock("stock", stock); + definitions.bind(type, "orders", true, true); + template = definitions.createTemplate("label", type, stockId, "orders"); + definitions.saveDraft(template, stockId, new DocumentDefinitionService.Draft(null, scene, null, List.of())); + } + + + + /*************************************************************************** + ** At each resolution the preview the service returns for a version 2 scene + ** with its sample data has the size and hash of exactly the bytes the print + ** path returns for the same revision, context and render target, and the + ** worker received the same job both times. Nothing is written to disk. + ***************************************************************************/ + @ParameterizedTest + @ValueSource(ints = {203, 300, 600}) + void thePreviewOfAVersionTwoSceneHasTheHashOfTheBytesThePrintPathValidates(int dpi) throws Exception + { + ObjectNode scene = (ObjectNode) RendererJson.JSON.readTree(FakeWorker.scene("margins")); + scene.putObject("samples").putObject("typical").putObject("record").put("name", "A synthetic sample"); + draft(RendererJson.JSON.writeValueAsString(scene), FakeWorker.stock("margins")); + worker = new FakeWorker(FakeWorker.CURRENT, job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("margins", dpi), List.of())); + bridge = FakeWorker.bridge(directory, worker); + + DocumentPreviewService.Result result = new DocumentPreviewService(config).preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, + DocumentPreviewService.Data.SAMPLE, null, null, null, dpi)); + GenerationPorts.PreviewRequest request = previewed.get(0); + GenerationPorts.Content printed = bridge.render(new GenerationPorts.RenderRequest("request-1", request.actorId(), new GenerationPorts.Source("orders", "1"), request.format(), + request.revision(), request.context(), request.maximumBytes(), request.targetDpi())); + + assertTrue(result.preview().printable()); + assertTrue(result.preview().exact()); + assertEquals(dpi, result.dpi()); + assertArrayEquals(FakeWorker.golden("margins", dpi), printed.bytes()); + assertEquals(RendererJson.hash(printed.bytes()), result.preview().outputSha256()); + assertEquals((long) printed.bytes().length, result.preview().outputBytes()); + assertEquals(1, result.preview().pages().size()); + + assertEquals(2, worker.jobs.size()); + ObjectNode previewJob = (ObjectNode) worker.jobs.get(0); + ObjectNode printJob = (ObjectNode) worker.jobs.get(1); + assertEquals("COLLECT", previewJob.remove("diagnostics").textValue()); + previewJob.remove("requestId"); + printJob.remove("requestId"); + assertEquals(printJob, previewJob); + assertEquals("A synthetic sample", previewJob.path("context").path("record").path("name").textValue()); + assertEquals(dpi, previewJob.path("targetDpi").intValue()); + try(Stream files = Files.walk(directory)) + { + assertEquals(List.of(), files.filter(Files::isRegularFile).toList()); + } + } + + + + /*************************************************************************** + ** A version 1 label comes back through the service as a picture that is not + ** exact, with the warning that printer font text is not shown. + ***************************************************************************/ + @Test + void aVersionOneLabelIsNotExactAndSaysWhy() throws Exception + { + byte[] label = BridgeTest.LABEL.getBytes(StandardCharsets.US_ASCII); + draft(FakeWorker.VERSION_ONE_SCENE, BridgeTest.STOCK); + worker = new FakeWorker(FakeWorker.CURRENT, job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, label, List.of())); + bridge = FakeWorker.bridge(directory, worker); + DocumentPreviewService.Result result = new DocumentPreviewService(config).preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, + DocumentPreviewService.Data.SAMPLE, null, null, null, null)); + assertTrue(result.preview().printable()); + assertFalse(result.preview().exact()); + assertEquals(List.of(new Diagnostic("PRINTER_FONT_NOT_SHOWN", Diagnostic.Severity.WARNING, null, null, null, null)), result.preview().diagnostics()); + assertEquals(RendererJson.hash(label), result.preview().outputSha256()); + assertEquals(Map.of(), previewed.get(0).context()); + assertEquals(BridgeTest.STOCK.dpi(), result.dpi()); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java index a82a416..248bf53 100644 --- a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java @@ -27,6 +27,9 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.regex.Matcher; @@ -44,6 +47,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -157,7 +161,7 @@ class PreviewTest ** the preview has no output. Its page is drawn on the host from the boxes of ** the error diagnostics alone, placed from the printable area's corner as ** the renderer places it (the corner of the golden label at each - ** resolution), cut to the page and hatched. + ** resolution), cut to the page, outlined and crossed. ***************************************************************************/ @ParameterizedTest @ValueSource(ints = {203, 300, 600}) @@ -190,8 +194,8 @@ class PreviewTest { for(int x = 0; x < page.getWidth(); x++) { - boolean first = hatched(x - left, y - top, 16, 12); - boolean last = hatched(x, y - top - 30, left + 20, page.getHeight() - top - 30); + boolean first = ZplRasterTest.crossed(x - left, y - top, 16, 12); + boolean last = ZplRasterTest.crossed(x, y - top - 30, left + 20, page.getHeight() - top - 30); assertEquals(first || last, (page.getRGB(x, y) & 0xFFFFFF) == 0, x + ", " + y); } } @@ -201,17 +205,6 @@ class PreviewTest - /*************************************************************************** - ** Whether a dot is ink in a hatched area of this size whose corner is the - ** origin of `column` and `row`. - ***************************************************************************/ - private static boolean hatched(int column, int row, int width, int height) - { - return (column >= 0 && column < width && row >= 0 && row < height && (column == 0 || row == 0 || column == width - 1 || row == height - 1 || (column + row) % 6 == 0)); - } - - - /*************************************************************************** ** A refusal is a result of a preview, not an exception: whatever the print ** path would refuse by name comes back as the diagnostic, not printable, @@ -296,6 +289,69 @@ class PreviewTest + /*************************************************************************** + ** A label the output policy accepts can still hold thousands of boxes that + ** each cover the page. It is not drawn dot by dot while an admission slot is + ** held: a label that would paint more than sixteen times its page comes + ** back at once as `PREVIEW_TOO_LARGE`, a result with the limit and no page. + ** Printing the same label is not changed by it. + ***************************************************************************/ + @Test + void aLabelThatWouldPaintTooMuchIsRefusedAsAResult() throws Exception + { + GenerationPorts.Stock stock = new GenerationPorts.Stock(101600, 152400, 0, 0, 0, 0, 600); + byte[] label = ("^XA^PW2400^LL3600^LH0,0^CI28\n" + "^FO0,0^GB2400,3600,1200^FS\n".repeat(6000) + "^XZ\n").getBytes(StandardCharsets.US_ASCII); + FakeWorker worker = answering(job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, label, List.of())); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, stock), Map.of(), null, MAXIMUM); + GenerationPorts.Preview preview = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> bridge.preview(request)); + assertFalse(preview.printable()); + assertFalse(preview.exact()); + assertEquals(List.of(new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, null, Map.of("paintedPercent", 1600))), preview.diagnostics()); + assertEquals(List.of(), preview.pages()); + assertEquals(0L, preview.outputBytes()); + assertEquals(NOTHING, preview.outputSha256()); + assertEquals(1, worker.jobs.size()); + assertArrayEquals(label, bridge.render(new GenerationPorts.RenderRequest("request-1", "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, request.revision(), Map.of(), + MAXIMUM)).bytes()); + } + + + + /*************************************************************************** + ** The page of a refused scene is bounded too. On a narrow stock of 8 x + ** 15984 dots a box of the whole page costs 63948 dots of outline and + ** diagonals, so 31 such boxes are within sixteen times the page and are + ** drawn; with 32 there is no page, and `PREVIEW_TOO_LARGE` joins the + ** diagnostics the worker sent. + ***************************************************************************/ + @Test + void aRefusedSceneWithTooManyLargeBoxesHasNoPage() throws Exception + { + GenerationPorts.Stock narrow = new GenerationPorts.Stock(1000, 2000000, 0, 0, 0, 0, 203); + var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), narrow), Map.of(), null, MAXIMUM); + Map whole = FakeWorker.diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", "ERROR", "box", List.of(0, 0, 8, 15984)); + Diagnostic read = new Diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", Diagnostic.Severity.ERROR, null, List.of(0, 0, 8, 15984), null, null); + + GenerationPorts.Preview drawn = FakeWorker.bridge(directory, answering(job -> FakeWorker.refused(job.path("requestId").textValue(), Collections.nCopies(31, whole)))).preview(request); + assertEquals(1, drawn.pages().size()); + assertEquals(8, drawn.pages().get(0).widthDots()); + assertEquals(15984, drawn.pages().get(0).heightDots()); + assertEquals(Collections.nCopies(31, read), drawn.diagnostics()); + + GenerationPorts.Preview undrawn = FakeWorker.bridge(directory, answering(job -> FakeWorker.refused(job.path("requestId").textValue(), Collections.nCopies(32, whole)))).preview(request); + List expected = new ArrayList<>(Collections.nCopies(32, read)); + expected.add(new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, null, Map.of("paintedPercent", 1600))); + assertEquals(expected, undrawn.diagnostics()); + assertEquals(List.of(), undrawn.pages()); + assertFalse(undrawn.printable()); + assertFalse(undrawn.exact()); + assertEquals(0L, undrawn.outputBytes()); + assertEquals(NOTHING, undrawn.outputSha256()); + } + + + /*************************************************************************** ** A version 1 label previews with its graphics and boxes exact and each ** printer font field as a placeholder: the page is not exact and says why. diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneAnswerTableTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneAnswerTableTest.java new file mode 100644 index 0000000..240b73a --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneAnswerTableTest.java @@ -0,0 +1,378 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration.renderer; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +/******************************************************************************* + ** What today's bridge does with an answer to a version 1 print job: one row + ** for each answer, every row through `render`. + ** + ** What this is, and what it is not. The answers are synthetic: the fake worker + ** of these tests builds each one in the shape the released renderer + ** (`qqq-renderer-0.1.0`) answers with, or in that shape with one stated + ** change. None was captured from the released renderer. The expected outcome + ** of each row is what the bridge of this branch does with that answer, written + ** down when the table was made. No row was run against the released bridge + ** (tag `v1.1.0-RC.2`), so the table does not show that today's bridge treats + ** an answer as the released one did. It holds today's behaviour still: a + ** change to what the bridge accepts, refuses or rejects fails a row. The one + ** artefact captured at the released tag is the job frame, which + ** `VersionOneJobFrameTest` compares. + ** + ** `render` has three outcomes and no other: the label, a typed refusal with a + ** code of the closed vocabulary, or the one generic rejection, which names + ** nothing. Each row states which. Beside it the row states the name the + ** version rule (`WorkerAnswer`) gives the same frame for the same job, or `-` + ** when the rule passes it; a row that the rule passes and `render` rejects was + ** rejected by the checks on the document that follow the rule (its size and + ** hash, its metadata, the output policy), whose names never leave the bridge. + *******************************************************************************/ +class VersionOneAnswerTableTest +{ + private static final String ACCEPTED = "ACCEPTED"; + private static final String REJECTED = "REJECTED"; + private static final String PASSES = "-"; + private static final String REQUEST_ID = "request-1"; + private static final int MAXIMUM = 4096; + private static final byte[] LABEL = "^XA^PW812^LL1218^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII); + private static final byte[] ELSEWHERE = "^XA^PW406^LL203^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII); + private static final byte[] COMMAND = "^XA^PW812^LL1218^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n~JR".getBytes(StandardCharsets.US_ASCII); + + @TempDir + Path directory; + + + + /*************************************************************************** + ** The frame a worker answers a request with. + ***************************************************************************/ + @FunctionalInterface + interface Frame + { + /************************************************************************ + ** The bytes of the answer to the request of this id. + ************************************************************************/ + byte[] to(String requestId) throws Exception; + } + + + + /*************************************************************************** + ** A row whose answer is the well-formed answer of the released renderer + ** with one change to its fields. + ***************************************************************************/ + private static Arguments changed(String name, String outcome, String rule, Consumer> change) + { + Frame frame = id -> + { + Map fields = FakeWorker.rendered(id, FakeWorker.RELEASED, LABEL, List.of()); + change.accept(fields); + return (FakeWorker.frame(fields)); + }; + return (Arguments.of(name, frame, outcome, rule)); + } + + + + /*************************************************************************** + ** A row whose answer is the well-formed answer for another document. + ***************************************************************************/ + private static Arguments document(String name, String outcome, String rule, byte[] label) + { + return (Arguments.of(name, (Frame) id -> FakeWorker.frame(FakeWorker.rendered(id, FakeWorker.RELEASED, label, List.of())), outcome, rule)); + } + + + + /*************************************************************************** + ** A row whose answer is an error answer of the released renderer, with one + ** change to its fields. + ***************************************************************************/ + private static Arguments error(String name, String outcome, String rule, String code, Consumer> change) + { + Frame frame = id -> + { + Map fields = FakeWorker.error(id, FakeWorker.RELEASED, code, null); + change.accept(fields); + return (FakeWorker.frame(fields)); + }; + return (Arguments.of(name, frame, outcome, rule)); + } + + + + /*************************************************************************** + ** A row whose answer is the well-formed frame with its bytes changed. + ***************************************************************************/ + private static Arguments bytes(String name, String outcome, String rule, UnaryOperator change) + { + Frame frame = id -> change.apply(new String(FakeWorker.frame(FakeWorker.rendered(id, FakeWorker.RELEASED, LABEL, List.of())), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8); + return (Arguments.of(name, frame, outcome, rule)); + } + + + + /*************************************************************************** + ** No change: the row's answer is sent as its renderer writes it. + ***************************************************************************/ + private static void unchanged(Map fields) + { + //////////////////////////////////////////// + // nothing is added, removed or reordered // + //////////////////////////////////////////// + } + + + + /*************************************************************************** + ** The fields of an answer in the opposite order. + ***************************************************************************/ + private static void reverse(Map fields) + { + List> entries = new ArrayList<>(fields.entrySet().stream().map(entry -> Map.entry(entry.getKey(), entry.getValue())).toList()); + fields.clear(); + for(Map.Entry entry : entries.reversed()) + { + fields.put(entry.getKey(), entry.getValue()); + } + } + + + + /*************************************************************************** + ** The table. Its rows follow the rule as it is written in `WorkerAnswer`, + ** `RendererVersion` and the bridge's checks on the document. + ***************************************************************************/ + static Stream answers() throws Exception + { + Map warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING"); + Map fit = FakeWorker.diagnostic("BARCODE_FIT_TOO_SMALL", "ERROR"); + String other = RendererJson.hash(ELSEWHERE); + return (Stream.of( + /////////////////////////////////////////////////////////////////////////////// + // accepted: the released answer, whatever the order and spacing of its JSON // + /////////////////////////////////////////////////////////////////////////////// + changed("the released answer", ACCEPTED, PASSES, VersionOneAnswerTableTest::unchanged), + changed("its fields in another order", ACCEPTED, PASSES, VersionOneAnswerTableTest::reverse), + bytes("spaces between its tokens", ACCEPTED, PASSES, frame -> frame.replace("\":", "\" : ").replace(",\"", " , \"")), + bytes("spaces after its object", ACCEPTED, PASSES, frame -> frame.replace("}\n", "} \n")), + changed("base64 without its padding", ACCEPTED, PASSES, fields -> fields.put("base64", ((String) fields.get("base64")).replace("=", ""))), + + ///////////////////////////////////////////////////////////////////////////// + // typed refusals: an error answer that names one of the 13 released codes // + ///////////////////////////////////////////////////////////////////////////// + error("an error with a released code", "BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_TOO_SMALL", VersionOneAnswerTableTest::unchanged), + error("an error with the last released code", "UNSUPPORTED_BARCODE_OPTION", "UNSUPPORTED_BARCODE_OPTION", "UNSUPPORTED_BARCODE_OPTION", VersionOneAnswerTableTest::unchanged), + + ///////////////////////////////////////////////// + // rejected: what only renderer 0.2.0 may send // + ///////////////////////////////////////////////// + changed("with empty diagnostics", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("diagnostics", List.of())), + changed("with a warning in diagnostics", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("diagnostics", List.of(warning))), + changed("with a detail", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("detail", fit)), + changed("with the outcome REFUSED", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("outcome", "REFUSED")), + error("a bare REFUSED answer", REJECTED, "WORKER_RESPONSE_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> + { + fields.put("outcome", "REFUSED"); + fields.remove("code"); + }), + error("a REFUSED answer with an error in diagnostics", REJECTED, "WORKER_RESPONSE_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> + { + fields.put("outcome", "REFUSED"); + fields.remove("code"); + fields.put("diagnostics", List.of(fit)); + }), + error("an error with a detail", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.put("detail", fit)), + error("an error with diagnostics", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.put("diagnostics", List.of(fit))), + error("an error with a version 2 scene code", REJECTED, "WORKER_ERROR_REJECTED", "SCENE_INVALID", VersionOneAnswerTableTest::unchanged), + error("an error that says the renderer is too old", REJECTED, "WORKER_ERROR_REJECTED", "RENDERER_TOO_OLD", VersionOneAnswerTableTest::unchanged), + error("an error that says the preview is too large", REJECTED, "WORKER_ERROR_REJECTED", "PREVIEW_TOO_LARGE", VersionOneAnswerTableTest::unchanged), + changed("renderer 0.2.0 named on the released fields", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", FakeWorker.CURRENT)), + + /////////////////////////////////////////////////////// + // rejected: an error answer that is not exactly one // + /////////////////////////////////////////////////////// + error("an error with an unknown code", REJECTED, "WORKER_ERROR_REJECTED", "NOT_A_CODE", VersionOneAnswerTableTest::unchanged), + error("an error with a code in lower case", REJECTED, "WORKER_ERROR_REJECTED", "barcode_fit_too_small", VersionOneAnswerTableTest::unchanged), + error("an error with no code", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.remove("code")), + error("an error with a field too many", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.put("pageCount", 1)), + changed("the released answer with a code", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("code", "BARCODE_FIT_TOO_SMALL")), + + /////////////////////////////////////////////////////////////////////////////////// + // rejected: the envelope is not this protocol, this request, this catalog or an // + // accepted renderer // + /////////////////////////////////////////////////////////////////////////////////// + changed("another protocol", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("protocol", "qbit-document-renderer/2")), + changed("no protocol", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("protocol")), + changed("another request", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("requestId", "request-2")), + changed("another catalog", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("catalogVersion", "qqq-barcodes-8")), + changed("an outcome that is not one", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("outcome", "DONE")), + changed("no outcome", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("outcome")), + changed("a later patch version", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "qqq-renderer-0.1.1")), + changed("a later renderer", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "qqq-renderer-0.3.0")), + changed("the version number alone", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "0.1.0")), + changed("the version in upper case", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "QQQ-RENDERER-0.1.0")), + changed("the version with a space after it", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "qqq-renderer-0.1.0 ")), + changed("no version", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("rendererVersion")), + changed("a version longer than a version", REJECTED, "RESPONSE_TEXT_LIMIT", fields -> fields.put("rendererVersion", "qqq-renderer-0.1.0" + "0".repeat(111))), + + ////////////////////////////////////////////////////////////////////// + // rejected: the fields are not the released fields in their shapes // + ////////////////////////////////////////////////////////////////////// + changed("a field missing", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("pageCount")), + changed("the answer to a validation", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.keySet().retainAll(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion"))), + changed("an unknown field", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("note", "x")), + changed("a render target", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("targetDpi", 203)), + changed("the length as text", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("length", String.valueOf(LABEL.length))), + changed("the length as a fraction", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("length", (double) LABEL.length)), + changed("the media type as a number", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("mediaType", 1)), + changed("a null field", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("sha256", null)), + changed("diagnostics that are not a list", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("diagnostics", "none")), + + ///////////////////////////////////////////////////////////////////////////// + // rejected: the frame is not one line of one JSON object within its bound // + ///////////////////////////////////////////////////////////////////////////// + bytes("no line end", REJECTED, "FRAME_TRUNCATED", frame -> frame.substring(0, frame.length() - 1)), + bytes("nothing", REJECTED, "FRAME_TRUNCATED", frame -> ""), + bytes("two frames", REJECTED, "EXTRA_FRAME", frame -> frame + frame), + bytes("a carriage return", REJECTED, "EXTRA_FRAME", frame -> frame.replace("}\n", "}\r\n")), + bytes("a list", REJECTED, "RESPONSE_OBJECT_REQUIRED", frame -> "[" + frame.trim() + "]\n"), + bytes("a second value", REJECTED, "RESPONSE_TRAILING_VALUE", frame -> frame.trim() + "{}\n"), + bytes("a field twice", REJECTED, "JsonParseException", frame -> frame.replace("{\"protocol\"", "{\"pageCount\":1,\"protocol\"")), + bytes("cut short", REJECTED, "JsonEOFException", frame -> frame.substring(0, frame.length() - 2) + "\n"), + document("a document past the frame's bound", REJECTED, "RESPONSE_BYTE_LIMIT", new byte[8000]), + + ///////////////////////////////////////////////////////////////////////////////////////// + // rejected by the checks on the document, after the version rule has passed the frame // + ///////////////////////////////////////////////////////////////////////////////////////// + changed("another hash", REJECTED, PASSES, fields -> fields.put("sha256", other)), + changed("the hash in upper case", REJECTED, PASSES, fields -> fields.put("sha256", ((String) fields.get("sha256")).toUpperCase(Locale.ROOT))), + changed("a hash longer than a hash", REJECTED, PASSES, fields -> fields.put("sha256", fields.get("sha256") + "0")), + changed("a length one more", REJECTED, PASSES, fields -> fields.put("length", LABEL.length + 1)), + changed("a length of nothing", REJECTED, PASSES, fields -> fields.put("length", 0)), + changed("a length past the limit", REJECTED, PASSES, fields -> fields.put("length", MAXIMUM + 1)), + changed("another document under the same hash", REJECTED, PASSES, fields -> fields.put("base64", Base64.getEncoder().encodeToString(ELSEWHERE))), + changed("base64 that is not base64", REJECTED, PASSES, fields -> fields.put("base64", "****")), + changed("no document", REJECTED, PASSES, fields -> fields.put("base64", "")), + changed("the media type of a page", REJECTED, PASSES, fields -> fields.put("mediaType", "application/pdf")), + changed("the media type in upper case", REJECTED, PASSES, fields -> fields.put("mediaType", "APPLICATION/VND.ZEBRA-ZPL")), + changed("two pages", REJECTED, PASSES, fields -> fields.put("pageCount", 2)), + changed("no pages", REJECTED, PASSES, fields -> fields.put("pageCount", 0)), + document("a label for another stock", REJECTED, PASSES, ELSEWHERE), + document("a label with a device command after it", REJECTED, PASSES, COMMAND), + document("a document past the job's limit", REJECTED, PASSES, new byte[MAXIMUM + 1]))); + } + + + + /*************************************************************************** + ** What the version rule says of a frame as the answer to the version 1 + ** print job of these tests: the name it rejects the frame by, the code of + ** the refusal it reads from it, or `-` when it passes the frame on. + ***************************************************************************/ + private static String rule(byte[] frame, int maximumFrame) throws Exception + { + try + { + WorkerAnswer.read(frame, maximumFrame, REQUEST_ID, SceneFacts.of(GenerationPorts.Format.ZPL, FakeWorker.VERSION_ONE_SCENE)).settle("RENDER", false, false); + return (PASSES); + } + catch(GenerationPorts.RenderException refusal) + { + return (refusal.code().name()); + } + catch(IllegalArgumentException rejected) + { + return (rejected.getMessage()); + } + catch(IOException unreadable) + { + return (unreadable.getClass().getSimpleName()); + } + } + + + + /*************************************************************************** + ** One row: the job is sent once, as the released job, with no version + ** question before it and no second attempt after it, and `render` ends as + ** the row says. + ***************************************************************************/ + @ParameterizedTest(name = "{0}") + @MethodSource("answers") + void anAnswerInTheReleasedShapeIsHeldToTheRule(String name, Frame frame, String outcome, String rule) throws Exception + { + List bounds = new ArrayList<>(); + List sent = new ArrayList<>(); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, (id, input, maximum) -> + { + RendererJson.parse(input, 16 * 1024 * 1024).fieldNames().forEachRemaining(sent::add); + bounds.add(maximum); + return (frame.to(id)); + }); + var request = new GenerationPorts.RenderRequest(REQUEST_ID, "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, + FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, BridgeTest.STOCK), Map.of(), MAXIMUM); + if(outcome.equals(ACCEPTED)) + { + assertArrayEquals(LABEL, bridge.render(request).bytes(), name); + } + else if(outcome.equals(REJECTED)) + { + QException failure = assertThrows(QException.class, () -> bridge.render(request), name); + assertEquals(QException.class, failure.getClass(), name); + assertEquals("Isolated renderer rejected or could not complete the request.", failure.getMessage(), name); + assertNull(failure.getCause(), name); + } + else + { + GenerationPorts.RenderException refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(request), name); + assertEquals(outcome, refusal.code().name(), name); + assertEquals(outcome, refusal.getMessage(), name); + assertNull(refusal.diagnostic(), name); + } + assertEquals(List.of(4 * ((MAXIMUM + 2) / 3) + 4096), bounds, name); + assertEquals(List.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), sent, name); + assertEquals(rule, rule(frame.to(REQUEST_ID), bounds.get(0)), name); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneJobFrameTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneJobFrameTest.java new file mode 100644 index 0000000..57ae3c8 --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneJobFrameTest.java @@ -0,0 +1,247 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.documentgeneration.renderer; + + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** The job a version 1 scene is printed with is the released job, byte for + ** byte: the frame `render` writes to the exchange for fixed inputs is compared + ** with the frame the released bridge wrote for the same inputs, which is + ** stored in this test's resources and was captured from the release tag by + ** this same class (`compat-v1/job-frame-render.md` says how). + ** + ** One part of the frame has no fixed bytes to compare, in the release or + ** since: the seven members of `stock` are written in the iteration order of an + ** immutable map, which the JVM changes from one run to the next (four captures + ** at the tag gave four orders and were otherwise identical). Those members are + ** therefore compared as the same seven texts in any order; every other byte of + ** the frame, before and after them, must be the stored byte. + ** + ** The class uses nothing a later bridge added, so that it compiles and runs + ** unchanged in a checkout of that tag. With no stored frame, or a different + ** one, it fails and leaves the frame it saw in `target/compat-v1-actual`. + *******************************************************************************/ +class VersionOneJobFrameTest +{ + private static final Path GOLDEN = Path.of("src", "test", "resources", "compat-v1", "job-frame-render.json"); + private static final Path ACTUAL = Path.of("target", "compat-v1-actual", "job-frame-render.json"); + + /////////////////////////////////////////////////////////////////////////////////////////// + // the SHA-256 of the stored frame, so it cannot be rewritten together with the code and // + // still pass // + /////////////////////////////////////////////////////////////////////////////////////////// + private static final String GOLDEN_SHA256 = "f7fc3faa862dfd06875ad034174315012cd945580872b4d64df12fefc9b52e10"; + + private static final String REQUEST_ID = "compat-v1-job-0001"; + private static final String ACTOR = "actor-1"; + private static final int MAXIMUM = 4096; + private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(101600, 152400, 1000, 2000, 3000, 4000, 203); + private static final byte[] LABEL = "^XA^PW812^LL1218^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII); + + ///////////////////////////////////////////////////////////////////////////////////////////// + // a version 1 scene of every element type, with line breaks, a tab and characters outside // + // ASCII in its text, because the source travels as one JSON string // + ///////////////////////////////////////////////////////////////////////////////////////////// + private static final String SCENE = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[\n" + + "\t{\"type\":\"text\",\"x\":20,\"y\":20,\"width\":12,\"height\":24,\"valuePath\":\"/order/id\"},\n" + + "\t{\"type\":\"box\",\"x\":20,\"y\":60,\"width\":500,\"height\":2,\"thickness\":2},\n" + + "\t{\"type\":\"image\",\"x\":20,\"y\":90,\"asset\":\"mark\"},\n" + + "\t{\"type\":\"rasterText\",\"x\":20,\"y\":150,\"fontAsset\":\"face\",\"fontSize\":24,\"width\":400,\"height\":40,\"value\":\"Café TEST-0001 €\"},\n" + + "\t{\"type\":\"barcode\",\"x\":20,\"y\":220,\"symbology\":\"qr\",\"scale\":4,\"quiet\":4,\"value\":\"TEST-0001\"}]}\n"; + + @TempDir + Path directory; + + + + /*************************************************************************** + ** The bytes of an asset: synthetic, every byte value in turn from a start. + ** The bridge reads no image or font; it hashes and encodes what it is given. + ***************************************************************************/ + private static byte[] content(int length, int start) + { + byte[] bytes = new byte[length]; + for(int index = 0; index < length; index++) + { + bytes[index] = (byte) (start + index); + } + return (bytes); + } + + + + /*************************************************************************** + ** The context of the job, in a stated order: text that JSON must escape, + ** every kind of number the bridge admits, both booleans, null, and nested + ** and empty maps and lists. Every value is synthetic. + ***************************************************************************/ + private static Map context() + { + Map first = new LinkedHashMap<>(); + first.put("sku", "TEST-0001"); + first.put("quantity", 3); + first.put("weight", new BigDecimal("12.50")); + Map second = new LinkedHashMap<>(); + second.put("sku", "TEST-0002"); + second.put("quantity", 0); + second.put("weight", new BigDecimal("0.125")); + Map order = new LinkedHashMap<>(); + order.put("id", "ORD-TEST-0001"); + order.put("lines", List.of(first, second)); + order.put("shipped", true); + order.put("cancelled", false); + order.put("note", null); + Map context = new LinkedHashMap<>(); + context.put("order", order); + context.put("text", "quote \" backslash \\ slash / tab \t line \n return \r control \u0001 e-acute é euro € separator 
 pair 😀 markup &"); + context.put("count", 2147483647); + context.put("total", -9007199254740993L); + context.put("big", new BigInteger("123456789012345678901234567890")); + context.put("ratio", 0.1); + context.put("small", -2.5E-7); + context.put("half", 1.5f); + context.put("byte", (byte) -8); + context.put("short", (short) 12345); + context.put("empty", new LinkedHashMap<>()); + context.put("none", List.of()); + return (context); + } + + + + /*************************************************************************** + ** A well-formed answer of the released renderer, so the job completes. + ***************************************************************************/ + private static byte[] answer(String requestId) throws Exception + { + Map fields = new LinkedHashMap<>(); + fields.put("protocol", RendererJson.PROTOCOL); + fields.put("requestId", requestId); + fields.put("outcome", "OK"); + fields.put("rendererVersion", "qqq-renderer-0.1.0"); + fields.put("catalogVersion", "qqq-barcodes-9"); + fields.put("mediaType", "application/vnd.zebra-zpl"); + fields.put("base64", Base64.getEncoder().encodeToString(LABEL)); + fields.put("sha256", RendererJson.hash(LABEL)); + fields.put("length", LABEL.length); + fields.put("pageCount", 1); + return ((RendererJson.JSON.writeValueAsString(fields) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + + + /*************************************************************************** + ** One `render` of the fixed job, and what the exchange was handed for it. + ***************************************************************************/ + private byte[] sent() throws Exception + { + Map contents = Map.of("mark", content(256, 0), "face", content(5, 251)); + List assets = List.of( + new GenerationPorts.Asset("mark", "file-version-0001", RendererJson.hash(contents.get("mark")), "image/png"), + new GenerationPorts.Asset("face", "file-version-0002", RendererJson.hash(contents.get("face")), "font/ttf", "Test Face", 700, "italic")); + List frames = new ArrayList<>(); + List exchanged = new ArrayList<>(); + IsolatedRendererBridge bridge = new IsolatedRendererBridge(BridgeTest.config(directory), (actor, asset, maximum) -> + { + assertEquals(ACTOR, actor); + return (contents.get(asset.name()).clone()); + }, (id, input, maximum) -> + { + frames.add(input.clone()); + exchanged.add(id + " " + maximum); + return (answer(id)); + }); + GenerationPorts.Revision revision = new GenerationPorts.Revision(7, 3, "orders", STOCK, null, SCENE, null, assets); + GenerationPorts.RenderRequest request = new GenerationPorts.RenderRequest(REQUEST_ID, ACTOR, new GenerationPorts.Source("orders", "1001"), GenerationPorts.Format.ZPL, revision, context(), MAXIMUM); + assertArrayEquals(LABEL, bridge.render(request).bytes()); + /////////////////////////////////////////////////////////////////////////////////////////// + // one exchange and no other: the job itself, under its own id, with the released bound // + // on its answer (the base64 of the largest label and 4096 bytes of envelope) // + /////////////////////////////////////////////////////////////////////////////////////////// + assertEquals(List.of(REQUEST_ID + " " + (4 * ((MAXIMUM + 2) / 3) + 4096)), exchanged); + return (frames.get(0)); + } + + + + /*************************************************************************** + ** A frame with the members of its `stock` in sorted order and every other + ** byte where it was. Each byte is one character here, so nothing is decoded + ** or normalised; a frame with no `stock` object is left as it is. + ***************************************************************************/ + private static String ordered(byte[] frame) + { + String bytes = new String(frame, StandardCharsets.ISO_8859_1); + int start = bytes.indexOf("\"stock\":{") + 9; + int end = bytes.indexOf('}', start); + if(start < 9 || end < 0) + { + return (bytes); + } + String[] members = bytes.substring(start, end).split(","); + Arrays.sort(members); + return (bytes.substring(0, start) + String.join(",", members) + bytes.substring(end)); + } + + + + /*************************************************************************** + ** The frame is the stored one. On any difference the frame that was sent is + ** left in `target/compat-v1-actual` and the first differing byte is named + ** (counted with the members of `stock` in sorted order in both frames). + ***************************************************************************/ + @Test + void theVersionOneRenderJobIsTheReleasedFrame() throws Exception + { + byte[] sent = sent(); + byte[] golden = Files.exists(GOLDEN) ? Files.readAllBytes(GOLDEN) : new byte[0]; + String expected = ordered(golden); + String actual = ordered(sent); + if(!expected.equals(actual)) + { + Files.createDirectories(ACTUAL.getParent()); + Files.write(ACTUAL, sent); + } + assertEquals(GOLDEN_SHA256, RendererJson.hash(golden), "The stored frame was regenerated or edited"); + int first = Arrays.mismatch(expected.toCharArray(), actual.toCharArray()); + assertTrue(first < 0, "The version 1 job frame differs from the released frame at byte " + first + " (the frame that was sent is in " + ACTUAL + ")"); + assertEquals(golden.length, sent.length); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java index 6f64360..362399b 100644 --- a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicReference; import com.fasterxml.jackson.databind.JsonNode; import com.kingsrook.qbits.documentgeneration.GenerationPorts; import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; @@ -229,6 +230,142 @@ class VersionRuleTest + /*************************************************************************** + ** A renderer image that is rolled back while a bridge uses it. What the + ** bridge remembered sends ONE version 2 job to the old renderer, scene and + ** record value included, and that job is refused as `RENDERER_TOO_OLD`. + ** Every job after it (a render, a validation, a preview) asks the version + ** first and is refused with nothing of the scene or the record sent. When + ** the image is rolled forward again the next job asks, is rendered, and the + ** question is not asked again. + ***************************************************************************/ + @Test + void afterARollbackOneJobReachesTheOldRendererAndTheNextAsksFirst() throws Exception + { + AtomicReference image = new AtomicReference<>(FakeWorker.CURRENT); + FakeWorker worker = new FakeWorker(image::get, job -> + { + String id = job.path("requestId").textValue(); + return (job.path("operation").textValue().equals("VALIDATE") ? FakeWorker.validated(id, image.get()) : FakeWorker.rendered(id, image.get(), FakeWorker.golden("anchors", 203), List.of())); + }); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + String scene = FakeWorker.scene("anchors"); + var preview = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, job(scene, null).revision(), Map.of("name", SECRET), null, MAXIMUM); + + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertEquals(List.of(1, 1), List.of(worker.questions.size(), worker.jobs.size())); + + image.set(FakeWorker.RELEASED); + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))).code()); + /////////////////////////////////////////////////////////////////////////////////////////// + // the window: this one job was sent unasked, with the scene and the record value // + /////////////////////////////////////////////////////////////////////////////////////////// + assertEquals(List.of(1, 2), List.of(worker.questions.size(), worker.jobs.size())); + assertEquals(scene, worker.jobs.get(1).path("source").textValue()); + assertEquals(SECRET, worker.jobs.get(1).path("context").path("name").textValue()); + + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))).code()); + assertEquals(List.of(2, 2), List.of(worker.questions.size(), worker.jobs.size())); + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.validate("actor-1", job(scene, null).revision())).code()); + assertEquals(List.of(3, 2), List.of(worker.questions.size(), worker.jobs.size())); + assertEquals(List.of(new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, null, null, null, null)), bridge.preview(preview).diagnostics()); + assertEquals(List.of(4, 2), List.of(worker.questions.size(), worker.jobs.size())); + for(JsonNode question : worker.questions) + { + assertEquals(IsolatedRendererBridge.PROBE_SCENE, question.path("source").textValue()); + assertFalse(question.toString().contains(SECRET) || question.toString().contains("qqq-zpl-scene/2")); + } + + image.set(FakeWorker.CURRENT); + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertEquals(List.of(5, 3), List.of(worker.questions.size(), worker.jobs.size())); + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertEquals(List.of(5, 4), List.of(worker.questions.size(), worker.jobs.size())); + } + + + + /*************************************************************************** + ** A job that ends in `RENDERER_TOO_OLD` ends what the bridge remembered, + ** however the job came to it. Renderer 0.2.0 may name that refusal itself + ** (an element it cannot draw yet), as an error answer or among collected + ** diagnostics, and then its version alone says nothing new: the next job + ** asks the version again, after a render, a validation and a preview + ** alike. Any other refusal, and any job that is done, leaves the bridge + ** remembering. + ***************************************************************************/ + @Test + void anyJobThatEndsAsTooOldMakesTheNextJobAskTheVersion() throws Exception + { + Map unavailable = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "box", List.of(0, 0, 10, 10)); + FakeWorker.Answer done = job -> job.path("operation").textValue().equals("VALIDATE") ? FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT) + : FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 203), List.of()); + FakeWorker.Answer tooOld = job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "RENDERER_TOO_OLD", unavailable); + FakeWorker.Answer invalid = job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "SCENE_INVALID", null); + FakeWorker.Answer collected = job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(unavailable)); + AtomicReference next = new AtomicReference<>(done); + FakeWorker worker = new FakeWorker(FakeWorker.CURRENT, job -> next.get().to(job)); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + String scene = FakeWorker.scene("anchors"); + var preview = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, job(scene, null).revision(), Map.of(), null, MAXIMUM); + Diagnostic read = new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, "center", List.of(0, 0, 10, 10), null, null); + + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertEquals(1, worker.questions.size()); + + /////////////////////////////////////////////////////////////////////////////////////////// + // another refusal, a validation and a preview that are done: nothing is asked again // + /////////////////////////////////////////////////////////////////////////////////////////// + next.set(invalid); + assertEquals(FailureCode.SCENE_INVALID, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))).code()); + next.set(done); + bridge.validate("actor-1", job(scene, null).revision()); + assertTrue(bridge.preview(preview).printable()); + assertEquals(1, worker.questions.size()); + + /////////////////////////////////////////////////////////////////////////////////////////// + // a render that the renderer refuses as too old: the job after it asks first // + /////////////////////////////////////////////////////////////////////////////////////////// + next.set(tooOld); + var refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))); + assertEquals(FailureCode.RENDERER_TOO_OLD, refusal.code()); + assertEquals(read, refusal.diagnostic()); + assertEquals(1, worker.questions.size()); + next.set(done); + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertEquals(2, worker.questions.size()); + + /////////////////////////////////////////////////////////////////////////////////////////// + // a validation that ends the same way // + /////////////////////////////////////////////////////////////////////////////////////////// + next.set(tooOld); + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.validate("actor-1", job(scene, null).revision())).code()); + next.set(done); + bridge.validate("actor-1", job(scene, null).revision()); + assertEquals(3, worker.questions.size()); + + /////////////////////////////////////////////////////////////////////////////////////////// + // a preview whose answer is that error, and one whose collected diagnostics hold it // + /////////////////////////////////////////////////////////////////////////////////////////// + next.set(tooOld); + assertEquals(List.of(read), bridge.preview(preview).diagnostics()); + next.set(done); + assertTrue(bridge.preview(preview).printable()); + assertEquals(4, worker.questions.size()); + next.set(collected); + GenerationPorts.Preview refused = bridge.preview(preview); + assertFalse(refused.printable()); + assertEquals(List.of(read), refused.diagnostics()); + assertEquals(4, worker.questions.size()); + next.set(done); + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertEquals(5, worker.questions.size()); + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertEquals(5, worker.questions.size()); + } + + + /*************************************************************************** ** A renderer of a version this bridge was not built for is rejected, and so ** is a refusal a renderer may not name; a refusal renderer 0.2.0 may name diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java index 599c677..872543e 100644 --- a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java @@ -27,6 +27,8 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -38,6 +40,7 @@ import org.junit.jupiter.params.provider.MethodSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -46,7 +49,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; ** label of the renderer's version 2 cases decodes, dot for dot, to the golden ** picture of the canvas it was emitted from, at 203, 300 and 600 dpi. Every ** released version 1 label decodes too. A label that is not exactly the - ** policy's grammar, or is too large to draw, is refused by name. + ** policy's grammar, or is too large to draw, is refused by name, and so is + ** one whose fields would paint more than sixteen times its page: the work of + ** drawing is bounded by the page, never by the number of fields. *******************************************************************************/ class ZplRasterTest { @@ -54,6 +59,11 @@ class ZplRasterTest private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(25400, 12700, 0, 0, 0, 0, 203); private static final String HEADER = "^XA^PW203^LL102^LH0,0^CI28\n"; + ///////////////////////////////////////////////////////////////////////////////////////////// + // what the one-inch page of these tests may be painted with: sixteen times its 20706 dots // + ///////////////////////////////////////////////////////////////////////////////////////////// + private static final long BUDGET = 16L * 203 * 102; + /*************************************************************************** @@ -217,10 +227,10 @@ class ZplRasterTest /*************************************************************************** ** Printer font text cannot be drawn on the host. Its area (one cell for each ** character, counted as characters and not as bytes) is outlined and - ** hatched, and the page says it is not exact. + ** crossed by its two diagonals, and the page says it is not exact. ***************************************************************************/ @Test - void printerFontTextIsAHatchedPlaceholderAndThePageIsNotExact() + void printerFontTextIsACrossedPlaceholderAndThePageIsNotExact() { ZplRaster page = ZplRaster.decode(label("^FO10,20^A0N,12,7^FH_^FD_41_C3_A9_E2_82_AC^FS\n")); assertFalse(page.exact()); @@ -228,10 +238,7 @@ class ZplRasterTest { for(int x = 8; x < 34; x++) { - int column = x - 10; - int row = y - 20; - boolean inside = column >= 0 && column < 21 && row >= 0 && row < 12; - assertEquals(inside && (column == 0 || row == 0 || column == 20 || row == 11 || (column + row) % 6 == 0), page.ink(x, y), x + ", " + y); + assertEquals(crossed(x - 10, y - 20, 21, 12), page.ink(x, y), x + ", " + y); } } } @@ -321,7 +328,8 @@ class ZplRasterTest /*************************************************************************** ** The page of a refused scene shows only where its refused elements are: ** each diagnostic box, moved from the printable area to the page, cut to the - ** page and hatched. It is never exact, and a box off the page draws nothing. + ** page, outlined and crossed. It is never exact, and a box off the page + ** draws nothing. ***************************************************************************/ @Test void theRefusedPageIsDrawnFromBoxesAlone() @@ -333,9 +341,9 @@ class ZplRasterTest { for(int x = 0; x < 60; x++) { - boolean first = hatched(x - 10, y - 5, 13, 7); - boolean cut = hatched(x - 55, y - 35, 5, 5); - boolean corner = hatched(x, y, 12, 8); + boolean first = crossed(x - 10, y - 5, 13, 7); + boolean cut = crossed(x - 55, y - 35, 5, 5); + boolean corner = crossed(x, y, 12, 8); assertEquals(first || cut || corner, page.ink(x, y), x + ", " + y); } } @@ -345,11 +353,193 @@ class ZplRasterTest /*************************************************************************** - ** Whether a dot is ink in a hatched area of this size whose corner is the - ** origin of `column` and `row`. + ** A placeholder of no width or no height has no dots, so nothing is drawn + ** for it: no outline, and no diagonal one dot outside it. An empty printer + ** font text field does not come this far: it is refused as no field. ***************************************************************************/ - private static boolean hatched(int column, int row, int width, int height) + @Test + void aPlaceholderOfNoWidthOrNoHeightDrawsNothing() { - return (column >= 0 && column < width && row >= 0 && row < height && (column == 0 || row == 0 || column == width - 1 || row == height - 1 || (column + row) % 6 == 0)); + ZplRaster page = ZplRaster.refused(60, 40, 0, 0, List.of(List.of(20, 10, 0, 12), List.of(20, 10, 12, 0), List.of(20, 10, 0, 0), List.of(70, 10, 5, 5), List.of(-9, 10, 5, 5), + List.of(20, 45, 5, 5), List.of(2147483647, 10, 5, 5))); + assertFalse(page.exact()); + for(int y = 0; y < 40; y++) + { + for(int x = 0; x < 60; x++) + { + assertFalse(page.ink(x, y), x + ", " + y); + } + } + assertEquals("RASTER_FIELD", refusal(HEADER + "^FO10,20^A0N,12,7^FH_^FD^FS\n^XZ\n")); + } + + + + /*************************************************************************** + ** Whether a dot is ink in a placeholder of this size whose corner is the + ** origin of `column` and `row`: its outline, and its two diagonals, which + ** have one dot in each row of a tall area and in each column of a wide one. + ** Written from that definition, not from the way the page draws it. + ***************************************************************************/ + static boolean crossed(int column, int row, int width, int height) + { + if(column < 0 || column >= width || row < 0 || row >= height) + { + return (false); + } + if(column == 0 || row == 0 || column == width - 1 || row == height - 1) + { + return (true); + } + if(height >= width) + { + int across = row * (width - 1) / (height - 1); + return (column == across || column == width - 1 - across); + } + return (row == column * (height - 1) / (width - 1) || row == (width - 1 - column) * (height - 1) / (width - 1)); + } + + + + /*************************************************************************** + ** Filled boxes that paint exactly this many dots of the one-inch page: whole + ** pages, then whole rows, then part of a row. + ***************************************************************************/ + private static String fills(long dots) + { + StringBuilder fields = new StringBuilder("^FO0,0^GB203,102,102^FS\n".repeat((int) (dots / (203 * 102)))); + int rest = (int) (dots % (203 * 102)); + if(rest >= 203) + { + fields.append("^FO0,0^GB203,").append(rest / 203).append(',').append(rest / 203).append("^FS\n"); + } + if(rest % 203 > 0) + { + fields.append("^FO0,0^GB").append(rest % 203).append(",1,1^FS\n"); + } + return (fields.toString()); + } + + + + /*************************************************************************** + ** A box is the same dots as painting it dot by dot, which is how it was + ** painted before it was painted as bars: thin, thick, solid where the + ** border meets itself, a single dot, a line, against the page's edges and + ** across byte boundaries. The expected dots are computed here, one at a + ** time, from the printer's rule. + ***************************************************************************/ + @Test + void aBoxIsTheSameDotsAsPaintingItDotByDot() + { + int boxes = 0; + for(int width = 1; width <= 40; width++) + { + for(int height = 1; height <= 12; height++) + { + for(int thickness = 1; thickness <= Math.min(width, height); thickness++) + { + for(int[] place : new int[][] {{0, 0}, {40 - width, 30 - height}, {Math.min(3, 40 - width), Math.min(2, 30 - height)}, {Math.min(8, 40 - width), Math.min(17, 30 - height)}}) + { + int left = place[0]; + int top = place[1]; + String box = "^FO" + left + "," + top + "^GB" + width + "," + height + "," + thickness + "^FS\n"; + ZplRaster page = ZplRaster.decode(("^XA^PW40^LL30^LH0,0^CI28\n" + box + "^XZ\n").getBytes(StandardCharsets.US_ASCII)); + for(int y = 0; y < 30; y++) + { + for(int x = 0; x < 40; x++) + { + int column = x - left; + int row = y - top; + boolean inside = column >= 0 && column < width && row >= 0 && row < height; + boolean ink = inside && (row < thickness || row >= height - thickness || column < thickness || column >= width - thickness); + if(page.ink(x, y) != ink) + { + throw new AssertionError(box.trim() + " differs from the box painted dot by dot at " + x + ", " + y); + } + } + } + boxes++; + } + } + } + } + assertEquals(4 * 2834, boxes); + } + + + + /*************************************************************************** + ** Thousands of boxes that each cover the page are valid output: the policy + ** allows 6000 fields. Painted dot by dot, 6000 solid boxes of a 4 x 6 in + ** label at 600 dpi were 52 thousand million dots of work while an admission + ** slot was held. The page is painted at most sixteen times over, so the + ** seventeenth is refused by name at once; and 6000 outlines, which ink only + ** their bars, are drawn for what they ink. + ***************************************************************************/ + @Test + void thousandsOfPageSizedBoxesAreRefusedOrCostOnlyTheirInk() throws Exception + { + GenerationPorts.Stock stock = new GenerationPorts.Stock(101600, 152400, 0, 0, 0, 0, 600); + String header = "^XA^PW2400^LL3600^LH0,0^CI28\n"; + byte[] solid = (header + "^FO0,0^GB2400,3600,1200^FS\n".repeat(6000) + "^XZ\n").getBytes(StandardCharsets.US_ASCII); + ZplOutputPolicy.validate(solid, stock); + assertEquals("PREVIEW_TOO_LARGE", assertTimeoutPreemptively(Duration.ofSeconds(10), () -> assertThrows(IllegalArgumentException.class, () -> ZplRaster.page(solid, stock)).getMessage())); + + byte[] outlines = (header + "^FO0,0^GB2400,3600,1^FS\n".repeat(6000) + "^XZ\n").getBytes(StandardCharsets.US_ASCII); + ZplRaster page = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> ZplRaster.page(outlines, stock)); + assertTrue(page.ink(0, 0) && page.ink(2399, 3599) && page.ink(0, 1800) && page.ink(1200, 3599)); + assertFalse(page.ink(1, 1) || page.ink(1200, 1800)); + } + + + + /*************************************************************************** + ** The limit is exact, and every painter counts against it what it paints: a + ** solid box its area, an outline its four bars, a graphic eight dots for + ** each byte, a placeholder its outline and its two diagonals. A field that + ** takes the page to exactly sixteen times its dots is drawn; the same field + ** after one more dot is refused. + ***************************************************************************/ + @Test + void thePaintedBudgetIsExactForEveryPainter() + { + assertTrue(ZplRaster.decode(label(fills(BUDGET))).ink(202, 101)); + assertEquals("PREVIEW_TOO_LARGE", refusal(HEADER + fills(BUDGET + 1) + "^XZ\n")); + for(Map.Entry field : Map.of( + "^FO0,0^GB1,1,1^FS\n", 1, + "^FO20,10^GB6,5,3^FS\n", 30, + "^FO20,10^GB6,5,1^FS\n", 6 + 6 + 3 + 3, + "^FO8,2^GFA,4,4,2,A0018001^FS\n", 32, + "^FO10,20^A0N,12,7^FH_^FD_41^FS\n", 7 + 7 + 10 + 10 + 12 + 12, + "^FO10,20^A0N,2,30^FH_^FD_41^FS\n", 60 + 30 + 30).entrySet()) + { + assertTrue(ZplRaster.decode(label(fills(BUDGET - field.getValue()) + field.getKey())).ink(0, 0), field.getKey()); + assertEquals("PREVIEW_TOO_LARGE", refusal(HEADER + fills(BUDGET - field.getValue() + 1) + field.getKey() + "^XZ\n"), field.getKey()); + } + } + + + + /*************************************************************************** + ** The page of a refused scene is bounded the same way. A worker may send + ** 2048 diagnostics; hatched dot by dot, 2048 boxes of a sixteen million dot + ** page were 32 thousand million dots of work. Each now costs its outline + ** and its diagonals, and boxes that would paint more than sixteen times the + ** page are refused by name. + ***************************************************************************/ + @Test + void theRefusedPageIsBoundedHoweverManyBoxesItHas() + { + ZplRaster page = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> ZplRaster.refused(4000, 4000, 0, 0, Collections.nCopies(2048, List.of(0, 0, 4000, 4000)))); + assertTrue(page.ink(0, 0) && page.ink(3999, 2000) && page.ink(2000, 2000) && page.ink(1000, 2999)); + assertFalse(page.ink(2000, 1000) || page.ink(1, 2)); + + /////////////////////////////////////////////////////////////////////////////////////////// + // on a page of 8 x 32000 dots a box of the whole page costs 64012 dots of outline and // + // 64000 of diagonals; 31 of them are within sixteen times the page and 32 are not // + /////////////////////////////////////////////////////////////////////////////////////////// + assertTrue(ZplRaster.refused(8, 32000, 0, 0, Collections.nCopies(31, List.of(0, 0, 8, 32000))).ink(7, 31999)); + assertEquals("PREVIEW_TOO_LARGE", assertThrows(IllegalArgumentException.class, () -> ZplRaster.refused(8, 32000, 0, 0, Collections.nCopies(32, List.of(0, 0, 8, 32000)))).getMessage()); } } diff --git a/bridges/renderer/src/test/resources/compat-v1/job-frame-render.json b/bridges/renderer/src/test/resources/compat-v1/job-frame-render.json new file mode 100644 index 0000000..561dadd --- /dev/null +++ b/bridges/renderer/src/test/resources/compat-v1/job-frame-render.json @@ -0,0 +1 @@ +{"protocol":"qbit-document-renderer/1","requestId":"compat-v1-job-0001","operation":"RENDER","format":"ZPL","source":"{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[\n\t{\"type\":\"text\",\"x\":20,\"y\":20,\"width\":12,\"height\":24,\"valuePath\":\"/order/id\"},\n\t{\"type\":\"box\",\"x\":20,\"y\":60,\"width\":500,\"height\":2,\"thickness\":2},\n\t{\"type\":\"image\",\"x\":20,\"y\":90,\"asset\":\"mark\"},\n\t{\"type\":\"rasterText\",\"x\":20,\"y\":150,\"fontAsset\":\"face\",\"fontSize\":24,\"width\":400,\"height\":40,\"value\":\"Café TEST-0001 €\"},\n\t{\"type\":\"barcode\",\"x\":20,\"y\":220,\"symbology\":\"qr\",\"scale\":4,\"quiet\":4,\"value\":\"TEST-0001\"}]}\n","stock":{"rightMicrons":2000,"bottomMicrons":3000,"dpi":203,"heightMicrons":152400,"leftMicrons":4000,"topMicrons":1000,"widthMicrons":101600},"context":{"order":{"id":"ORD-TEST-0001","lines":[{"sku":"TEST-0001","quantity":3,"weight":12.50},{"sku":"TEST-0002","quantity":0,"weight":0.125}],"shipped":true,"cancelled":false,"note":null},"text":"quote \" backslash \\ slash / tab \t line \n return \r control \u0001 e-acute é euro € separator 
 pair 😀 markup &","count":2147483647,"total":-9007199254740993,"big":123456789012345678901234567890,"ratio":0.1,"small":-2.5E-7,"half":1.5,"byte":-8,"short":12345,"empty":{},"none":[]},"assets":[{"name":"mark","sha256":"40aff2e9d2d8922e47afd4648e6967497158785fbd1da870e7110266bf944880","mediaType":"image/png","base64":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w=="},{"name":"face","sha256":"93a2541056fb33566545ab2dec2ef36467ff8ca7ffdc40efbd966b4bfd16128b","mediaType":"font/ttf","base64":"+/z9/v8=","fontFamily":"Test Face","fontWeight":700,"fontStyle":"italic"}],"maximumBytes":4096} diff --git a/bridges/renderer/src/test/resources/compat-v1/job-frame-render.md b/bridges/renderer/src/test/resources/compat-v1/job-frame-render.md new file mode 100644 index 0000000..4f1a0c4 --- /dev/null +++ b/bridges/renderer/src/test/resources/compat-v1/job-frame-render.md @@ -0,0 +1,74 @@ +# Released version 1 job frame + +`job-frame-render.json` is the request the released bridge wrote to its exchange for one `render` of a version 1 +scene: 2021 bytes, one line of JSON ending in a line feed, exactly as the exchange received it. + +SHA-256: `f7fc3faa862dfd06875ad034174315012cd945580872b4d64df12fefc9b52e10` (pinned in `VersionOneJobFrameTest`). + +## Where the bytes came from + +They were written by the source of tag `v1.1.0-RC.2` (commit `e9ffba484e8d97f9853cb071cdac14f3bfd7acda`), not by +the branch that added this file. `VersionOneJobFrameTest.java` is its own capture tool: it uses only what that tag +has (`BridgeTest.config`, `RendererJson` and the bridge constructor that takes an exchange), so it was copied +unchanged into a checkout of the tag, with no stored frame, and run there: + +``` +git worktree add --detach v1.1.0-RC.2 +cp bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneJobFrameTest.java \ + /bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ +cd +./mvnw --batch-mode -pl bridges/renderer -am test -Dtest=VersionOneJobFrameTest -Dsurefire.failIfNoSpecifiedTests=false +``` + +The test fails in that state and leaves the frame that the released `IsolatedRendererBridge.render` handed to the +exchange in `bridges/renderer/target/compat-v1-actual/job-frame-render.json`. That file, from the first of four +such runs, is the stored frame. With the stored frame copied into the checkout as well, the test passed against +the tag three times in a row. The runs were on Temurin 21.0.12 on Windows with Maven 3.9.11. Nothing else was +added to the checkout and nothing of it was committed. + +The four capture runs used the class as it first was: the same inputs and the same capturing exchange, a strict +comparison of every byte and no pinned hash. Those runs showed that the order of the members of `stock` is not +fixed (below); the comparison was then written as it is now and the hash pinned, and that is the class which +passed at the tag. + +## The inputs + +All of them are constants of the test; every value is synthetic. + +| Input | Value | +|---|---| +| Request id | `compat-v1-job-0001` | +| Actor | `actor-1` | +| Source record | table `orders`, record `1001` (not part of a job) | +| Format | `ZPL` | +| Output limit | 4096 bytes, which is also the deployment's limit (`BridgeTest.config`) | +| Revision | id 7, template 3, source table `orders`, no page source, no script | +| Stock | 101600 x 152400 micrometres, margins top 1000, right 2000, bottom 3000, left 4000, 203 dpi | +| Scene | the constant `SCENE`: one `text`, `box`, `image`, `rasterText` and `barcode` element, with line feeds, tabs and two characters outside ASCII in its text | +| Asset `mark` | `image/png`, file version `file-version-0001`, the 256 bytes 0 to 255 | +| Asset `face` | `font/ttf`, file version `file-version-0002`, family `Test Face`, weight 700, style `italic`, the 5 bytes 251 to 255 | +| Context | the map `context()` builds, in its insertion order: a nested order with two lines, text that JSON must escape (quote, backslash, tab, line feed, carriage return, a control character, characters outside ASCII and outside the basic plane), an `Integer`, a `Long`, a `BigInteger`, a `BigDecimal` with a trailing zero, a `Double`, a `Float`, a `Byte`, a `Short`, both booleans, null, an empty map and an empty list | +| Answer | a well-formed answer of `qqq-renderer-0.1.0` with a one-box label for the stock, so that `render` completes | + +The asset bytes are not an image or a font. The bridge reads neither: it hashes the bytes and encodes them. + +## What is compared + +Every byte of the frame, with one exception that the released bridge itself makes. It writes the seven members of +`stock` in the iteration order of an immutable `Map.of`, and the JVM changes that order from one run to the next: +the four captures at the tag were four different files that differed in the order of those members and in +nothing else. The stored file has the order of the first run. The test therefore compares the members of `stock` +as the same seven `"name":value` texts in any order, and everything before and after them byte for byte. No +stricter comparison is true of the release. + +The test also holds the job to one exchange, under its own request id, with the released bound on the answer +(the base64 of 4096 bytes and 4096 bytes of envelope: 9560). + +Not covered: `validate` (its request id is random), a page (PDF) job, and any job only renderer 0.2.0 can do. + +## Rules + +- Never regenerate or edit `job-frame-render.json`. If `VersionOneJobFrameTest` fails, the code changed what a + version 1 print job sends; fix the code. The failure names the first differing byte and the frame that was sent + is in `bridges/renderer/target/compat-v1-actual`. +- A new frame may be added only with bytes captured the same way, from a released tag. diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index bb1ecd0..0648a84 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -26,7 +26,9 @@ Source editing displays the included names and preserves their manifest; submitt TrueType assets expose CSS family, integer weight100–900 and normal/italic style. Older four-field font references keep their name as family and400/normal defaults, including the canonical hash of already queued revisions. Explicit nondefault faces participate in that hash. Images cannot carry font attributes. Existing databases need the additive nullable font columns; `db/h2/002_asset_font_faces.sql` is an H2 upgrade, not a MySQL deployment migration. Published revisions pin asset identities, script identity, sources, stock and margins. -**Generate retained draft preview** accepts one source record identity and PDF/ZPL selection. It creates an immutable PREVIEW revision, executes the ordinary source authorization, isolated rendering, strict audit and Record Files intake workflow, and leaves the active published revision unchanged. The preview has a normal generation request and retained attachment. Retrying its process request key returns the same request; it does not regenerate bytes. Quarantine rules still apply. Results expose native request/file links and, when an authorized download provider is installed and release completed, explicit preview/download actions. Authenticated delivery uses verified response bytes and a durable completion receipt; its URL is never rendered as an ordinary browser link or iframe. PDF display uses the shared canvas preview component. The resulting preview remains a retained, audited file. +**Preview layout** shows a label as it would print, before anything is printed and without saving anything. It is an action on a template (its current draft, for a person with `document-generation.configure`) and on a published revision (for a person with `document-generation.view`), and the template record screen shows the same picture in its **Label preview** section. The person chooses the data (a sample written in the scene under `samples`, the same sample with every bound text filled with capital W to the `maxLength` it declares, or one source record), optionally another stock, and a resolution of 203, 300 or 600 dpi. A record preview also needs `document-generation.generate`; the record is read with the person's own permissions and record locks, and one they cannot read is refused as not found. The longest values picture is indicative: W is not the widest character of every font, and the renderer's own proof of the declared length at publication decides whether every value fits. A revision row that is not a publication (the frozen draft a retained preview writes) is not previewed: it is answered as not found, and a draft is previewed from its template. The picture is decoded from the bytes the print path validated, one pixel per printer dot, and is shown with whether it is exact, whether the label can print, its size in dots and millimetres, and each refusal, warning and note with the element it concerns. A version 1 scene is drawn with its printer font text as outlined, crossed areas and is marked as not exact; a label that would paint more than a preview draws is said to be too dense to preview and prints all the same; a page (PDF) document is not previewed this way. No revision, request, audit event or file is written, and the picture and the record's values are not kept in the process state: the client fetches the preview widget by the identifier of the run, the widget reads the person's choices from the state of that run (so the record is not a parameter of the request), and the picture exists only in that answer. Samples must be synthetic. See the [design](design/LABEL-LAYOUT-FONTS-PREVIEW.md), section 6. + +**Generate retained preview file** (the action formerly labelled "Preview draft") accepts one source record identity and PDF/ZPL selection. It creates an immutable PREVIEW revision, executes the ordinary source authorization, isolated rendering, strict audit and Record Files intake workflow, and leaves the active published revision unchanged. The preview has a normal generation request and retained attachment. Retrying its process request key returns the same request; it does not regenerate bytes. Quarantine rules still apply. Results expose native request/file links and, when an authorized download provider is installed and release completed, explicit preview/download actions. Authenticated delivery uses verified response bytes and a durable completion receipt; its URL is never rendered as an ordinary browser link or iframe. PDF display uses the shared canvas preview component. The resulting preview remains a retained, audited file. **Barcode catalog and template examples** displays the optional renderer's bounded packaged catalog plus editable FreeMarker HTML and restricted ZPL JSON examples. The renderer owns the catalog and its supported options; the foundation copies no encoder registry and does not evaluate source to display help. An unavailable catalog provider fails explicitly. Renderer version qualification and physical barcode acceptance remain separate. diff --git a/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md b/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md index 0173744..b13e679 100644 --- a/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md +++ b/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md @@ -564,6 +564,92 @@ for it. **A host application** gets all of the above by registering the QBit as it does now. Forge adds help entries for the new process, fields and widget (its help coverage test requires them) and nothing else. +**Decided while building the preview (P6b).** + +- **The spike.** A composite widget whose `IMAGE` block has a data URL as its `path` renders as an `` in the + frontend a host serves, both in a process step (seeded from the process value of the widget's name, and + fetched when no such value exists) and in a record screen section that names the widget. This was run as + component tests against the frontend's released source in a simulated browser document, with the payload shape + the backend's block classes serialise to (asserted in this repository's tests); it was not run in a real + browser against a running host. The `FILE_PREVIEW` fallback was not needed. +- **The result step fetches the widget; it is not seeded.** QQQ stores the state of a process after every + backend step and when it stops at a screen, and never removes it when the process ends: the in-memory provider + drops an entry five hours after the process started (a sweep every thirty minutes), and the shared table + provider keeps a row (encrypted when a key is configured) for its configured lifetime, twelve hours by default. A seeded widget would therefore put the picture of a real + record in memory for hours, or in a database table, which section 9 forbids. So the process state holds the + person's choices only (which template or revision, the data source, the sample name, the source table, the + record's identity, the stock and the resolution). The result step names the widget, the client requests it, + the request is authorised and drawn like any other, and the picture, the values and the diagnostics exist + only in that answer. The record's identity stays in the process state for the lifetime above; no value of the + record does. +- **The widget is requested by the run, not by the choices (independent review).** A client sends every plain + process value (a text, a number, a boolean) as a parameter of the widget request, and QQQ's widget route + writes the parameters of a request that fails or takes over a second to the application log. A render in an + isolated worker often takes that long, so the record's identity would have been logged. Once the form is + accepted, its choices are therefore held as one structured process value, which is not a plain value and is + not sent; the widget is given the run's `processUUID` and reads the choices from the state of the person's own + run (QQQ answers only the session that started it). The widget no longer reads a revision, record, sample, + stock or resolution from any request parameter. +- **One widget, two places.** The record screen sends the table and the template's identity; the action sends + the identifier of its run. The widget never trusts either: every request goes through + `DocumentPreviewService`, which applies the table of section 6.3. On the record screen an author sees the + draft and a person who may only view sees the published revision, both with sample data. +- **A revision that is named must be a publication (independent review).** The revision table also holds the + frozen drafts that the retained preview writes (`purpose` is `PREVIEW`). On the revision path the service + resolves only a publication; any other row is "Document definition not found.", the answer for an identity + that does not exist, from the action and from the widget alike. A draft is previewed through its template + alone, which needs `configure`. An earlier publication that a later one replaced is still a publication. +- **A record is read within the ceiling printing applies (independent review).** Before a source record is read + the service asks what the print path asks of the source table: that the person's channel and credential may + read it. The enforced read that follows is unchanged. +- **Too dense to preview is not "cannot print" (independent review).** When the only thing refused is the + picture (`PREVIEW_TOO_LARGE` and no other refusal), the widget says "This label is too dense to preview; + printing is not affected." (or "too large", for a page of more than sixteen million dots) in place of the + verdict that the layout cannot print. +- **No `Cache-Control` from the widget route.** A widget renderer cannot set a response header and QQQ's widget + route sets none, so a host or its proxy must send `no-store` for widget responses (README, "What a host must + configure for label previews"). +- **Published revision with a record needs `generate` and not `view`**, as the table says. The record selector + (`GenerationPreviewSources`) therefore asks for `generate` alone; it asked for `configure` as well, which + would have kept a person who may generate but not author from choosing a record. Every record it offers is + still read as that person. +- **No draft fingerprint.** The service signature of section 6.3 named one. Nothing is saved against a preview, + so there is nothing a stale draft could overwrite; the preview is always of the draft as it stands. +- **Samples.** The schema allows sixteen samples where section 6.2 says eight; the schema is followed. The form + offers the first sixteen whose names are plain words. A version 1 scene has no `samples`, so its sample + preview has an empty context; a label bound to record values then cannot be drawn without a record, and the + widget says so. +- **Longest values on the host.** The host has no fonts and cannot know a face's widest glyph. It fills each + bound element that declares `maxLength` (1 to 4096) with that many capital W, starting from the first sample + so that barcodes and unbounded values keep their data. A capital W is not the widest glyph of every face, so + the picture is indicative, and the help of the form says so. A preview is a render of the values it is given; + it proves nothing about other values. The renderer's own worst-case layout at publication stays the proof of + fit. +- **What reaches the browser is bounded:** the first page only, a picture of at most two mebibytes (larger, the + facts and diagnostics are shown without it), and fifty diagnostics with a count of the rest. +- **Painting is bounded as well as the page (review of P5).** `ZplRaster` counts the dots every field paints, an + overlap each time, and refuses the field that would take the page past sixteen times its dots as + `PREVIEW_TOO_LARGE`, the renderer's own limit for a scene; a refused scene whose boxes would pass it has no + page and gains that diagnostic. A box is at most four solid bars and a placeholder is a one-dot outline with + its two diagonals, so the work a label can cause is what it inks and not its fields times its page. Where + this document says "hatched", read "outlined and crossed". A version 1 label that painted its page more than + sixteen times over would print and not be previewed. +- **The version question is asked again after any `RENDERER_TOO_OLD` (review of P5).** What the bridge remembers + about the renderer is what it last saw: after an image rollback one job that only 0.2.0 can do reaches the old + renderer and is refused. Any job that ends in that refusal clears what was remembered, so the window is one + job. +- **The released version 1 job frame is pinned (review of P5).** A golden captured at `v1.1.0-RC.2` is compared + with what `render()` sends today, byte for byte except the order of the seven members of `stock`, which the + released bridge already wrote in an order that varies from run to run. +- **A failure's own words are never shown.** The widget shows the fixed sentences of this module and the closed + code of a named render refusal. A message from the host's renderer adapter or context script, which could + quote record data, reaches a caller of the service but not the screen. +- **The existing action stays** beside the new one, relabelled "Generate retained preview file"; its behaviour + and its process name are unchanged. +- **Help** is carried by the metadata itself (process, steps, fields, section, widget and the two choice + lists), as this repository does for its other screens; it has no help bundle file. A host's coverage check + that counts metadata help needs no entries of its own for these. + ### 6.5 Page (PDF) templates - Today's retained preview keeps working for PDF and stays the way to obtain the exact file. @@ -714,9 +800,10 @@ the server sends, so this needs no scanner or app release. The client's own word pixel limits, SVG still refused. Assets are addressed by hash; identical bytes are stored once. - **Bundled fonts.** Unmodified, each with licence, provenance and hash, listed in `THIRD-PARTY.md` and `NOTICE`. - **No preview is stored.** No revision, request, event, retained file or log content. The PNG of a real-record - preview exists in the response and, when shown through a process step, in that process's server-side state until - it expires, as any process screen showing record data does. (That store's retention was not verified.) The record - screen widget uses sample data only, so opening a template never renders customer data. + preview exists in the answer to the widget request and nowhere else: it is never a process value. (Decided in + P6b, section 6.4, once the retention of process state had been read: hours in memory, or a table row with the + shared provider.) The record screen widget uses sample data only, so opening a template never renders customer + data. - **Samples** are synthetic by rule, stated in the field help and the authoring guide. - **Diagnostics and logs** carry element ids and numbers only. - **Permissions** are the existing five; section 6.3 gives the mapping. Uploading and converting need `configure`. diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentDefinitionService.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentDefinitionService.java index 4d27ed9..01be50e 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentDefinitionService.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentDefinitionService.java @@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; @@ -599,6 +600,44 @@ public class DocumentDefinitionService return (snapshot(id, row.getValueInteger("templateId"), row, row)); } + /******************************************************************************* + ** A published snapshot named by a person, for a layout preview. A revision + ** holds either a publication or the frozen draft of a retained preview, and + ** only a publication may be shown to a person who may not author: any other + ** row is answered exactly as an identity that does not exist. A row written + ** before revisions recorded their purpose is a publication, as everywhere + ** else a revision is resolved. + *******************************************************************************/ + GenerationPorts.Revision publishedSnapshot(Integer id) throws QException + { + QRecord row = GenerationStore.get(GenerationTables.REVISION, id); + if(row.getValueString("purpose") != null && !"PUBLISHED".equals(row.getValueString("purpose"))) + { + throw (new QNotFoundException("Document definition not found.")); + } + return (snapshot(id, row.getValueInteger("templateId"), row, row)); + } + + /******************************************************************************* + ** The current draft as the renderer would receive it, for a layout preview. + ** Nothing is frozen or written; the caller has already checked authority. + *******************************************************************************/ + GenerationPorts.Revision draft(Integer templateId) throws QException + { + QRecord row = GenerationStore.get(GenerationTables.TEMPLATE, templateId); + return (snapshot(null, templateId, row, GenerationStore.get(GenerationTables.STOCK, row.getValueInteger("stockId")))); + } + + /******************************************************************************* + ** A stored stock by identity, held to the same physical limits as publication. + *******************************************************************************/ + GenerationPorts.Stock stock(Integer stockId) throws QException + { + GenerationPorts.Stock stock = readStock(GenerationStore.get(GenerationTables.STOCK, stockId)); + validateStock(stock); + return (stock); + } + /******************************************************************************* ** Current binding policy is checked independently from revision pinning. *******************************************************************************/ diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentPreviewService.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentPreviewService.java new file mode 100644 index 0000000..3c5df9e --- /dev/null +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentPreviewService.java @@ -0,0 +1,414 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.documentgeneration; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + +/******************************************************************************* + ** A layout preview: the print path stopped before retention. The configured + ** isolated renderer draws a template's draft or a published revision with + ** sample data, its longest declared values or one record the person may read, + ** and the answer is a picture with its diagnostics. + ** + ** Nothing is written: no revision, request, event or file. This service logs + ** one line that names the actor, the template, the target and the outcome, + ** and never any content or record identity. + ** + ** What a host's own request log holds is outside this service. The "Preview + ** layout" action therefore keeps the person's choices in the state of their + ** run, and the picture is requested by the run's identifier alone, so a record + ** identity is not a parameter of that request (`GenerationPreviewProcesses`). + *******************************************************************************/ +public final class DocumentPreviewService +{ + static final int MAXIMUM_SAMPLES = 16; + static final int MAXIMUM_LENGTH = 4096; + private static final QLogger LOG = QLogger.getLogger(DocumentPreviewService.class); + private static final ObjectMapper JSON = new ObjectMapper(); + + private final DocumentGenerationQBitConfig config; + private final DocumentDefinitionService definitions; + + /*************************************************************************** + ** Where the values of a preview come from. + ***************************************************************************/ + public enum Data + { + ///////////////////////////////////////////////////////////////////////////////////// + // a named synthetic context the author wrote in the scene; the first when unnamed // + ///////////////////////////////////////////////////////////////////////////////////// + SAMPLE, + ///////////////////////////////////////////////////////////////////////////////////// + // the first sample, with every bound text that declares a maximum length filled // + // to that length, so overflow shows // + ///////////////////////////////////////////////////////////////////////////////////// + LONGEST, + ///////////////////////////////////////////////////////////////////////////////////// + // one source record, read as the person asking // + ///////////////////////////////////////////////////////////////////////////////////// + RECORD + } + + /*************************************************************************** + ** What to preview. Exactly one of the template (its current draft) and the + ** revision (a published snapshot) is given. The stock and the render target + ** are optional: absent, the template's or revision's own stock and that + ** stock's resolution are used. + ***************************************************************************/ + public record Options(Integer templateId, Integer revisionId, GenerationPorts.Format format, Data data, String sampleName, String sourceRecordId, Integer stockId, + Integer targetDpi) + { + } + + /*************************************************************************** + ** A preview and the resolution its pages were drawn at. + ***************************************************************************/ + public record Result(GenerationPorts.Preview preview, Integer dpi) + { + } + + /*************************************************************************** + ** What a form needs before the person chooses: the source table of the + ** template and the names of the samples its scene declares. + ***************************************************************************/ + public record Choices(String sourceTable, List sampleNames) + { + } + + /******************************************************************************* + ** Use the host's configured renderer and context script execution. + *******************************************************************************/ + public DocumentPreviewService(DocumentGenerationQBitConfig config) + { + this.config = config; + this.definitions = new DocumentDefinitionService(config); + } + + /******************************************************************************* + ** Draw the preview. A layout that cannot print is a result, not an exception: + ** the preview says so and carries the diagnostics. + *******************************************************************************/ + public Result preview(Options options) throws QException + { + String actor = authorize(options); + GenerationPorts.Revision revision = revision(options); + Map context = context(actor, revision, options); + Integer dpi = options.targetDpi() == null ? revision.stock().dpi() : options.targetDpi(); + try + { + GenerationPorts.Preview preview = config.getRenderer().preview(new GenerationPorts.PreviewRequest(actor, options.format(), revision, context, options.targetDpi(), + config.getMaximumOutputBytes())); + log(actor, options, dpi, preview.printable() ? "PRINTABLE" : "REFUSED", preview.diagnostics().stream().map(GenerationPorts.Diagnostic::code).distinct().sorted().toList()); + return (new Result(preview, dpi)); + } + catch(QException failure) + { + log(actor, options, dpi, "FAILED", List.of()); + throw (foreign(failure)); + } + } + + /******************************************************************************* + ** Everything a preview checks before it renders: the permissions, the + ** template or revision, the stock, and that the chosen record can be read + ** or the chosen sample exists. A form calls this to refuse at once. + *******************************************************************************/ + public void check(Options options) throws QException + { + authorize(options); + GenerationPorts.Revision revision = revision(options); + if(options.data() == Data.RECORD) + { + GenerationSecurity.requireRead(revision.sourceTable()); + GenerationSecurity.source(new GenerationPorts.Source(revision.sourceTable(), options.sourceRecordId())); + } + else + { + sample(scene(revision.zplSource()), options.sampleName()); + } + } + + /******************************************************************************* + ** The source table and sample names of a draft or a published revision, under + ** the permission a sample preview of it needs. + *******************************************************************************/ + public Choices choices(Integer templateId, Integer revisionId) throws QException + { + Options sample = new Options(templateId, revisionId, GenerationPorts.Format.ZPL, Data.SAMPLE, null, null, null, null); + authorize(sample); + GenerationPorts.Revision revision = revision(sample); + JsonNode samples = scene(revision.zplSource()).path("samples"); + List names = new ArrayList<>(); + for(Iterator name = samples.fieldNames(); name.hasNext() && names.size() < MAXIMUM_SAMPLES; ) + { + String candidate = name.next(); + if(samples.get(candidate).isObject() && candidate.matches("[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}")) + { + names.add(candidate); + } + } + return (new Choices(revision.sourceTable(), List.copyOf(names))); + } + + /******************************************************************************* + ** The published revision of a template, for a person who may view it but not + ** author it; absent when the template has never been published. + *******************************************************************************/ + public Integer publishedRevision(Integer templateId) throws QException + { + GenerationSecurity.require("view"); + return (GenerationStore.get(GenerationTables.TEMPLATE, templateId).getValueInteger("publishedRevisionId")); + } + + /******************************************************************************* + ** Who may preview what. A draft needs `configure`; a published revision needs + ** `view`; a real record needs `generate` (beside `configure` for a draft, and + ** in place of `view` for a published revision) and is then read as the + ** person. No permission is new. A draft is reached through its template + ** alone: a revision named here is a publication or it is not found + ** (`revision`), so neither `view` nor `generate` shows a draft. + *******************************************************************************/ + private String authorize(Options options) throws QException + { + if(options == null || options.data() == null || options.format() == null || (options.templateId() == null) == (options.revisionId() == null) + || (options.targetDpi() != null && !List.of(203, 300, 600).contains(options.targetDpi()))) + { + throw new QBadRequestException("A preview needs a template draft or a published revision, a data source and a resolution of 203, 300 or 600 dpi."); + } + boolean record = options.data() == Data.RECORD; + String actor = GenerationSecurity.require(options.revisionId() == null ? "configure" : record ? "generate" : "view"); + if(record) + { + GenerationSecurity.require("generate"); + if(options.sourceRecordId() == null || options.sourceRecordId().isBlank()) + { + throw new QBadRequestException("Choose the record to preview."); + } + } + if(options.format() != GenerationPorts.Format.ZPL) + { + throw new QBadRequestException("Preview is available for labels only."); + } + return (actor); + } + + /******************************************************************************* + ** The draft as it stands or the published snapshot, on the chosen stock. + ** Neither is written anywhere. A revision that is not a publication (the + ** frozen draft of a retained preview) is not found, in the words and with + ** the type of an identity that does not exist. + *******************************************************************************/ + private GenerationPorts.Revision revision(Options options) throws QException + { + GenerationPorts.Revision revision = options.revisionId() == null ? definitions.draft(options.templateId()) : definitions.publishedSnapshot(options.revisionId()); + if(revision.zplSource() == null || revision.zplSource().isBlank()) + { + throw new QBadRequestException("This template has no label (ZPL) source to preview."); + } + if(options.stockId() == null) + { + return (revision); + } + return (new GenerationPorts.Revision(revision.id(), revision.templateId(), revision.sourceTable(), definitions.stock(options.stockId()), revision.pdfSource(), + revision.zplSource(), revision.scriptArtifact(), revision.assets(), revision.outputBaseName(), revision.outputNameContextKey())); + } + + /******************************************************************************* + ** The values the renderer receives. A record is read as the person, with + ** their permissions and record locks and within what their channel and + ** credential may read (the ceiling printing applies to the source table), + ** before any context is built, and the context is then built exactly as + ** generation builds it, including the pinned context script. The record is + ** read again after the script, as generation does, so access lost meanwhile + ** still refuses. + *******************************************************************************/ + private Map context(String actor, GenerationPorts.Revision revision, Options options) throws QException + { + if(options.data() != Data.RECORD) + { + JsonNode scene = scene(revision.zplSource()); + Map sample = sample(scene, options.sampleName()); + if(options.data() == Data.LONGEST) + { + longest(scene.path("elements"), sample, 0); + } + return (SafeContext.copy(sample)); + } + GenerationSecurity.requireRead(revision.sourceTable()); + QRecord selected = GenerationSecurity.source(new GenerationPorts.Source(revision.sourceTable(), options.sourceRecordId())); + GenerationPorts.Source source = new GenerationPorts.Source(revision.sourceTable(), + selected.getValueString(QContext.getQInstance().getTable(revision.sourceTable()).getPrimaryKeyField())); + Map record = SafeContext.copy(selected.getValues()); + Map context = new LinkedHashMap<>(); + if(revision.scriptArtifact() != null) + { + try + { + context.putAll(SafeContext.copy(config.getContextExecution().prepare(new GenerationPorts.ContextRequest(actor, revision.scriptArtifact(), source, record)))); + } + catch(QException failure) + { + throw (foreign(failure)); + } + GenerationSecurity.require("generate"); + GenerationSecurity.requireRead(source.tableName()); + GenerationSecurity.source(source); + } + context.put("record", record); + return (SafeContext.copy(context)); + } + + /******************************************************************************* + ** A failure of the host's renderer or context script keeps its message for + ** the caller of this service, but loses any type that a screen would show to + ** a person as an explanation: such a message could quote record data, and + ** only this service's own fixed sentences are shown. A named render refusal + ** is closed vocabulary and stays as it is. + *******************************************************************************/ + private static QException foreign(QException failure) + { + return (failure instanceof GenerationPorts.RenderException || failure.getClass() == QException.class ? failure : new QException(failure.getMessage())); + } + + /******************************************************************************* + ** The scene as JSON, read only for its samples and declared lengths. A source + ** this host cannot read declares none; the renderer reports what is wrong. + *******************************************************************************/ + private static JsonNode scene(String source) + { + try + { + return (JSON.readTree(source)); + } + catch(JsonProcessingException unreadable) + { + return (JSON.missingNode()); + } + } + + /******************************************************************************* + ** The named sample, or the first when no name is given. A scene without + ** samples gives an empty context; a name it does not declare is refused. + *******************************************************************************/ + @SuppressWarnings("unchecked") + private static Map sample(JsonNode scene, String name) throws QBadRequestException + { + JsonNode samples = scene.path("samples"); + boolean named = name != null && !name.isBlank(); + int seen = 0; + for(Iterator candidate = samples.fieldNames(); candidate.hasNext() && seen < MAXIMUM_SAMPLES; seen++) + { + String key = candidate.next(); + if(samples.get(key).isObject() && (!named || name.equals(key))) + { + return (JSON.convertValue(samples.get(key), LinkedHashMap.class)); + } + } + if(named) + { + throw new QBadRequestException("The scene declares no sample of that name."); + } + return (new LinkedHashMap<>()); + } + + /******************************************************************************* + ** Fill every bound element that declares a maximum length with a value of + ** that length. The host has no fonts, so it cannot know a face's widest + ** glyph: a capital W is wide but is not the widest glyph of every face, so + ** the picture is indicative. A preview is a render of these values and + ** proves nothing about other values; whether every value of the declared + ** length fits is decided by the renderer when it validates the template for + ** publication. + *******************************************************************************/ + private static void longest(JsonNode elements, Map context, int depth) + { + for(JsonNode element : elements) + { + JsonNode path = element.path("valuePath"); + JsonNode length = element.path("maxLength"); + if(path.isTextual() && length.isInt() && length.intValue() >= 1 && length.intValue() <= MAXIMUM_LENGTH) + { + put(context, path.textValue().split("/", -1), "W".repeat(length.intValue())); + } + if(depth < 16) + { + longest(element.path("children"), context, depth + 1); + } + } + } + + /******************************************************************************* + ** Set one value by its JSON pointer, creating the objects on the way. A path + ** that is not a pointer into objects is left alone. + *******************************************************************************/ + @SuppressWarnings("unchecked") + private static void put(Map context, String[] segments, String value) + { + if(segments.length < 2 || segments.length > 17 || !segments[0].isEmpty()) + { + return; + } + Map at = context; + for(int index = 1; index < segments.length - 1; index++) + { + Object next = at.get(key(segments[index])); + if(!(next instanceof Map)) + { + next = new LinkedHashMap(); + at.put(key(segments[index]), next); + } + at = (Map) next; + } + at.put(key(segments[segments.length - 1]), value); + } + + /******************************************************************************* + ** One pointer segment as the property name it stands for. + *******************************************************************************/ + private static String key(String segment) + { + return (segment.replace("~1", "/").replace("~0", "~")); + } + + /******************************************************************************* + ** The one line this service logs for a preview: identities, the target and + ** the outcome. No source, sample, record identity or record value is in it. + *******************************************************************************/ + private static void log(String actor, Options options, Integer dpi, String outcome, List codes) + { + LOG.info("Document layout preview", logPair("actor", actor), logPair("templateId", options.templateId()), logPair("revisionId", options.revisionId()), + logPair("data", options.data()), logPair("dpi", dpi), logPair("outcome", outcome), logPair("diagnostics", String.join(",", codes))); + } +} diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationAuthoringProcesses.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationAuthoringProcesses.java index 76fd0cc..6f54bc2 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationAuthoringProcesses.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationAuthoringProcesses.java @@ -133,7 +133,7 @@ public final class GenerationAuthoringProcesses } register(instance, GenerationTables.TEMPLATE, "naming", "Edit output filename"); register(instance, GenerationTables.TEMPLATE, "publish", "Validate and publish draft"); - register(instance, GenerationTables.TEMPLATE, "preview", "Preview draft"); + register(instance, GenerationTables.TEMPLATE, "preview", "Generate retained preview file"); register(instance, GenerationTables.TEMPLATE, "asset", "Select retained asset for draft"); register(instance, GenerationTables.TEMPLATE, "removeAsset", "Remove included draft asset"); register(instance, GenerationTables.TEMPLATE, "replaceAsset", "Replace included draft asset"); diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java index 52cf9e6..9c9b866 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewProcesses.java @@ -21,15 +21,76 @@ package com.kingsrook.qbits.documentgeneration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessMetaDataAdjustment; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessState; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData; /******************************************************************************* - ** Layout preview processes for a template: sample, stress and record data. - ** The producer already calls this registration point; it registers nothing yet. + ** The "Preview layout" action on a template (its current draft) and on a + ** published revision: a form for the data, the stock and the resolution, and + ** a result that is the preview widget. + ** + ** The process keeps the person's choices and nothing else. The picture, the + ** values it was drawn from and its diagnostics are never process values: the + ** result step names the widget, the client fetches it, and the picture exists + ** only in that answer. QQQ keeps the state of a finished process for hours + ** (and, with a shared state provider, in a table), so a picture of record + ** data must not be put there. + ** + ** The widget is fetched by the identifier of the run, not by the choices. A + ** client sends every plain process value (a text, a number, a boolean) as a + ** parameter of the widget request, and QQQ's widget route writes the + ** parameters of a request that fails or takes over a second to the + ** application log. So once the form is accepted its choices, the record among + ** them, are held as one structured process value (`CHOICES`), which is not a + ** plain value and so not a request parameter, and the widget reads them from + ** the state of the person's own run. *******************************************************************************/ final class GenerationPreviewProcesses { + static final String DATA = "document_generation_preview_data"; + static final String RESOLUTION = "document_generation_preview_resolution"; + static final String CHOICES = "document_generation_preview_choices"; + + private static final List FORM = List.of("dataSource", "sampleName", "sourceRecordId", "stockId", "targetDpi"); + private static final String RUN = "[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"; + /******************************************************************************* ** Metadata namespace. *******************************************************************************/ @@ -42,5 +103,229 @@ final class GenerationPreviewProcesses *******************************************************************************/ static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException { + instance.addPossibleValueSource(new QPossibleValueSource().withName(DATA).withLabel("Preview data").withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING) + .withEnumValues(List.of(new QPossibleValue<>("SAMPLE", "Sample data written in the scene"), new QPossibleValue<>("LONGEST", "Longest values"), + new QPossibleValue<>("RECORD", "A record I choose"))) + .withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help("Where the values of a layout preview come from: a sample the author wrote in the scene, the same sample " + + "with every bound text filled with capital W to the maximum length it declares, or one source record the person may read. The longest values picture is indicative: " + + "W is not the widest character of every font, and the renderer's own proof of the declared length, made when the template is published, decides whether every value fits."))); + instance.addPossibleValueSource(new QPossibleValueSource().withName(RESOLUTION).withLabel("Preview resolution").withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.INTEGER) + .withEnumValues(List.of(new QPossibleValue<>(203, "203 dpi"), new QPossibleValue<>(300, "300 dpi"), new QPossibleValue<>(600, "600 dpi"))) + .withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help("The printer resolutions a label can be drawn for. A version 1 scene is drawn only at the resolution of its stock."))); + instance.addWidget(GenerationPreviewWidget.metaData()); + boolean selector = instance.getPossibleValueSource(GenerationAuthoringProcesses.PREVIEW_SOURCE) != null; + for(String table : List.of(GenerationTables.TEMPLATE, GenerationTables.REVISION)) + { + boolean draft = GenerationTables.TEMPLATE.equals(table); + QFrontendStepMetaData options = new QFrontendStepMetaData().withName("options").withLabel("Preview layout") + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)) + .withHelpContent(GenerationTables.help("Choose what the label is drawn with. Sample data and longest values come from the scene and involve no record. " + + "A record is read with your own permissions. Nothing is saved, and no file is created.")) + .withFormField(new QFieldMetaData("dataSource", QFieldType.STRING).withLabel("Data to draw with").withIsRequired(true).withPossibleValueSourceName(DATA) + .withHelpContent(GenerationTables.help("Sample data uses a sample written in the scene. Longest values fills every bound text with capital W to the maximum length " + + "it declares, so overflow shows. That picture is indicative: W is not the widest character of every font, and the renderer's own proof of the declared length, " + + "made when the template is published, decides whether every value fits. A record uses one source record you can read."))) + .withFormField(new QFieldMetaData("sampleName", QFieldType.STRING).withLabel("Sample (blank = the first in the scene)").withMaxLength(64) + .withHelpContent(GenerationTables.help("The name of a sample the scene declares under samples. Samples are written by the template's author and must be synthetic: " + + "never copy real record data into a scene."))); + if(selector) + { + options.withFormField(new QFieldMetaData("sourceTable", QFieldType.STRING).withLabel("Records this template belongs to").withIsEditable(false) + .withPossibleValueSourceName(GenerationAuthoringProcesses.SOURCES) + .withHelpContent(GenerationTables.help("The table whose records this template is generated for. It is set on the template and cannot be changed here."))); + } + QFieldMetaData record = new QFieldMetaData("sourceRecordId", QFieldType.STRING).withLabel("Record (only for a record preview)").withMaxLength(255) + .withHelpContent(GenerationTables.help("The record to draw the label with. Only records you can read are offered, and one you cannot read is refused as not found. " + + "Its values are used for this one picture and are not stored or logged.")); + if(selector) + { + record.setPossibleValueSourceName(GenerationAuthoringProcesses.PREVIEW_SOURCE); + } + options.withFormField(record) + .withFormField(new QFieldMetaData("stockId", QFieldType.INTEGER).withLabel("Stock (blank = " + (draft ? "the template's own)" : "the revision's own)")) + .withPossibleValueSourceName(GenerationTables.STOCK) + .withHelpContent(GenerationTables.help("Draw the label on another stock to see how the layout behaves there. The template is not changed."))) + .withFormField(new QFieldMetaData("targetDpi", QFieldType.INTEGER).withLabel("Resolution (blank = the stock's own)").withPossibleValueSourceName(RESOLUTION) + .withHelpContent(GenerationTables.help("The printer resolution to draw for. A version 2 scene can be drawn at 203, 300 or 600 dpi; " + + "a version 1 scene only at the resolution of its stock."))); + instance.addProcess(new QProcessMetaData().withName(name(table)).withTableName(table).withLabel("Preview layout").withIcon(new QIcon("image")) + .withMinInputRecords(1).withMaxInputRecords(1) + .withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help((draft ? "Draw the current draft of this template" : "Draw this published revision") + + " as the label that would print, with its warnings and refusals, before anything is printed. Nothing is saved and no file is created.")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(Permission.class))) + .withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class)) + .withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(table)))) + .withStep(options) + .withStep(new QBackendStepMetaData().withName("check").withCode(new QCodeReference(Check.class))) + .withStep(new QFrontendStepMetaData().withName("result").withLabel("Layout preview") + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", GenerationPreviewWidget.NAME)) + .withHelpContent(GenerationTables.help("The picture is decoded from the exact bytes that would be sent to the printer. The first line says whether it is exact " + + "and whether the label can print; the lines below the picture give its size and each refusal, warning and note with the element it concerns.")))); + } + } + + /******************************************************************************* + ** One stable name per table: the draft on a template, the snapshot on a revision. + *******************************************************************************/ + static String name(String table) + { + return (table + "_preview_layout"); + } + + /******************************************************************************* + ** The choices an accepted form left in the state of the person's own run, + ** for the widget of the result step. QQQ answers only the person who started + ** the run, in the session that started it, and only while they may still run + ** the action; a run that is unknown, has expired or holds no choices is one + ** refusal. The choices are authorised again by the preview service. + *******************************************************************************/ + static DocumentPreviewService.Options choices(String processUUID) throws QException + { + Optional state = processUUID != null && processUUID.matches(RUN) ? RunProcessAction.getStateForUser(processUUID, null) : Optional.empty(); + if(state.isEmpty() || !(state.get().getValues().get(CHOICES) instanceof Map chosen)) + { + throw new QBadRequestException("This preview is no longer available. Run Preview layout again."); + } + return (options(name -> chosen.get(name) == null ? null : String.valueOf(chosen.get(name)))); + } + + /******************************************************************************* + ** The choices of the form, as the form submits them or as the state of the + ** run holds them. Anything that is not a choice the form offers is refused. + *******************************************************************************/ + static DocumentPreviewService.Options options(UnaryOperator value) throws QBadRequestException + { + try + { + return (new DocumentPreviewService.Options(integer(value.apply("previewTemplateId")), integer(value.apply("previewRevisionId")), GenerationPorts.Format.ZPL, + DocumentPreviewService.Data.valueOf(value.apply("dataSource")), text(value.apply("sampleName")), text(value.apply("sourceRecordId")), + integer(value.apply("stockId")), integer(value.apply("targetDpi")))); + } + catch(RuntimeException invalid) + { + throw new QBadRequestException("Choose the data, the stock and the resolution of the preview from the form."); + } + } + + /******************************************************************************* + ** An absent or blank value is no choice. + *******************************************************************************/ + private static String text(String value) + { + return (value == null || value.isBlank() ? null : value); + } + + /******************************************************************************* + ** A whole number, or no choice. + *******************************************************************************/ + private static Integer integer(String value) + { + return (text(value) == null ? null : Integer.valueOf(value.trim())); + } + + /******************************************************************************* + ** Who may open the action: an author on a template, a viewer on a revision. + ** A record preview also needs `generate`, which the service checks when a + ** record is chosen. + *******************************************************************************/ + public static class Permission implements CustomPermissionChecker + { + /******************************************************************************* + ** Discovery and direct execution both apply the rule. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + GenerationSecurity.require(name(GenerationTables.TEMPLATE).equals(metadata.getName()) ? "configure" : "view"); + } + } + + /******************************************************************************* + ** Before the form: which template or revision, its source table, and the + ** samples its scene declares. + *******************************************************************************/ + public static class Prepare implements BackendStep + { + /******************************************************************************* + ** Only the selected record's identity is used; everything else is read + ** again under the person's authority. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new Permission().checkPermissionsThrowing(input, input.getProcess()); + if(input.getRecords() == null || input.getRecords().size() != 1) + { + throw new QBadRequestException("Select one template or one published revision."); + } + Integer id = input.getRecords().getFirst().getValueInteger("id"); + boolean draft = name(GenerationTables.TEMPLATE).equals(input.getProcess().getName()); + QInstance instance = QContext.getQInstance(); + DocumentPreviewService.Choices choices = new DocumentPreviewService(GenerationProcesses.config(instance)).choices(draft ? id : null, draft ? null : id); + output.addValue(draft ? "previewTemplateId" : "previewRevisionId", id); + output.addValue("sourceTable", choices.sourceTable()); + output.addValue("dataSource", DocumentPreviewService.Data.SAMPLE.name()); + ProcessMetaDataAdjustment adjustment = new ProcessMetaDataAdjustment(); + if(!choices.sampleNames().isEmpty()) + { + List> names = new ArrayList<>(); + choices.sampleNames().forEach(name -> names.add(new QPossibleValue<>(name, name))); + QFieldMetaData sample = field(input, "sampleName"); + sample.setInlinePossibleValueSource(new QPossibleValueSource().withName("document_generation_preview_samples").withType(QPossibleValueSourceType.ENUM) + .withIdType(QFieldType.STRING).withEnumValues(names)); + adjustment.withUpdatedField(sample); + } + var source = instance.getTable(choices.sourceTable()); + var selector = instance.getPossibleValueSource(GenerationAuthoringProcesses.sourceRecordChoices(choices.sourceTable())); + if(source != null && selector != null) + { + QFieldMetaData record = field(input, "sourceRecordId"); + record.setType(source.getField(source.getPrimaryKeyField()).getType()); + record.setLabel("Record (only for a record preview): " + Objects.requireNonNullElse(source.getLabel(), source.getName())); + record.setPossibleValueSourceName(selector.getName()); + adjustment.withUpdatedField(record); + } + output.setProcessMetaDataAdjustment(adjustment); + } + + /******************************************************************************* + ** A copy of one form field, to adjust for this run only. + *******************************************************************************/ + private static QFieldMetaData field(RunBackendStepInput input, String name) + { + return (input.getProcess().getFrontendStep("options").getFormFields().stream().filter(candidate -> name.equals(candidate.getName())).findFirst().orElseThrow().clone()); + } + } + + /******************************************************************************* + ** After the form: refuse at once what the preview would refuse, so the + ** person sees the reason on the form. Nothing is drawn, and nothing is kept + ** but the choices. + *******************************************************************************/ + public static class Check implements BackendStep + { + /******************************************************************************* + ** The same checks the widget's request makes again when it draws. The + ** accepted choices then leave the plain process values, which a client + ** sends with the widget request, for the one structured value the widget + ** reads from the state of the run. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + new Permission().checkPermissionsThrowing(input, input.getProcess()); + new DocumentPreviewService(GenerationProcesses.config(QContext.getQInstance())).check(options(input::getValueString)); + LinkedHashMap chosen = new LinkedHashMap<>(); + for(String name : Stream.concat(Stream.of("previewTemplateId", "previewRevisionId"), FORM.stream()).toList()) + { + String value = input.getValueString(name); + if(value != null) + { + chosen.put(name, value); + } + } + FORM.forEach(output.getValues()::remove); + output.addValue(CHOICES, chosen); + } } } diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewSources.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewSources.java index c9434d9..517de8d 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewSources.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewSources.java @@ -63,7 +63,11 @@ public class GenerationPreviewSources implements QCustomPossibleValueProvider> search(SearchPossibleValueSourceInput input) throws QException { - GenerationSecurity.require("configure"); + ///////////////////////////////////////////////////////////////////////////////////////////// + // a record is chosen to generate from or to preview with, which both need `generate`; an // + // author previews a draft and a viewer previews a published revision, so `configure` is // + // not required of someone who may only generate. Every record is still read as the caller // + ///////////////////////////////////////////////////////////////////////////////////////////// GenerationSecurity.require("generate"); var instance = QContext.getQInstance(); var source = instance.getPossibleValueSource(input.getPossibleValueSourceName()); diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java index bbfec1d..33e523b 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java @@ -21,14 +21,122 @@ package com.kingsrook.qbits.documentgeneration; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Stream; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageBlockData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageStyles; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageValues; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextStyles; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextValues; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; /******************************************************************************* - ** The "Label preview" section of the template record screen. The template - ** table already offers itself here after its own sections; nothing is added yet. + ** The label preview as a standard composite widget: a line that says whether + ** the picture is what prints, the picture itself (the page's PNG as a data + ** URL, at the label's physical size), the facts, and one line per diagnostic. + ** + ** It appears in two places with no host code. On the template record screen + ** it is the "Label preview" section, drawn with sample data only, so opening + ** a template never renders record data. In the "Preview layout" action it is + ** the result step, drawn from the choices the person made. Either way the + ** client fetches the widget, the picture is drawn for that request and + ** returned in its answer, and nothing of it is kept on the server: not in a + ** table, not in a file and not in the process's state. + ** + ** The request names a template record (the record screen) or a run of the + ** action (the result step) and nothing else is read from it: no revision, + ** record, sample, stock or resolution is taken from a request parameter. QQQ's + ** widget route logs the parameters of a request that fails or takes over a + ** second, so what a request carries is what that log can hold: the widget's + ** name with a table name and a template identity, or with the identifier of a + ** run and the plain process values a client adds to it. After the form those + ** are the template or revision identity, the source table's name and whatever + ** the client itself sent to start the run (its selection of the template or + ** revision row); no choice of the form is among them. + ** + ** A widget renderer cannot set response headers and the widget route sets no + ** `Cache-Control`, so the host or its proxy must send `no-store` for widget + ** responses (see the README). *******************************************************************************/ final class GenerationPreviewWidget { + static final String NAME = "document_generation_preview"; + static final String SECTION = "labelPreview"; + static final int MAXIMUM_IMAGE_BYTES = 2 * 1024 * 1024; + static final int MAXIMUM_LINES = 50; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // the closed set of verdicts for a label whose picture is not drawn although nothing says it // + // cannot print: a preview paints at most sixteen times the page and draws at most sixteen // + // million dots, and neither limit is a limit of printing // + //////////////////////////////////////////////////////////////////////////////////////////////// + static final String TOO_DENSE = "This label is too dense to preview; printing is not affected."; + static final String TOO_LARGE = "This label is too large to preview; printing is not affected."; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // one plain sentence for each code of the closed vocabulary that a preview can report; a // + // code without one is shown by its name, element and numbers alone // + //////////////////////////////////////////////////////////////////////////////////////////////// + private static final Map MEANINGS = Map.ofEntries( + Map.entry("SCENE_INVALID", "The scene is not valid at the place named."), + Map.entry("STOCK_MISMATCH", "The scene was drawn for another stock size."), + Map.entry("TARGET_REQUIRES_SCENE_2", "Only a version 2 scene can be drawn at a resolution other than its stock's."), + Map.entry("ELEMENT_OUTSIDE_PRINTABLE_AREA", "The element reaches outside the printable area."), + Map.entry("TEXT_DOES_NOT_FIT", "The text does not fit its box."), + Map.entry("VALUE_MISSING", "The data has no value for this element."), + Map.entry("VALUE_TOO_LONG", "The value is longer than the maximum length the element declares."), + Map.entry("FONT_UNKNOWN", "The font is not available."), + Map.entry("FONT_MISSING_GLYPH", "The font has no shape for a character of the value."), + Map.entry("RESERVED_FONT_NAME", "An included font uses a name reserved for the bundled fonts."), + Map.entry("BARCODE_DOES_NOT_FIT", "The barcode does not fit its box at the smallest module allowed."), + Map.entry("GS1_INVALID", "The GS1 data is not valid."), + Map.entry("ASSET_UNKNOWN", "The scene names an asset the template does not include."), + Map.entry("PREVIEW_TOO_LARGE", "The picture would be too large to draw."), + Map.entry("RENDERER_TOO_OLD", "The renderer in use cannot draw this yet."), + Map.entry("TEXT_TRUNCATED", "The text was cut short."), + Map.entry("TEXT_SMALL_FOR_DPI", "The text is small for this resolution."), + Map.entry("TEXT_LIGHT_FACE", "A light face may print faintly at this size."), + Map.entry("FIT_NOT_PROVEN", "No maximum length is declared, so the fit is not proven for every value."), + Map.entry("BARCODE_MODULE_BELOW_RECOMMENDED", "The barcode's narrow bar is below the recommended width."), + Map.entry("QUIET_ZONE_INTRUDED", "Something intrudes on the barcode's quiet zone."), + Map.entry("ELEMENTS_OVERLAP", "Elements overlap."), + Map.entry("IMAGE_UPSCALED", "The image was enlarged and may look coarse."), + Map.entry("DEVICE_UNIT_USED", "A device unit (dots) is used, so the layout depends on the resolution."), + Map.entry("PRINTER_FONT_NOT_SHOWN", "Printer font text cannot be drawn; its estimated area is outlined and crossed."), + Map.entry("TEXT_SHRUNK", "The text was reduced to fit."), + Map.entry("TEXT_WRAPPED", "The text was wrapped."), + Map.entry("BARCODE_MODULE", "The width of the barcode's narrow bar."), + Map.entry("IMAGE_SCALED", "The image was scaled.")); + /******************************************************************************* ** Metadata namespace. *******************************************************************************/ @@ -37,9 +145,300 @@ final class GenerationPreviewWidget } /******************************************************************************* - ** Called once for the template table, after its standard sections. + ** Called once for the template table, after its standard sections: the + ** preview goes directly below the overview. *******************************************************************************/ static void section(QTableMetaData table) { + List sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections()); + sections.add(Math.min(1, sections.size()), new QFieldSection(SECTION, new QIcon("image"), Tier.T2).withLabel("Label preview").withWidgetName(NAME) + .withHelpContent(GenerationTables.help("The label as it would print, drawn from the current draft with the first sample the scene declares, at the stock's own resolution. " + + "It is drawn each time the record opens and nothing is saved. Use Preview layout to choose other data, another stock or another resolution."))); + table.setSections(sections); + } + + /******************************************************************************* + ** The widget a host gets by registering the QBit. Its renderer decides what + ** each person may see, so the rule here is only that the person holds one of + ** the existing permissions a preview can be drawn under. + *******************************************************************************/ + static QWidgetMetaData metaData() + { + return (new QWidgetMetaData().withName(NAME).withType(WidgetType.COMPOSITE.getType()).withLabel("Label preview").withIsCard(true).withGridColumns(12) + .withShowReloadButton(true).withCodeReference(new QCodeReference(Renderer.class)) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(Permission.class))) + .withHelpContent("label", GenerationTables.help("The picture is decoded from the exact bytes that would be sent to the printer, one pixel per printer dot. " + + "Refusals mean the label cannot print as it stands; warnings do not stop printing. Reload after editing the layout."))); + } + + /******************************************************************************* + ** A person with none of the document permissions never reaches the renderer. + *******************************************************************************/ + public static class Permission implements CustomPermissionChecker + { + /******************************************************************************* + ** Discovery and direct execution both apply the rule. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + var session = QContext.getQSession(); + if(session == null || Stream.of("view", "configure", "generate").noneMatch(suffix -> session.hasPermission("document-generation." + suffix))) + { + throw new QPermissionDeniedException("Document Generation permission denied."); + } + } + } + + /******************************************************************************* + ** The standard widget route: the request names a template record or a run of + ** the action, and what it leads to is authorised again by the preview service. + *******************************************************************************/ + public static class Renderer extends AbstractWidgetRenderer + { + /******************************************************************************* + ** Draw for this request only; the answer is not stored. + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + return (new RenderWidgetOutput(widget(GenerationProcesses.config(QContext.getQInstance()), input.getQueryParams() == null ? Map.of() : input.getQueryParams()))); + } + } + + /******************************************************************************* + ** The whole widget for one request. A refusal this module states in its own + ** words (no source to draw, an unreadable record, an unknown sample) is one + ** line; a failure of the renderer is a fixed sentence, never its message; a + ** label too dense to draw is said to be so, and to print all the same; a + ** missing permission stays a denial. + *******************************************************************************/ + static CompositeWidgetData widget(DocumentGenerationQBitConfig config, Map parameters) throws QException + { + CompositeWidgetData data = new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN); + try + { + DocumentPreviewService service = new DocumentPreviewService(config); + DocumentPreviewService.Options options = options(service, parameters); + blocks(data, options, service.preview(options)); + } + catch(QPermissionDeniedException denied) + { + throw (denied); + } + catch(QBadRequestException | QNotFoundException refused) + { + data.addBlock(line(refused.getMessage(), TextStyles.StandardColor.WARNING, true)); + } + catch(GenerationPorts.RenderException named) + { + boolean dense = named.code() == GenerationPorts.FailureCode.PREVIEW_TOO_LARGE; + data.addBlock(line(dense ? TOO_DENSE : "The preview could not be drawn (" + named.code() + ").", dense ? TextStyles.StandardColor.WARNING : TextStyles.StandardColor.ERROR, true)); + } + catch(QException failure) + { + data.addBlock(line("The preview could not be drawn. A label that takes its values from a record needs sample data in its scene, or use Preview layout with a record.", + TextStyles.StandardColor.ERROR, true)); + } + return (data); + } + + /******************************************************************************* + ** On a record screen the client sends the table and the record: the draft + ** for a person who may author it, the published revision for a person who + ** may only view it, always with sample data. In the action the client sends + ** the identifier of the run, and the choices of the form are read from the + ** state of that run; whatever else the request carries is not read. + *******************************************************************************/ + private static DocumentPreviewService.Options options(DocumentPreviewService service, Map parameters) throws QException + { + if(parameters.containsKey("processUUID")) + { + return (GenerationPreviewProcesses.choices(parameters.get("processUUID"))); + } + if(!GenerationTables.TEMPLATE.equals(parameters.get("tableName"))) + { + throw new QBadRequestException("The label preview belongs to a document template."); + } + DocumentPreviewService.Options screen = GenerationPreviewProcesses.options(name -> switch(name) + { + case "previewTemplateId" -> parameters.get("id"); + case "dataSource" -> "SAMPLE"; + default -> null; + }); + if(QContext.getQSession().hasPermission("document-generation.configure")) + { + return (screen); + } + Integer published = service.publishedRevision(screen.templateId()); + if(published == null) + { + throw new QBadRequestException("This template has no published revision to preview."); + } + return (new DocumentPreviewService.Options(null, published, screen.format(), screen.data(), null, null, null, null)); + } + + /******************************************************************************* + ** The verdict, what was drawn, the picture, the facts and the diagnostics. + *******************************************************************************/ + private static void blocks(CompositeWidgetData data, DocumentPreviewService.Options options, DocumentPreviewService.Result result) + { + GenerationPorts.Preview preview = result.preview(); + boolean page = !preview.pages().isEmpty(); + List refusals = preview.diagnostics().stream().filter(diagnostic -> diagnostic.severity() == GenerationPorts.Diagnostic.Severity.ERROR).toList(); + if(!page && !refusals.isEmpty() && refusals.stream().allMatch(refusal -> GenerationPorts.FailureCode.PREVIEW_TOO_LARGE.name().equals(refusal.code()))) + { + ///////////////////////////////////////////////////////////////////////////////////// + // the only thing refused is the picture: the label was not found unprintable, it // + // would paint or measure more than a preview draws // + ///////////////////////////////////////////////////////////////////////////////////// + data.addBlock(line(refusals.getFirst().limit().containsKey("paintedPercent") ? TOO_DENSE : TOO_LARGE, TextStyles.StandardColor.WARNING, true)); + } + else if(!preview.printable()) + { + data.addBlock(line("This layout cannot print. " + (page ? "The picture shows only where the refused elements are." : "There is nothing to draw."), + TextStyles.StandardColor.ERROR, true)); + } + else if(preview.exact()) + { + data.addBlock(line("This is the picture that would print.", TextStyles.StandardColor.SUCCESS, true)); + } + else + { + data.addBlock(line("This picture is not exact: what the printer draws itself is shown as an outlined, crossed area.", TextStyles.StandardColor.WARNING, true)); + } + String values = options.data() == DocumentPreviewService.Data.SAMPLE ? "sample data" : options.data() == DocumentPreviewService.Data.LONGEST ? "longest values" : "one record"; + data.addBlock(line((options.revisionId() == null ? "Current draft" : "Published revision " + options.revisionId()) + ", " + values + ", " + result.dpi() + " dpi.", null, false)); + if(page) + { + GenerationPorts.PreviewPage first = preview.pages().getFirst(); + byte[] png = first.png(); + if(png.length <= MAXIMUM_IMAGE_BYTES) + { + data.addBlock(new ImageBlockData().withValues(new ImageValues().withPath("data:image/png;base64," + Base64.getEncoder().encodeToString(png)) + .withAlt("Label preview, " + first.widthDots() + " by " + first.heightDots() + " dots")) + .withStyles(new ImageStyles().withWidth("min(100%, " + millimetres(first.widthDots(), result.dpi()) + "mm)"))); + } + else + { + data.addBlock(line("The picture is " + png.length + " bytes, which is too large to show here.", TextStyles.StandardColor.WARNING, false)); + } + data.addBlock(line("Size: " + first.widthDots() + " x " + first.heightDots() + " dots at " + result.dpi() + " dpi (" + millimetres(first.widthDots(), result.dpi()) + " x " + + millimetres(first.heightDots(), result.dpi()) + " mm). Output: " + preview.outputBytes() + " bytes, SHA-256 " + preview.outputSha256() + ".", null, false)); + } + List ordered = preview.diagnostics().stream().sorted(Comparator.comparing(GenerationPorts.Diagnostic::severity)).toList(); + ordered.stream().limit(MAXIMUM_LINES).forEach(diagnostic -> data.addBlock(line(sentence(diagnostic, result.dpi()), + TextStyles.StandardColor.valueOf(diagnostic.severity().name()), false))); + if(ordered.size() > MAXIMUM_LINES) + { + data.addBlock(line("And " + (ordered.size() - MAXIMUM_LINES) + " more.", null, false)); + } + if(ordered.isEmpty()) + { + data.addBlock(line("No refusals and no warnings.", null, false)); + } + } + + /******************************************************************************* + ** One diagnostic as a sentence: a refusal, a warning or a note, its code, the + ** element the author named, what it means, and the measured values against + ** the limits in dots and in physical units. A diagnostic holds identifiers + ** and integers only, so no record value can appear here. + *******************************************************************************/ + static String sentence(GenerationPorts.Diagnostic diagnostic, int dpi) + { + GenerationPorts.Diagnostic.Severity severity = diagnostic.severity(); + StringBuilder text = new StringBuilder(severity == GenerationPorts.Diagnostic.Severity.ERROR ? "Refusal" : severity == GenerationPorts.Diagnostic.Severity.WARNING ? "Warning" : "Note") + .append(": ").append(diagnostic.code()); + if(diagnostic.element() != null) + { + text.append(", element '").append(diagnostic.element()).append('\''); + } + if(diagnostic.pointer() != null) + { + text.append(", at ").append(diagnostic.pointer().isEmpty() ? "the scene" : diagnostic.pointer()); + } + text.append('.'); + if(MEANINGS.containsKey(diagnostic.code())) + { + text.append(' ').append(MEANINGS.get(diagnostic.code())); + } + if(!diagnostic.measured().isEmpty()) + { + text.append(" Measured ").append(numbers(diagnostic.measured(), dpi)).append(diagnostic.limit().isEmpty() ? "." : " against a limit of " + numbers(diagnostic.limit(), dpi) + "."); + } + else if(!diagnostic.limit().isEmpty()) + { + text.append(" Limit ").append(numbers(diagnostic.limit(), dpi)).append('.'); + } + return (text.toString()); + } + + /******************************************************************************* + ** Named integers in a fixed order, each with its unit. + *******************************************************************************/ + private static String numbers(Map values, int dpi) + { + return (String.join(", ", new TreeMap<>(values).entrySet().stream().map(entry -> number(entry.getKey(), entry.getValue(), dpi)).toList())); + } + + /******************************************************************************* + ** A value by the unit its name ends with: dots also in millimetres at the + ** resolution drawn, hundredths of a point as points, micrometres as + ** millimetres, a percentage as such, and anything else as the bare number. + *******************************************************************************/ + private static String number(String name, int value, int dpi) + { + if(name.endsWith("Dots")) + { + return (words(name, 4) + " " + value + " dots (" + millimetres(value, dpi) + " mm)"); + } + if(name.endsWith("Centipoints")) + { + return (words(name, 11) + " " + BigDecimal.valueOf(value, 2).stripTrailingZeros().toPlainString() + " pt"); + } + if(name.endsWith("Micrometres")) + { + return (words(name, 11) + " " + BigDecimal.valueOf(value, 3).stripTrailingZeros().toPlainString() + " mm"); + } + if(name.endsWith("Percent")) + { + return (words(name, 7) + " " + value + "%"); + } + return (words(name, 0) + " " + value); + } + + /******************************************************************************* + ** A value's name without its unit, as lower-case words. + *******************************************************************************/ + private static String words(String name, int unit) + { + return (name.substring(0, name.length() - unit).replaceAll("([a-z0-9])([A-Z])", "$1 $2").toLowerCase(Locale.ROOT)); + } + + /******************************************************************************* + ** Dots as millimetres at a resolution, to a tenth. + *******************************************************************************/ + private static String millimetres(int dots, int dpi) + { + return (BigDecimal.valueOf(dots * 254L).divide(BigDecimal.valueOf(dpi * 10L), 1, RoundingMode.HALF_UP).toPlainString()); + } + + /******************************************************************************* + ** One line of text, coloured by what it reports and boxed when it is the + ** verdict. + *******************************************************************************/ + private static TextBlockData line(String text, TextStyles.StandardColor color, boolean verdict) + { + TextStyles styles = new TextStyles(); + if(color != null) + { + styles.withColor(color); + } + if(verdict) + { + styles.withFormat(TextStyles.StandardFormat.ALERT).withWeight(TextStyles.StandardWeight.BOLD); + } + return (new TextBlockData().withValues(new TextValues(text)).withStyles(styles)); } } diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java index 8a26c10..4cfe685 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java @@ -173,10 +173,12 @@ public final class GenerationTables table.getField("id").setLabel("Template ID"); table.withSection(new QFieldSection("overview", new QIcon("description"), Tier.T1, List.of("name", "typeId", "stockId", "tableName", "publishedRevisionId")).withLabel("Overview") - .withHelpContent(help("Choose Preview draft from this record's actions, select a source record and PDF or ZPL, then open Preview or Download on the result. Preview uses the current draft and does not publish it."))); + .withHelpContent(help("Choose Preview layout from this record's actions to see the label that would print, with sample data, its longest values or a record; nothing is saved. " + + "Generate retained preview file creates an audited file (PDF or ZPL) from a source record and the current draft, to open or download; it does not publish the draft."))); table.withSection(new QFieldSection("layout", new QIcon("code"), Tier.T2, List.of("pdfSource", "zplSource")).withLabel("Layout") .withCollapsible(CollapsibleMetaData.INITIALLY_CLOSED) - .withHelpContent(help("Use Edit layout to change PDF HTML or the ZPL scene. Preview draft checks the result against a source record; Validate and publish draft makes an immutable revision available for generation."))); + .withHelpContent(help("Use Edit layout to change PDF HTML or the ZPL scene. Preview layout shows the label and its warnings before anything is printed; " + + "Validate and publish draft makes an immutable revision available for generation."))); table.withSection(new QFieldSection("context", new QIcon("data_object"), Tier.T2, List.of("outputBaseName", "outputNameContextKey")).withLabel("Context & output") .withHelpContent(help("Choose Select context script to select a published, context-tested script or explicitly clear it. Without a script, templates use the authorized source record under record. Use Edit output filename to configure naming."))); table.withSection(new QFieldSection("advanced", new QIcon("settings"), Tier.T3, List.of("id", "createDate", "scriptArtifact", "assetIds")).withLabel("Advanced references") diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/DocumentPreviewTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/DocumentPreviewTest.java new file mode 100644 index 0000000..70d15f2 --- /dev/null +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/DocumentPreviewTest.java @@ -0,0 +1,1428 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.documentgeneration; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; +import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.processes.ProcessStateAccess; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.logging.CollectedLogMessage; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessState; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageBlockData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QCredentialScope; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import com.kingsrook.qqq.backend.core.state.StateType; +import com.kingsrook.qqq.backend.core.state.UUIDAndTypeStateKey; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** The layout preview inside the QBit: the service, the "Preview layout" action + ** and the widget, with a renderer that records what it was asked to draw. + ** Every value in the fixtures is synthetic. + *******************************************************************************/ +class DocumentPreviewTest +{ + private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10, 1, 2, 3}; + private static final String HASH = "ab".repeat(32); + private static final String GONE = "This preview is no longer available. Run Preview layout again."; + private static final String SCENE = """ + {"schema":"qqq-zpl-scene/2", + "samples":{"typical":{"record":{"name":"Sample name"},"extra":"scalar"},"long one":{"record":{"name":"A longer sample"}},"bad/name!":{"record":{}},"notAnObject":5}, + "elements":[{"id":"caption","type":"text","valuePath":"/record/name","maxLength":12}, + {"id":"group","type":"group","children":[{"id":"inner","type":"text","valuePath":"/extra/deep~1er/value","maxLength":3}, + {"id":"unbounded","type":"text","valuePath":"/record/other"},{"id":"excessive","type":"text","valuePath":"/record/huge","maxLength":5000}, + {"id":"relative","type":"text","valuePath":"record/x","maxLength":2},{"id":"root","type":"text","valuePath":"","maxLength":2}]}]}"""; + + private QInstance instance; + private DocumentGenerationQBitConfig config; + private Integer template; + private Integer stock; + private Integer revision; + private GenerationPorts.PreviewRequest previewed; + private int previews; + private int scripts; + private GenerationPorts.Preview answer; + private QException failure; + private QException scriptFailure; + private Map rendered; + private final List audits = new ArrayList<>(); + + /*************************************************************************** + ** One row of the permission matrix: a request, the document permissions the + ** person holds, and whether the preview is drawn. + ***************************************************************************/ + private record Row(DocumentPreviewService.Options options, List permissions, boolean allowed) + { + } + + /*************************************************************************** + ** One refusal of the form: the values submitted and how they are refused. + ***************************************************************************/ + private record Refused(Map values, Class type, String message) + { + } + + /*************************************************************************** + ** Sample data is the first sample of the scene or the one named, and no + ** record is read for it. + ***************************************************************************/ + @Test + void sampleDataIsTheFirstSampleOrTheNamedOneAndInvolvesNoRecord() throws Exception + { + DocumentPreviewService service = new DocumentPreviewService(config); + DocumentPreviewService.Result first = service.preview(draft(DocumentPreviewService.Data.SAMPLE)); + assertThat(first.preview()).isSameAs(answer); + assertThat(first.dpi()).isEqualTo(203); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("name", "Sample name"), "extra", "scalar")); + assertThat(previewed.actorId()).isEqualTo("alice"); + assertThat(previewed.format()).isEqualTo(GenerationPorts.Format.ZPL); + assertThat(previewed.targetDpi()).isNull(); + assertThat(previewed.maximumBytes()).isEqualTo(config.getMaximumOutputBytes()); + assertThat(previewed.revision().id()).isNull(); + assertThat(previewed.revision().zplSource()).isEqualTo(SCENE); + assertThat(previewed.revision().scriptArtifact()).isEqualTo("script-7"); + service.preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, DocumentPreviewService.Data.SAMPLE, "long one", null, null, null)); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("name", "A longer sample"))); + assertThat(scripts).isZero(); + assertThatThrownBy(() -> service.preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, DocumentPreviewService.Data.SAMPLE, "absent", null, null, null))) + .isInstanceOf(QBadRequestException.class).hasMessage("The scene declares no sample of that name."); + assertThatThrownBy(() -> service.preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, DocumentPreviewService.Data.SAMPLE, "notAnObject", null, null, null))) + .isInstanceOf(QBadRequestException.class); + } + + /*************************************************************************** + ** A scene without samples, or one this host cannot read, gives the renderer + ** an empty context; the renderer says what is wrong with it. + ***************************************************************************/ + @Test + void aSceneWithoutSamplesOrOneTheHostCannotReadGivesAnEmptyContext() throws Exception + { + DocumentDefinitionService definitions = new DocumentDefinitionService(config); + for(String source : List.of("{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[]}", "not json at all", "null", "{\"samples\":[],\"elements\":{\"a\":1}}")) + { + definitions.saveDraft(template, stock, new DocumentDefinitionService.Draft(null, source, null, List.of())); + new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.SAMPLE)); + assertThat(previewed.context()).as(source).isEmpty(); + assertThat(previewed.revision().zplSource()).isEqualTo(source); + new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.LONGEST)); + assertThat(previewed.context()).as(source).isEmpty(); + assertThat(new DocumentPreviewService(config).choices(template, null).sampleNames()).as(source).isEmpty(); + } + } + + /*************************************************************************** + ** Longest values start from the first sample and fill each bound text that + ** declares a maximum length, at any depth, to that length. + ***************************************************************************/ + @Test + void longestValuesFillEveryBoundTextToItsDeclaredMaximum() throws Exception + { + new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.LONGEST)); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("name", "W".repeat(12)), "extra", Map.of("deep/er", Map.of("value", "WWW")))); + assertThat(scripts).isZero(); + StringBuilder deep = new StringBuilder("{\"schema\":\"qqq-zpl-scene/2\",\"elements\":["); + for(int level = 0; level < 20; level++) + { + deep.append("{\"id\":\"g").append(level).append("\",\"valuePath\":\"/level").append(level).append("\",\"maxLength\":1,\"children\":["); + } + deep.append("]}".repeat(20)).append("]}"); + new DocumentDefinitionService(config).saveDraft(template, stock, new DocumentDefinitionService.Draft(null, deep.toString(), null, List.of())); + new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.LONGEST)); + assertThat(previewed.context()).hasSize(17).containsEntry("level0", "W").containsEntry("level16", "W").doesNotContainKey("level17"); + String far = "/a".repeat(17); + new DocumentDefinitionService(config).saveDraft(template, stock, new DocumentDefinitionService.Draft(null, + "{\"elements\":[{\"valuePath\":\"" + far + "\",\"maxLength\":1},{\"valuePath\":\"/tilde~0d\",\"maxLength\":4096},{\"valuePath\":\"/zero\",\"maxLength\":0}]}", null, List.of())); + new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.LONGEST)); + assertThat(previewed.context()).isEqualTo(Map.of("tilde~d", "W".repeat(4096))); + } + + /*************************************************************************** + ** A real record is read as the person, and its context is the one generation + ** builds for the same record, including the pinned context script. + ***************************************************************************/ + @Test + void aRecordIsReadAsThePersonAndItsContextIsBuiltAsGenerationBuildsIt() throws Exception + { + new DocumentGenerationService(config).generate(new DocumentGenerationService.Request(template, new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, "key-1", null)); + assertThat(rendered).isEqualTo(Map.of("record", Map.of("id", 1, "name", "canonical", "tenantId", 1), "fromScript", "canonical")); + scripts = 0; + new DocumentPreviewService(config).preview(published(DocumentPreviewService.Data.RECORD, "1")); + assertThat(previewed.context()).isEqualTo(rendered); + assertThat(previewed.revision()).isEqualTo(new DocumentDefinitionService(config).revision(revision)); + assertThat(scripts).isEqualTo(1); + new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.RECORD, "1")); + assertThat(previewed.context()).isEqualTo(rendered); + assertThat(previewed.revision().id()).isNull(); + new DocumentDefinitionService(config).saveDraft(template, stock, new DocumentDefinitionService.Draft(null, SCENE, null, List.of())); + scripts = 0; + new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.RECORD, "1")); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("id", 1, "name", "canonical", "tenantId", 1))); + assertThat(scripts).isZero(); + } + + /*************************************************************************** + ** A record the person cannot read is refused exactly as one that does not + ** exist, before any script or renderer runs, and nothing of it is returned. + ***************************************************************************/ + @Test + void aRecordThePersonCannotReadIsRefusedAndNothingAboutItIsReturned() throws Exception + { + DocumentPreviewService service = new DocumentPreviewService(config); + for(String id : List.of("2", "99", "not-a-number")) + { + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, id))).as(id).isInstanceOf(QNotFoundException.class).hasMessage("Source record not found."); + assertThatThrownBy(() -> service.check(draft(DocumentPreviewService.Data.RECORD, id))).as(id).isInstanceOf(QNotFoundException.class).hasMessage("Source record not found."); + } + login(false, "view", "configure", "generate"); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isInstanceOf(QNotFoundException.class).hasMessage("Source record not found."); + assertThatThrownBy(() -> service.preview(published(DocumentPreviewService.Data.RECORD, "1"))).isInstanceOf(QNotFoundException.class).hasMessage("Source record not found."); + assertThat(previews).isZero(); + assertThat(scripts).isZero(); + List shown = lines(GenerationPreviewWidget.widget(config, ran(Map.of("previewTemplateId", template.toString(), "dataSource", "RECORD", "sourceRecordId", "1")))); + assertThat(shown).containsExactly("Source record not found."); + login(true, "view", "configure", "generate"); + shown = lines(GenerationPreviewWidget.widget(config, ran(Map.of("previewTemplateId", template.toString(), "dataSource", "RECORD", "sourceRecordId", "2")))); + assertThat(shown).containsExactly("Source record not found."); + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** Access lost while the context script ran still refuses, as in generation. + ***************************************************************************/ + @Test + void accessLostWhileTheScriptRanStillRefuses() + { + scriptFailure = null; + config.withContextExecution(request -> + { + QContext.getQSession().getPermissions().remove("orders.read"); + return (Map.of()); + }); + assertThatThrownBy(() -> new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isInstanceOf(QNotFoundException.class); + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** The matrix of the design: a draft needs configure, a published revision + ** needs view, a record needs generate (with configure for a draft, alone + ** for a published revision) and read access to the record. + ***************************************************************************/ + @Test + void thePermissionMatrixOfTheDesignIsApplied() throws Exception + { + DocumentPreviewService service = new DocumentPreviewService(config); + List rows = List.of( + new Row(draft(DocumentPreviewService.Data.SAMPLE), List.of("configure"), true), + new Row(draft(DocumentPreviewService.Data.SAMPLE), List.of("view", "generate", "publish", "print"), false), + new Row(draft(DocumentPreviewService.Data.LONGEST), List.of("configure"), true), + new Row(draft(DocumentPreviewService.Data.LONGEST), List.of("view"), false), + new Row(draft(DocumentPreviewService.Data.RECORD, "1"), List.of("configure", "generate"), true), + new Row(draft(DocumentPreviewService.Data.RECORD, "1"), List.of("configure", "view"), false), + new Row(draft(DocumentPreviewService.Data.RECORD, "1"), List.of("generate", "view"), false), + new Row(published(DocumentPreviewService.Data.SAMPLE, null), List.of("view"), true), + new Row(published(DocumentPreviewService.Data.SAMPLE, null), List.of("configure", "generate"), false), + new Row(published(DocumentPreviewService.Data.LONGEST, null), List.of("view"), true), + new Row(published(DocumentPreviewService.Data.LONGEST, null), List.of("generate"), false), + new Row(published(DocumentPreviewService.Data.RECORD, "1"), List.of("generate"), true), + new Row(published(DocumentPreviewService.Data.RECORD, "1"), List.of("view", "configure"), false), + new Row(draft(DocumentPreviewService.Data.SAMPLE), List.of(), false)); + for(Row row : rows) + { + login(true, row.permissions().toArray(String[]::new)); + previews = 0; + if(row.allowed()) + { + service.preview(row.options()); + service.check(row.options()); + assertThat(previews).as(row.toString()).isEqualTo(1); + } + else + { + assertThatThrownBy(() -> service.preview(row.options())).as(row.toString()).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> service.check(row.options())).as(row.toString()).isInstanceOf(QPermissionDeniedException.class); + assertThat(previews).as(row.toString()).isZero(); + } + } + login(true, "configure"); + assertThatThrownBy(() -> service.choices(null, revision)).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> service.publishedRevision(template)).isInstanceOf(QPermissionDeniedException.class); + assertThat(service.choices(template, null).sourceTable()).isEqualTo("orders"); + QContext.init(instance, new QSession()); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.SAMPLE))).isInstanceOf(QPermissionDeniedException.class); + } + + /*************************************************************************** + ** A preview writes nothing: no row in any table of the QBit or of the host, + ** no audit event, no retained file, whatever the data and whoever asks. + ***************************************************************************/ + @Test + void nothingIsWrittenByAPreview() throws Exception + { + Map before = counts(); + DocumentPreviewService service = new DocumentPreviewService(config); + service.preview(draft(DocumentPreviewService.Data.SAMPLE)); + service.preview(draft(DocumentPreviewService.Data.LONGEST)); + service.preview(draft(DocumentPreviewService.Data.RECORD, "1")); + service.preview(published(DocumentPreviewService.Data.RECORD, "1")); + service.check(draft(DocumentPreviewService.Data.RECORD, "1")); + service.choices(template, null); + fetch(Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString())); + answer = new GenerationPorts.Preview(List.of(), false, false, List.of(new GenerationPorts.Diagnostic("VALUE_MISSING", GenerationPorts.Diagnostic.Severity.ERROR, "caption", null, null, null)), + 0L, HASH); + service.preview(draft(DocumentPreviewService.Data.RECORD, "1")); + failure = new QException("opaque"); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isInstanceOf(QException.class); + assertThat(counts()).isEqualTo(before); + assertThat(audits).isEmpty(); + assertThat(rendered).isNull(); + assertThat(before).containsEntry(GenerationTables.REVISION, 1).containsEntry(GenerationTables.REQUEST, 0).containsEntry(GenerationTables.EVENT, 0).containsEntry("orders", 2); + } + + /*************************************************************************** + ** A page document is refused by name; the retained preview file remains the + ** way to obtain it. + ***************************************************************************/ + @Test + void aPdfIsRefusedWithTheStatedMessage() + { + DocumentPreviewService service = new DocumentPreviewService(config); + DocumentPreviewService.Options pdf = new DocumentPreviewService.Options(template, null, GenerationPorts.Format.PDF, DocumentPreviewService.Data.SAMPLE, null, null, null, null); + assertThatThrownBy(() -> service.preview(pdf)).isInstanceOf(QBadRequestException.class).hasMessage("Preview is available for labels only."); + assertThatThrownBy(() -> service.check(pdf)).isInstanceOf(QBadRequestException.class).hasMessage("Preview is available for labels only."); + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** What is not a preview is refused before anything is read or drawn. + ***************************************************************************/ + @Test + void optionsThatAreNotAPreviewAreRefused() throws Exception + { + DocumentPreviewService service = new DocumentPreviewService(config); + GenerationPorts.Format zpl = GenerationPorts.Format.ZPL; + DocumentPreviewService.Data sample = DocumentPreviewService.Data.SAMPLE; + List invalid = Arrays.asList(null, + new DocumentPreviewService.Options(null, null, zpl, sample, null, null, null, null), + new DocumentPreviewService.Options(template, revision, zpl, sample, null, null, null, null), + new DocumentPreviewService.Options(template, null, null, sample, null, null, null, null), + new DocumentPreviewService.Options(template, null, zpl, null, null, null, null, null), + new DocumentPreviewService.Options(template, null, zpl, sample, null, null, null, 204)); + for(DocumentPreviewService.Options options : invalid) + { + assertThatThrownBy(() -> service.preview(options)).as(String.valueOf(options)).isInstanceOf(QBadRequestException.class).hasMessageStartingWith("A preview needs"); + } + for(String blank : Arrays.asList(null, "", " ")) + { + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, blank))).isInstanceOf(QBadRequestException.class).hasMessage("Choose the record to preview."); + } + assertThatThrownBy(() -> service.preview(new DocumentPreviewService.Options(9999, null, zpl, sample, null, null, null, null))).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> service.preview(new DocumentPreviewService.Options(template, null, zpl, sample, null, null, 9999, null))).isInstanceOf(QNotFoundException.class); + new DocumentDefinitionService(config).saveDraft(template, stock, new DocumentDefinitionService.Draft("

page

", " ", null, List.of())); + assertThatThrownBy(() -> service.preview(draft(sample))).isInstanceOf(QBadRequestException.class).hasMessage("This template has no label (ZPL) source to preview."); + new DocumentDefinitionService(config).saveDraft(template, stock, new DocumentDefinitionService.Draft("

page

", null, null, List.of())); + assertThatThrownBy(() -> service.choices(template, null)).isInstanceOf(QBadRequestException.class).hasMessage("This template has no label (ZPL) source to preview."); + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** The chosen stock and resolution reach the renderer; the template keeps its + ** own stock. + ***************************************************************************/ + @Test + void theChosenStockAndResolutionReachTheRenderer() throws Exception + { + DocumentDefinitionService definitions = new DocumentDefinitionService(config); + Integer other = definitions.createStock("4x6", new GenerationPorts.Stock(101600, 152400, 1000, 1000, 1000, 1000, 300)); + DocumentPreviewService service = new DocumentPreviewService(config); + DocumentPreviewService.Result result = service.preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, DocumentPreviewService.Data.SAMPLE, null, null, other, null)); + assertThat(previewed.revision().stock()).isEqualTo(new GenerationPorts.Stock(101600, 152400, 1000, 1000, 1000, 1000, 300)); + assertThat(previewed.revision().zplSource()).isEqualTo(SCENE); + assertThat(result.dpi()).isEqualTo(300); + result = service.preview(new DocumentPreviewService.Options(null, revision, GenerationPorts.Format.ZPL, DocumentPreviewService.Data.SAMPLE, null, null, other, 600)); + assertThat(previewed.targetDpi()).isEqualTo(600); + assertThat(previewed.revision().id()).isEqualTo(revision); + assertThat(result.dpi()).isEqualTo(600); + assertThat(GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("stockId")).isEqualTo(stock); + assertThat(service.publishedRevision(template)).isEqualTo(revision); + } + + /*************************************************************************** + ** One log line per preview: who, which template, the target and the + ** outcome. No source, sample, record identity or record value. + ***************************************************************************/ + @Test + void theLogLineNamesIdentitiesAndTheOutcomeAndNoContent() throws Exception + { + var collected = QLogger.activateCollectingLoggerForClass(DocumentPreviewService.class); + try + { + DocumentPreviewService service = new DocumentPreviewService(config); + service.preview(draft(DocumentPreviewService.Data.RECORD, "1")); + answer = new GenerationPorts.Preview(List.of(), false, false, List.of( + new GenerationPorts.Diagnostic("VALUE_MISSING", GenerationPorts.Diagnostic.Severity.ERROR, "caption", null, null, null), + new GenerationPorts.Diagnostic("FIT_NOT_PROVEN", GenerationPorts.Diagnostic.Severity.WARNING, "caption", null, null, null), + new GenerationPorts.Diagnostic("VALUE_MISSING", GenerationPorts.Diagnostic.Severity.ERROR, "inner", null, null, null)), 0L, HASH); + service.preview(published(DocumentPreviewService.Data.SAMPLE, null)); + failure = new QException("opaque with canonical in it"); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isSameAs(failure); + List messages = collected.getCollectedMessages().stream().map(CollectedLogMessage::getMessage).toList(); + assertThat(messages).hasSize(3); + assertThat(messages.get(0)).contains("alice").contains("\"templateId\":" + template).contains("RECORD").contains("203").contains("PRINTABLE"); + assertThat(messages.get(1)).contains("\"revisionId\":" + revision).contains("SAMPLE").contains("REFUSED").contains("FIT_NOT_PROVEN,VALUE_MISSING"); + assertThat(messages.get(2)).contains("FAILED"); + assertThat(String.join("\n", messages)).doesNotContain("canonical").doesNotContain("Sample name").doesNotContain("qqq-zpl-scene").doesNotContain("orders") + .doesNotContain("sourceRecordId").doesNotContain("opaque"); + } + finally + { + QLogger.deactivateCollectingLoggerForClass(DocumentPreviewService.class); + } + } + + /*************************************************************************** + ** A failure of the host's script or renderer keeps its message for a caller + ** of the service but loses any type a screen would show as an explanation, + ** so the widget answers with a fixed sentence. A named refusal keeps its code. + ***************************************************************************/ + @Test + void aFailureOfTheScriptOrTheRendererIsNeverShownInItsOwnWords() throws Exception + { + DocumentPreviewService service = new DocumentPreviewService(config); + Map record = ran(Map.of("previewTemplateId", template.toString(), "dataSource", "RECORD", "sourceRecordId", "1")); + scriptFailure = new QBadRequestException("script quoted canonical"); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isExactlyInstanceOf(QException.class).hasMessage("script quoted canonical"); + assertThat(lines(GenerationPreviewWidget.widget(config, record))).singleElement().asString().startsWith("The preview could not be drawn.").doesNotContain("canonical"); + scriptFailure = null; + failure = new QNotFoundException("renderer quoted canonical"); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isExactlyInstanceOf(QException.class).hasMessage("renderer quoted canonical"); + assertThat(lines(GenerationPreviewWidget.widget(config, record))).singleElement().asString().startsWith("The preview could not be drawn.").doesNotContain("canonical"); + failure = new GenerationPorts.RenderException(GenerationPorts.FailureCode.UNSUPPORTED_BARCODE_OPTION); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isSameAs(failure); + assertThat(lines(GenerationPreviewWidget.widget(config, record))).containsExactly("The preview could not be drawn (UNSUPPORTED_BARCODE_OPTION)."); + config.withRenderer(new GenerationPorts.IsolatedRenderer() + { + /*************************************************************************** + ** An adapter written before the preview: it validates. + ***************************************************************************/ + public void validate(String actor, GenerationPorts.Revision candidate) + { + } + + /*************************************************************************** + ** An adapter written before the preview: it renders. + ***************************************************************************/ + public GenerationPorts.Content render(GenerationPorts.RenderRequest request) + { + return (new GenerationPorts.Content(new byte[] {1})); + } + }); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.SAMPLE))).isExactlyInstanceOf(QException.class).hasMessage("Renderer preview is unavailable."); + assertThat(lines(GenerationPreviewWidget.widget(config, Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString())))).singleElement().asString() + .startsWith("The preview could not be drawn."); + } + + /*************************************************************************** + ** The form is offered only sample names that are plain words. + ***************************************************************************/ + @Test + void choicesOfferOnlyWellFormedSampleNames() throws Exception + { + DocumentPreviewService.Choices choices = new DocumentPreviewService(config).choices(template, null); + assertThat(choices.sourceTable()).isEqualTo("orders"); + assertThat(choices.sampleNames()).containsExactly("typical", "long one"); + assertThat(new DocumentPreviewService(config).choices(null, revision).sampleNames()).containsExactly("typical", "long one"); + StringBuilder many = new StringBuilder("{\"samples\":{"); + for(int index = 0; index < 20; index++) + { + many.append(index == 0 ? "" : ",").append("\"s").append(index).append("\":{\"n\":").append(index).append('}'); + } + new DocumentDefinitionService(config).saveDraft(template, stock, new DocumentDefinitionService.Draft(null, many.append("}}").toString(), null, List.of())); + assertThat(new DocumentPreviewService(config).choices(template, null).sampleNames()).hasSize(DocumentPreviewService.MAXIMUM_SAMPLES).startsWith("s0").endsWith("s15"); + assertThatThrownBy(() -> new DocumentPreviewService(config).preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, + DocumentPreviewService.Data.SAMPLE, "s16", null, null, null))).isInstanceOf(QBadRequestException.class); + } + + /*************************************************************************** + ** On the template record screen an author sees the draft and a viewer sees + ** the published revision, both with sample data and never a record. + ***************************************************************************/ + @Test + void theTemplateScreenShowsTheDraftToAnAuthorAndThePublishedRevisionToAViewer() throws Exception + { + Map screen = Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString()); + List shown = lines(fetch(screen)); + assertThat(shown).containsExactly("This is the picture that would print.", "Current draft, sample data, 203 dpi.", "IMAGE Label preview, 812 by 203 dots", + "Size: 812 x 203 dots at 203 dpi (101.6 x 25.4 mm). Output: 120 bytes, SHA-256 " + HASH + ".", "No refusals and no warnings."); + assertThat(previewed.revision().id()).isNull(); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("name", "Sample name"), "extra", "scalar")); + login(true, "view"); + shown = lines(fetch(screen)); + assertThat(shown.get(1)).isEqualTo("Published revision " + revision + ", sample data, 203 dpi."); + assertThat(previewed.revision().id()).isEqualTo(revision); + assertThat(scripts).isZero(); + login(true, "view", "configure"); + Integer unpublished = new DocumentDefinitionService(config).createTemplate("draft only", GenerationStore.get(GenerationTables.TEMPLATE, template).getValueInteger("typeId"), stock, "orders"); + new DocumentDefinitionService(config).saveDraft(unpublished, stock, new DocumentDefinitionService.Draft(null, SCENE, null, List.of())); + login(true, "view"); + assertThat(lines(fetch(Map.of("tableName", GenerationTables.TEMPLATE, "id", unpublished.toString())))).containsExactly("This template has no published revision to preview."); + assertThat(lines(fetch(Map.of("tableName", GenerationTables.REVISION, "id", revision.toString())))).containsExactly("The label preview belongs to a document template."); + assertThat(lines(fetch(Map.of("tableName", GenerationTables.TEMPLATE, "id", "seven")))).containsExactly("Choose the data, the stock and the resolution of the preview from the form."); + assertThat(lines(GenerationPreviewWidget.widget(config, Map.of()))).containsExactly("The label preview belongs to a document template."); + RenderWidgetInput bare = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(GenerationPreviewWidget.NAME)); + bare.setQueryParams(null); + assertThat(lines((CompositeWidgetData) new GenerationPreviewWidget.Renderer().render(bare).getWidgetData())).containsExactly("The label preview belongs to a document template."); + } + + /*************************************************************************** + ** A person with none of the document permissions never reaches the + ** renderer, and one whose permission does not cover the request is denied. + ***************************************************************************/ + @Test + void aPersonWithoutPermissionIsDenied() + { + Map screen = Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString()); + assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), GenerationPreviewWidget.NAME)).isTrue(); + login(true); + assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), GenerationPreviewWidget.NAME)).isFalse(); + assertThatThrownBy(() -> fetch(screen)).isInstanceOf(QPermissionDeniedException.class); + QContext.init(instance, new QSession()); + assertThatThrownBy(() -> new GenerationPreviewWidget.Permission().checkPermissionsThrowing(new RenderWidgetInput(), instance.getWidget(GenerationPreviewWidget.NAME))) + .isInstanceOf(QPermissionDeniedException.class); + login(true, "generate"); + assertThatThrownBy(() -> fetch(screen)).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> fetch(ran(Map.of("previewTemplateId", template.toString(), "dataSource", "SAMPLE")))).isInstanceOf(QPermissionDeniedException.class); + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** A layout that cannot print shows where the refused elements are and lists + ** each diagnostic, refusals first, with the element and measured against limit. + ***************************************************************************/ + @Test + void aRefusedSceneShowsItsBoxesAndTheListOfDiagnostics() throws Exception + { + answer = new GenerationPorts.Preview(List.of(new GenerationPorts.PreviewPage(PNG, 812, 203)), false, false, List.of( + new GenerationPorts.Diagnostic("BARCODE_MODULE", GenerationPorts.Diagnostic.Severity.INFO, "code", null, Map.of("moduleMicrometres", 250, "moduleDots", 2), null), + new GenerationPorts.Diagnostic("FIT_NOT_PROVEN", GenerationPorts.Diagnostic.Severity.WARNING, "caption", null, null, null), + new GenerationPorts.Diagnostic("TEXT_DOES_NOT_FIT", GenerationPorts.Diagnostic.Severity.ERROR, "caption", List.of(24, 131, 764, 56), + Map.of("widthDots", 902, "sizeCentipoints", 700, "lines", 2), Map.of("widthDots", 764, "lines", 2), "/elements/1/fit")), 0L, HASH); + CompositeWidgetData data = GenerationPreviewWidget.widget(config, ran(Map.of("previewTemplateId", template.toString(), "dataSource", "LONGEST", "targetDpi", "203"))); + assertThat(lines(data)).containsExactly("This layout cannot print. The picture shows only where the refused elements are.", "Current draft, longest values, 203 dpi.", + "IMAGE Label preview, 812 by 203 dots", "Size: 812 x 203 dots at 203 dpi (101.6 x 25.4 mm). Output: 0 bytes, SHA-256 " + HASH + ".", + "Refusal: TEXT_DOES_NOT_FIT, element 'caption', at /elements/1/fit. The text does not fit its box. " + + "Measured lines 2, size 7 pt, width 902 dots (112.9 mm) against a limit of lines 2, width 764 dots (95.6 mm).", + "Warning: FIT_NOT_PROVEN, element 'caption'. No maximum length is declared, so the fit is not proven for every value.", + "Note: BARCODE_MODULE, element 'code'. The width of the barcode's narrow bar. Measured module 2 dots (0.3 mm), module 0.25 mm."); + assertThat(colors(data)).containsExactly("ERROR", null, null, null, "ERROR", "WARNING", "INFO"); + answer = new GenerationPorts.Preview(List.of(), false, false, List.of( + new GenerationPorts.Diagnostic("SCENE_INVALID", GenerationPorts.Diagnostic.Severity.ERROR, null, null, Map.of("paintedPercent", 1700), Map.of("paintedPercent", 1600), ""), + new GenerationPorts.Diagnostic("BARCODE_FIT_TOO_SMALL", GenerationPorts.Diagnostic.Severity.ERROR, null, null, null, Map.of("modules", 3))), 0L, HASH); + assertThat(lines(GenerationPreviewWidget.widget(config, ran(Map.of("previewRevisionId", revision.toString(), "dataSource", "RECORD", "sourceRecordId", "1", "targetDpi", "300"))))) + .containsExactly("This layout cannot print. There is nothing to draw.", "Published revision " + revision + ", one record, 300 dpi.", + "Refusal: SCENE_INVALID, at the scene. The scene is not valid at the place named. Measured painted 1700% against a limit of painted 1600%.", + "Refusal: BARCODE_FIT_TOO_SMALL. Limit modules 3."); + } + + /*************************************************************************** + ** A version 1 label is drawn, marked as not exact, with the warning that + ** printer font text is not shown. + ***************************************************************************/ + @Test + void aVersionOneLabelIsShownAsNotExactWithItsWarning() throws Exception + { + answer = new GenerationPorts.Preview(List.of(new GenerationPorts.PreviewPage(PNG, 800, 1200)), false, true, + List.of(new GenerationPorts.Diagnostic("PRINTER_FONT_NOT_SHOWN", GenerationPorts.Diagnostic.Severity.WARNING, null, null, null, null)), 2048L, HASH); + DocumentPreviewService.Result result = new DocumentPreviewService(config).preview(draft(DocumentPreviewService.Data.SAMPLE)); + assertThat(result.preview().exact()).isFalse(); + assertThat(result.preview().printable()).isTrue(); + assertThat(result.preview().diagnostics()).extracting(GenerationPorts.Diagnostic::code).containsExactly("PRINTER_FONT_NOT_SHOWN"); + CompositeWidgetData data = fetch(Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString())); + assertThat(lines(data)).containsExactly("This picture is not exact: what the printer draws itself is shown as an outlined, crossed area.", "Current draft, sample data, 203 dpi.", + "IMAGE Label preview, 800 by 1200 dots", "Size: 800 x 1200 dots at 203 dpi (100.1 x 150.1 mm). Output: 2048 bytes, SHA-256 " + HASH + ".", + "Warning: PRINTER_FONT_NOT_SHOWN. Printer font text cannot be drawn; its estimated area is outlined and crossed."); + assertThat(colors(data).getFirst()).isEqualTo("WARNING"); + } + + /*************************************************************************** + ** What reaches the browser is bounded: one page, a picture of at most two + ** mebibytes, and fifty diagnostics. + ***************************************************************************/ + @Test + void whatReachesTheBrowserIsBounded() throws Exception + { + byte[] large = Arrays.copyOf(PNG, GenerationPreviewWidget.MAXIMUM_IMAGE_BYTES + 1); + List many = new ArrayList<>(); + for(int index = 0; index < GenerationPreviewWidget.MAXIMUM_LINES + 10; index++) + { + many.add(new GenerationPorts.Diagnostic("ELEMENTS_OVERLAP", GenerationPorts.Diagnostic.Severity.WARNING, "e" + index, null, null, null)); + } + answer = new GenerationPorts.Preview(List.of(new GenerationPorts.PreviewPage(large, 812, 203), new GenerationPorts.PreviewPage(PNG, 1, 1)), true, true, many, 5L, HASH); + CompositeWidgetData data = fetch(Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString())); + List shown = lines(data); + assertThat(shown).hasSize(4 + GenerationPreviewWidget.MAXIMUM_LINES + 1).noneMatch(line -> line.startsWith("IMAGE")); + assertThat(shown.get(2)).isEqualTo("The picture is " + large.length + " bytes, which is too large to show here."); + assertThat(shown.getLast()).isEqualTo("And 10 more."); + assertThat(JsonUtils.toJson(data).length()).isLessThan(40000); + answer = new GenerationPorts.Preview(List.of(new GenerationPorts.PreviewPage(Arrays.copyOf(PNG, GenerationPreviewWidget.MAXIMUM_IMAGE_BYTES), 812, 203)), true, true, List.of(), 5L, HASH); + assertThat(lines(fetch(Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString())))).anyMatch(line -> line.startsWith("IMAGE")); + } + + /*************************************************************************** + ** The widget's answer is the composite the frontend draws: an IMAGE block + ** whose path is the page's PNG as a data URL, between TEXT blocks. + ***************************************************************************/ + @Test + void theWidgetSerialisesAsTheCompositeTheFrontendDraws() throws Exception + { + var widget = instance.getWidget(GenerationPreviewWidget.NAME); + assertThat(widget.getType()).isEqualTo("composite"); + assertThat(widget.getLabel()).isEqualTo("Label preview"); + JsonNode json = new ObjectMapper().readTree(JsonUtils.toJson(fetch(Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString())))); + assertThat(json.path("type").asText()).isEqualTo("block"); + assertThat(json.path("blockTypeName").asText()).isEqualTo("COMPOSITE"); + assertThat(json.path("layout").asText()).isEqualTo("FLEX_COLUMN"); + assertThat(json.path("blocks")).hasSize(5); + assertThat(json.path("blocks").get(0).path("blockTypeName").asText()).isEqualTo("TEXT"); + assertThat(json.path("blocks").get(0).path("values").path("text").asText()).isEqualTo("This is the picture that would print."); + assertThat(json.path("blocks").get(0).path("styles").path("format").asText()).isEqualTo("alert"); + JsonNode image = json.path("blocks").get(2); + assertThat(image.path("blockTypeName").asText()).isEqualTo("IMAGE"); + assertThat(image.path("values").path("path").asText()).isEqualTo("data:image/png;base64," + Base64.getEncoder().encodeToString(PNG)); + assertThat(image.path("values").path("alt").asText()).isEqualTo("Label preview, 812 by 203 dots"); + assertThat(image.path("styles").path("width").asText()).isEqualTo("min(100%, 101.6mm)"); + } + + /*************************************************************************** + ** The action asks for the data, the stock and the resolution, offers the + ** samples the scene declares and the records the person can read. + ***************************************************************************/ + @Test + void theActionAsksForDataStockAndResolutionAndOffersTheScenesSamples() throws Exception + { + for(String table : List.of(GenerationTables.TEMPLATE, GenerationTables.REVISION)) + { + boolean draft = GenerationTables.TEMPLATE.equals(table); + var process = instance.getProcess(GenerationPreviewProcesses.name(table)); + assertThat(process.getLabel()).isEqualTo("Preview layout"); + assertThat(process.getTableName()).isEqualTo(table); + assertThat(process.getStepList()).extracting(step -> step.getName()).containsExactly("prepare", "options", "check", "result"); + assertThat(process.getFrontendStep("options").getFormFields()).extracting(QFieldMetaData::getName) + .containsExactly("dataSource", "sampleName", "sourceTable", "sourceRecordId", "stockId", "targetDpi"); + assertThat(process.getFrontendStep("result").getComponents().getFirst().getValues()).containsEntry("widgetName", GenerationPreviewWidget.NAME); + assertThat(process.getFrontendStep("result").getViewFields()).isNullOrEmpty(); + var input = new RunBackendStepInput().withProcessName(process.getName()).withRecords(List.of(new QRecord().withValue("id", draft ? template : revision).withValue("tableName", "forged"))); + var output = new RunBackendStepOutput(); + new GenerationPreviewProcesses.Prepare().run(input, output); + assertThat(output.getValues()).isEqualTo(Map.of(draft ? "previewTemplateId" : "previewRevisionId", draft ? template : revision, "sourceTable", "orders", "dataSource", "SAMPLE")); + var adjusted = output.getProcessMetaDataAdjustment().getUpdatedFields(); + assertThat(adjusted.get("sampleName").getInlinePossibleValueSource().getEnumValues()).extracting(value -> String.valueOf(value.getId())).containsExactly("typical", "long one"); + assertThat(adjusted.get("sourceRecordId").getType()).isEqualTo(QFieldType.INTEGER); + assertThat(adjusted.get("sourceRecordId").getPossibleValueSourceName()).isEqualTo(GenerationAuthoringProcesses.sourceRecordChoices("orders")); + assertThat(process.getFrontendStep("options").getFormFields().stream().filter(field -> "sourceRecordId".equals(field.getName())).findFirst().orElseThrow().getType()) + .isEqualTo(QFieldType.STRING); + assertThatThrownBy(() -> new GenerationPreviewProcesses.Prepare().run(new RunBackendStepInput().withProcessName(process.getName()), new RunBackendStepOutput())) + .isInstanceOf(QBadRequestException.class); + assertThatThrownBy(() -> new GenerationPreviewProcesses.Prepare().run(new RunBackendStepInput().withProcessName(process.getName()) + .withRecords(List.of(new QRecord().withValue("id", 1), new QRecord().withValue("id", 2))), new RunBackendStepOutput())).isInstanceOf(QBadRequestException.class); + } + assertThat(instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "preview")).getLabel()).isEqualTo("Generate retained preview file"); + new DocumentDefinitionService(config).saveDraft(template, stock, new DocumentDefinitionService.Draft(null, "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[]}", null, List.of())); + var output = new RunBackendStepOutput(); + new GenerationPreviewProcesses.Prepare().run(new RunBackendStepInput().withProcessName(GenerationPreviewProcesses.name(GenerationTables.TEMPLATE)) + .withRecords(List.of(new QRecord().withValue("id", template))), output); + assertThat(output.getProcessMetaDataAdjustment().getUpdatedFields()).containsOnlyKeys("sourceRecordId"); + } + + /*************************************************************************** + ** A person without the authoring permission cannot run the action on a + ** template, and one without the view permission cannot run it on a revision. + ***************************************************************************/ + @Test + void theActionIsRefusedWithoutTheAuthoringOrViewPermission() throws Exception + { + String onTemplate = GenerationPreviewProcesses.name(GenerationTables.TEMPLATE); + String onRevision = GenerationPreviewProcesses.name(GenerationTables.REVISION); + login(true, "view", "generate", "publish", "print"); + assertThat(PermissionsHelper.hasProcessPermission(new RunProcessInput(), onTemplate)).isFalse(); + assertThat(PermissionsHelper.hasProcessPermission(new RunProcessInput(), onRevision)).isTrue(); + assertThatThrownBy(() -> new GenerationPreviewProcesses.Prepare().run(new RunBackendStepInput().withProcessName(onTemplate) + .withRecords(List.of(new QRecord().withValue("id", template))), new RunBackendStepOutput())).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new GenerationPreviewProcesses.Check().run(new RunBackendStepInput().withProcessName(onTemplate) + .withValues(Map.of("previewTemplateId", template, "dataSource", "SAMPLE")), new RunBackendStepOutput())).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new RunProcessAction().execute(new RunProcessInput().withProcessName(onTemplate).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK) + .withCallback(QProcessCallbackFactory.forPrimaryKey("id", template)))).isInstanceOf(QException.class); + login(true, "configure", "generate", "publish", "print"); + assertThat(PermissionsHelper.hasProcessPermission(new RunProcessInput(), onTemplate)).isTrue(); + assertThat(PermissionsHelper.hasProcessPermission(new RunProcessInput(), onRevision)).isFalse(); + assertThatThrownBy(() -> new GenerationPreviewProcesses.Prepare().run(new RunBackendStepInput().withProcessName(onRevision) + .withRecords(List.of(new QRecord().withValue("id", revision))), new RunBackendStepOutput())).isInstanceOf(QPermissionDeniedException.class); + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** The form refuses at once what the preview would refuse, without drawing. + ***************************************************************************/ + @Test + void theFormRefusesAtOnceWhatThePreviewWouldRefuse() throws Exception + { + String name = GenerationPreviewProcesses.name(GenerationTables.TEMPLATE); + new GenerationPreviewProcesses.Check().run(new RunBackendStepInput().withProcessName(name).withValues(Map.of("previewTemplateId", template, "dataSource", "SAMPLE", "sampleName", "long one", + "stockId", stock, "targetDpi", 600)), new RunBackendStepOutput()); + new GenerationPreviewProcesses.Check().run(new RunBackendStepInput().withProcessName(name).withValues(Map.of("previewTemplateId", template, "dataSource", "RECORD", "sourceRecordId", 1)), + new RunBackendStepOutput()); + List refusals = List.of( + new Refused(Map.of("previewTemplateId", template, "dataSource", "RECORD", "sourceRecordId", 2), QNotFoundException.class, "Source record not found."), + new Refused(Map.of("previewTemplateId", template, "dataSource", "RECORD"), QBadRequestException.class, "Choose the record to preview."), + new Refused(Map.of("previewTemplateId", template, "dataSource", "SAMPLE", "sampleName", "absent"), QBadRequestException.class, "The scene declares no sample of that name."), + new Refused(Map.of("previewTemplateId", template, "dataSource", "SAMPLE", "targetDpi", 204), QBadRequestException.class, null), + new Refused(Map.of("previewTemplateId", template, "dataSource", "EVERYTHING"), QBadRequestException.class, "Choose the data, the stock and the resolution of the preview from the form."), + new Refused(Map.of("previewTemplateId", template), QBadRequestException.class, "Choose the data, the stock and the resolution of the preview from the form."), + new Refused(Map.of("previewTemplateId", "seven", "dataSource", "SAMPLE"), QBadRequestException.class, "Choose the data, the stock and the resolution of the preview from the form."), + new Refused(Map.of("dataSource", "SAMPLE"), QBadRequestException.class, null)); + for(Refused refused : refusals) + { + var thrown = assertThatThrownBy(() -> new GenerationPreviewProcesses.Check().run(new RunBackendStepInput().withProcessName(name).withValues(refused.values()), new RunBackendStepOutput())) + .as(refused.toString()).isInstanceOf(refused.type()); + if(refused.message() != null) + { + thrown.hasMessage(refused.message()); + } + } + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** A finished action with a real record keeps only the person's choices in + ** the process state: no picture, no context, no value of the record, no + ** diagnostic. The picture exists only in the answer to the widget request, + ** and that request, built as a client builds it (the identifier of the run + ** and every plain process value), carries no record identity, no sample + ** name and no other choice of the form: the widget reads them from the + ** state of the run. + ***************************************************************************/ + @Test + void aFinishedActionKeepsOnlyTheChoicesAndItsWidgetRequestCarriesNoRecord() throws Exception + { + QSession person = QContext.getQSession(); + QContext.init(instance, new QSession().withUser(new QUser().withIdReference("setup")).withPermissions("orders.read", "orders.write").withSecurityKeyValue("tenant", 1)); + new InsertAction().execute(new InsertInput("orders").withRecords(List.of(new QRecord().withValue("id", 4711).withValue("name", "canonical").withValue("tenantId", 1)))); + QContext.init(instance, person); + Map before = counts(); + String name = GenerationPreviewProcesses.name(GenerationTables.TEMPLATE); + RunProcessOutput form = new RunProcessAction().execute(user(new RunProcessInput().withProcessName(name).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK) + .withCallback(QProcessCallbackFactory.forPrimaryKey("id", template)))); + assertThat(form.getProcessState().getNextStepName()).contains("options"); + assertThat(lines(fetch(request(form)))).containsExactly(GONE); + Map chosen = new LinkedHashMap<>(); + chosen.put("dataSource", "RECORD"); + chosen.put("sourceRecordId", 4711); + chosen.put("targetDpi", 300); + RunProcessOutput result = new RunProcessAction().execute(user(new RunProcessInput().withProcessName(name).withProcessUUID(form.getProcessUUID()).withStartAfterStep("options") + .withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK).withValues(chosen))); + assertThat(result.getException()).isEmpty(); + assertThat(result.getProcessState().getNextStepName()).contains("result"); + assertThat(previews).isZero(); + Map parameters = request(result); + assertThat(parameters).isEqualTo(Map.of("processUUID", form.getProcessUUID(), "previewTemplateId", template.toString(), "sourceTable", "orders")); + assertThat(JsonUtils.toJson(parameters)).doesNotContain("4711").doesNotContain("sourceRecordId").doesNotContain("sampleName").doesNotContain("RECORD"); + List shown = lines(fetch(parameters)); + assertThat(shown).contains("This is the picture that would print.", "Current draft, one record, 300 dpi.", "IMAGE Label preview, 812 by 203 dots"); + assertThat(previews).isEqualTo(1); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("id", 4711, "name", "canonical", "tenantId", 1), "fromScript", "canonical")); + assertThat(previewed.targetDpi()).isEqualTo(300); + var state = RunProcessAction.getState(form.getProcessUUID()).orElseThrow(); + assertThat(state.getValues().keySet().stream().filter(key -> !key.startsWith("_")).collect(Collectors.toSet())) + .containsExactlyInAnyOrder("previewTemplateId", "sourceTable", GenerationPreviewProcesses.CHOICES); + assertThat(state.getValues()).containsEntry("previewTemplateId", template) + .containsEntry(GenerationPreviewProcesses.CHOICES, new HashMap<>(Map.of("previewTemplateId", template.toString(), "dataSource", "RECORD", "sourceRecordId", "4711", "targetDpi", "300"))); + List rows = state.getRecords() == null ? List.of() : state.getRecords(); + String kept = JsonUtils.toJson(state.getValues()) + JsonUtils.toJson(rows.stream().map(QRecord::getValues).toList()); + assertThat(kept).doesNotContain("data:image").doesNotContain("canonical").doesNotContain(Base64.getEncoder().encodeToString(PNG)).doesNotContain(HASH) + .doesNotContain("fromScript"); + assertThat(rows).allMatch(row -> template.equals(row.getValueInteger("id"))); + assertThat(counts()).isEqualTo(before); + assertThat(audits).isEmpty(); + } + + /*************************************************************************** + ** A sample's name is a choice like the record: it is in the state of the + ** run and not in the widget request. + ***************************************************************************/ + @Test + void theWidgetRequestOfTheActionCarriesNoSampleName() throws Exception + { + String name = GenerationPreviewProcesses.name(GenerationTables.REVISION); + RunProcessOutput form = new RunProcessAction().execute(user(new RunProcessInput().withProcessName(name).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK) + .withCallback(QProcessCallbackFactory.forPrimaryKey("id", revision)))); + Map chosen = new LinkedHashMap<>(); + chosen.put("dataSource", "SAMPLE"); + chosen.put("sampleName", "long one"); + chosen.put("stockId", stock); + RunProcessOutput result = new RunProcessAction().execute(user(new RunProcessInput().withProcessName(name).withProcessUUID(form.getProcessUUID()).withStartAfterStep("options") + .withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK).withValues(chosen))); + assertThat(result.getException()).isEmpty(); + Map parameters = request(result); + assertThat(parameters).isEqualTo(Map.of("processUUID", form.getProcessUUID(), "previewRevisionId", revision.toString(), "sourceTable", "orders")); + assertThat(lines(fetch(parameters))).contains("Published revision " + revision + ", sample data, 203 dpi."); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("name", "A longer sample"))); + assertThat(scripts).isZero(); + } + + /*************************************************************************** + ** The widget of the action answers the person who ran it, in the session + ** that ran it, and reads nothing but the run from the request. A run that + ** is unknown or holds no accepted choices is one refusal; choices that are + ** not choices of the form are refused as on the form; a revision, a record + ** or a data source sent as a request parameter is not read at all. + ***************************************************************************/ + @Test + void theWidgetOfTheActionReadsItsChoicesFromThePersonsOwnRun() throws Exception + { + Map run = ran(Map.of("previewTemplateId", template.toString(), "dataSource", "RECORD", "sourceRecordId", "1")); + assertThat(lines(fetch(run))).contains("Current draft, one record, 203 dpi."); + Map noise = new HashMap<>(run); + noise.putAll(Map.of("previewRevisionId", "987654", "dataSource", "SAMPLE", "sourceRecordId", "2", "targetDpi", "600", "tableName", "orders", "id", "2")); + assertThat(lines(fetch(noise))).contains("Current draft, one record, 203 dpi."); + assertThat(previewed.context().get("record")).isEqualTo(Map.of("id", 1, "name", "canonical", "tenantId", 1)); + previews = 0; + Map absent = new HashMap<>(); + absent.put("processUUID", null); + for(Map unknown : List.of(Map.of("processUUID", UUID.randomUUID().toString()), Map.of("processUUID", "not-a-run"), Map.of("processUUID", ""), absent)) + { + assertThat(lines(fetch(unknown))).as(unknown.toString()).containsExactly(GONE); + } + assertThat(lines(fetch(ran(Map.of("previewTemplateId", "seven", "dataSource", "SAMPLE"))))).containsExactly("Choose the data, the stock and the resolution of the preview from the form."); + assertThat(lines(fetch(ran(Map.of("previewTemplateId", template.toString(), "dataSource", "EVERYTHING"))))).containsExactly("Choose the data, the stock and the resolution of the preview from the form."); + String unaccepted = ran(Map.of("previewTemplateId", template.toString(), "dataSource", "SAMPLE")).get("processUUID"); + RunProcessAction.getState(unaccepted).orElseThrow().getValues().put(GenerationPreviewProcesses.CHOICES, "previewTemplateId=" + template); + assertThat(lines(fetch(Map.of("processUUID", unaccepted)))).containsExactly(GONE); + for(Map direct : List.of(Map.of("previewTemplateId", template.toString(), "dataSource", "SAMPLE"), + Map.of("previewRevisionId", revision.toString(), "dataSource", "RECORD", "sourceRecordId", "1"))) + { + assertThat(lines(fetch(direct))).as(direct.toString()).containsExactly("The label preview belongs to a document template."); + } + assertThat(previews).isZero(); + login(true, "view", "configure", "publish", "generate", "print"); + assertThatThrownBy(() -> fetch(run)).isInstanceOf(QPermissionDeniedException.class); + QContext.init(instance, new QSession().withUser(new QUser().withIdReference("mallory")).withSecurityKeyValue("tenant", 1) + .withPermissions("orders.read", "document-generation.view", "document-generation.configure", "document-generation.generate")); + assertThatThrownBy(() -> fetch(run)).isInstanceOf(QPermissionDeniedException.class); + assertThat(previews).isZero(); + } + + /*************************************************************************** + ** A revision that is named must be a publication. The revision table also + ** holds the frozen drafts of retained previews; such a row, and a row with + ** any purpose other than publication, is answered as an identity that does + ** not exist: the same type and the same words from the service, from the + ** action before and after its form, and from the widget, whatever the + ** person holds and whether sample data or a record is asked for. A person + ** who may author previews the draft through its template. A publication + ** stays one after a later publication replaces it. + ***************************************************************************/ + @Test + void aNamedRevisionThatIsNotAPublicationIsNotFound() throws Exception + { + DocumentDefinitionService definitions = new DocumentDefinitionService(config); + DocumentPreviewService service = new DocumentPreviewService(config); + definitions.saveDraft(template, stock, new DocumentDefinitionService.Draft(null, SCENE.replace("Sample name", "Unpublished name"), "script-7", List.of())); + Integer frozen = definitions.previewRevision(template, definitions.loadDraft(template).fingerprint()); + QRecord row = GenerationStore.get(GenerationTables.REVISION, frozen); + assertThat(row.getValueString("purpose")).isEqualTo("PREVIEW"); + assertThat(row.getValueString("zplSource")).contains("Unpublished name"); + List named = new ArrayList<>(List.of(frozen)); + for(String purpose : List.of("DRAFT", "RETIRED", "SUPERSEDED", "published")) + { + QRecord other = new QRecord(row); + other.setValue("id", null); + other.setValue("purpose", purpose); + named.add(GenerationStore.insert(GenerationTables.REVISION, other).getValueInteger("id")); + } + Integer absent = 987654; + named.add(absent); + String onRevision = GenerationPreviewProcesses.name(GenerationTables.REVISION); + String nothing = null; + for(List held : List.of(List.of("view"), List.of("view", "generate"), List.of("view", "configure", "publish", "generate", "print"))) + { + login(true, held.toArray(String[]::new)); + for(Integer id : named) + { + String who = held + " naming " + id; + for(DocumentPreviewService.Data data : held.contains("generate") ? List.of(DocumentPreviewService.Data.SAMPLE, DocumentPreviewService.Data.LONGEST, DocumentPreviewService.Data.RECORD) + : List.of(DocumentPreviewService.Data.SAMPLE, DocumentPreviewService.Data.LONGEST)) + { + String record = data == DocumentPreviewService.Data.RECORD ? "1" : null; + DocumentPreviewService.Options options = new DocumentPreviewService.Options(null, id, GenerationPorts.Format.ZPL, data, null, record, null, null); + assertThatThrownBy(() -> service.preview(options)).as(who).isExactlyInstanceOf(QNotFoundException.class).hasMessage("Document definition not found."); + assertThatThrownBy(() -> service.check(options)).as(who).isExactlyInstanceOf(QNotFoundException.class).hasMessage("Document definition not found."); + Map submitted = new HashMap<>(Map.of("previewRevisionId", id, "dataSource", data.name())); + Map chosen = new HashMap<>(Map.of("previewRevisionId", id.toString(), "dataSource", data.name())); + if(record != null) + { + submitted.put("sourceRecordId", record); + chosen.put("sourceRecordId", record); + } + assertThatThrownBy(() -> new GenerationPreviewProcesses.Check().run(new RunBackendStepInput().withProcessName(onRevision).withValues(submitted), new RunBackendStepOutput())) + .as(who).isExactlyInstanceOf(QNotFoundException.class).hasMessage("Document definition not found."); + String answered = JsonUtils.toJson(fetch(ran(chosen))); + assertThat(lines(fetch(ran(chosen)))).as(who).containsExactly("Document definition not found."); + nothing = nothing == null ? answered : nothing; + assertThat(answered).as(who).isEqualTo(nothing); + } + assertThatThrownBy(() -> service.choices(null, id)).as(who).isExactlyInstanceOf(QNotFoundException.class).hasMessage("Document definition not found."); + assertThatThrownBy(() -> new GenerationPreviewProcesses.Prepare().run(new RunBackendStepInput().withProcessName(onRevision).withRecords(List.of(new QRecord().withValue("id", id))), + new RunBackendStepOutput())).as(who).isExactlyInstanceOf(QNotFoundException.class).hasMessage("Document definition not found."); + if(!absent.equals(id)) + { + ////////////////////////////////////////////////////////////////////////////////////// + // started on a row from the revision screen, where the row is listed: QQQ selects // + // the row before any step runs (an identity with no row is its own refusal there) // + ////////////////////////////////////////////////////////////////////////////////////// + assertThatThrownBy(() -> new RunProcessAction().execute(user(new RunProcessInput().withProcessName(onRevision).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK) + .withCallback(QProcessCallbackFactory.forPrimaryKey("id", id))))).as(who).isExactlyInstanceOf(QNotFoundException.class).hasMessage("Document definition not found."); + } + } + } + login(true, "generate"); + for(Integer id : named) + { + assertThatThrownBy(() -> service.preview(new DocumentPreviewService.Options(null, id, GenerationPorts.Format.ZPL, DocumentPreviewService.Data.RECORD, null, "1", null, null))) + .as("generate alone naming " + id).isExactlyInstanceOf(QNotFoundException.class).hasMessage("Document definition not found."); + } + assertThat(previews).isZero(); + assertThat(scripts).isZero(); + login(true, "configure"); + service.preview(draft(DocumentPreviewService.Data.SAMPLE)); + assertThat(previewed.revision().id()).isNull(); + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("name", "Unpublished name"), "extra", "scalar")); + login(true, "view", "configure", "publish", "generate", "print"); + Integer later = definitions.publish(template); + QRecord before = new QRecord(GenerationStore.get(GenerationTables.REVISION, revision)); + before.setValue("id", null); + before.setValue("purpose", null); + Integer unmarked = GenerationStore.insert(GenerationTables.REVISION, before).getValueInteger("id"); + login(true, "view"); + for(Integer id : List.of(revision, later, unmarked)) + { + service.preview(new DocumentPreviewService.Options(null, id, GenerationPorts.Format.ZPL, DocumentPreviewService.Data.SAMPLE, null, null, null, null)); + assertThat(previewed.revision().id()).as("publication " + id).isEqualTo(id); + assertThat(lines(fetch(ran(Map.of("previewRevisionId", id.toString(), "dataSource", "SAMPLE"))))).contains("Published revision " + id + ", sample data, 203 dpi."); + } + assertThat(previewed.context()).isEqualTo(Map.of("record", Map.of("name", "Sample name"), "extra", "scalar")); + } + + /*************************************************************************** + ** A record is read only within what the person's channel and credential may + ** read of the source table, the ceiling printing applies, and that is asked + ** before the record is read: the refusal is the ceiling's own and neither a + ** script nor the renderer runs. Sample data involves no record. + ***************************************************************************/ + @Test + void aRecordIsReadOnlyWithinTheChannelAndCredentialCeiling() throws Exception + { + DocumentPreviewService service = new DocumentPreviewService(config); + service.preview(published(DocumentPreviewService.Data.RECORD, "1")); + previews = 0; + scripts = 0; + ClientChannelsMetaData.ofOrWithNew(instance).withDeniedObject("api", "orders"); + QContext.getQSession().withClientChannel("api"); + for(DocumentPreviewService.Options options : List.of(draft(DocumentPreviewService.Data.RECORD, "1"), published(DocumentPreviewService.Data.RECORD, "1"))) + { + assertThatThrownBy(() -> service.preview(options)).isInstanceOf(QPermissionDeniedException.class).hasMessage("Current channel or credential cannot perform this generation mutation."); + assertThatThrownBy(() -> service.check(options)).isInstanceOf(QPermissionDeniedException.class).hasMessage("Current channel or credential cannot perform this generation mutation."); + } + assertThatThrownBy(() -> fetch(ran(Map.of("previewTemplateId", template.toString(), "dataSource", "RECORD", "sourceRecordId", "1")))).isInstanceOf(QPermissionDeniedException.class); + assertThat(previews).isZero(); + assertThat(scripts).isZero(); + service.preview(draft(DocumentPreviewService.Data.SAMPLE)); + assertThat(previews).isEqualTo(1); + QContext.getQSession().withClientChannel(null); + QContext.getQSession().withCredentialScope(new QCredentialScope().withTables(instance.getTables().keySet().stream().filter(table -> !"orders".equals(table)).collect(Collectors.toSet()))); + assertThatThrownBy(() -> service.preview(draft(DocumentPreviewService.Data.RECORD, "1"))).isInstanceOf(QPermissionDeniedException.class) + .hasMessage("Current channel or credential cannot perform this generation mutation."); + service.preview(draft(DocumentPreviewService.Data.SAMPLE)); + assertThat(previews).isEqualTo(2); + assertThat(scripts).isZero(); + } + + /*************************************************************************** + ** A label whose picture alone is refused (it would paint more than a + ** preview paints, or its page is larger than a preview draws) is not said + ** to be unprintable: the verdict says the preview is what fails and that + ** printing is not affected. With any other refusal the layout cannot print. + ***************************************************************************/ + @Test + void aLabelTooDenseToDrawIsSaidToPrintAllTheSame() throws Exception + { + Map screen = Map.of("tableName", GenerationTables.TEMPLATE, "id", template.toString()); + GenerationPorts.Diagnostic dense = new GenerationPorts.Diagnostic("PREVIEW_TOO_LARGE", GenerationPorts.Diagnostic.Severity.ERROR, null, null, null, Map.of("paintedPercent", 1600)); + answer = new GenerationPorts.Preview(List.of(), false, false, List.of(dense), 0L, HASH); + CompositeWidgetData data = fetch(screen); + assertThat(lines(data)).containsExactly("This label is too dense to preview; printing is not affected.", "Current draft, sample data, 203 dpi.", + "Refusal: PREVIEW_TOO_LARGE. The picture would be too large to draw. Limit painted 1600%."); + assertThat(colors(data).getFirst()).isEqualTo("WARNING"); + answer = new GenerationPorts.Preview(List.of(), false, false, List.of(new GenerationPorts.Diagnostic("PREVIEW_TOO_LARGE", GenerationPorts.Diagnostic.Severity.ERROR, null, null, + Map.of("widthDots", 4800, "heightDots", 7200), Map.of("dots", 16000000)), new GenerationPorts.Diagnostic("FIT_NOT_PROVEN", GenerationPorts.Diagnostic.Severity.WARNING, "caption", null, null, null)), + 0L, HASH); + data = fetch(screen); + assertThat(lines(data).getFirst()).isEqualTo("This label is too large to preview; printing is not affected."); + assertThat(colors(data).getFirst()).isEqualTo("WARNING"); + answer = new GenerationPorts.Preview(List.of(), false, false, List.of(dense, + new GenerationPorts.Diagnostic("TEXT_DOES_NOT_FIT", GenerationPorts.Diagnostic.Severity.ERROR, "caption", List.of(24, 131, 764, 56), null, null)), 0L, HASH); + data = fetch(screen); + assertThat(lines(data).getFirst()).isEqualTo("This layout cannot print. There is nothing to draw."); + assertThat(colors(data).getFirst()).isEqualTo("ERROR"); + failure = new GenerationPorts.RenderException(GenerationPorts.FailureCode.PREVIEW_TOO_LARGE); + data = fetch(screen); + assertThat(lines(data)).containsExactly("This label is too dense to preview; printing is not affected."); + assertThat(colors(data)).containsExactly("WARNING"); + assertThat(GenerationPreviewWidget.TOO_DENSE).isEqualTo("This label is too dense to preview; printing is not affected."); + } + + /*************************************************************************** + ** The form says that the longest values picture is indicative and that the + ** renderer's proof at publication decides. + ***************************************************************************/ + @Test + void theHelpSaysThatLongestValuesAreIndicative() + { + List said = new ArrayList<>(); + for(var target : HelpTargetCatalog.collect(instance)) + { + if((target.key().contains("_preview_layout") && target.key().endsWith(";field:dataSource")) || target.key().equals("possibleValueSource:" + GenerationPreviewProcesses.DATA + ";slot:help")) + { + said.add(target.key()); + assertThat(target.contents()).as(target.key()).anyMatch(content -> content.getContent().contains("indicative") && content.getContent().contains("capital W") + && content.getContent().contains("not the widest character of every font") && content.getContent().contains("when the template is published")); + } + } + assertThat(said).hasSize(3); + } + + /*************************************************************************** + ** Every new process, screen, field, section, widget and choice list carries + ** its own help, so a host's help coverage holds without host entries. + ***************************************************************************/ + @Test + void everyNewScreenFieldSectionAndWidgetCarriesHelp() + { + List ours = new ArrayList<>(); + List missing = new ArrayList<>(); + for(var target : HelpTargetCatalog.collect(instance)) + { + String key = target.key(); + if(!key.contains("_preview_layout") && !key.startsWith("widget:" + GenerationPreviewWidget.NAME + ";") && !key.endsWith(";section:" + GenerationPreviewWidget.SECTION) + && !key.startsWith("possibleValueSource:" + GenerationPreviewProcesses.DATA + ";") && !key.startsWith("possibleValueSource:" + GenerationPreviewProcesses.RESOLUTION + ";")) + { + continue; + } + if(key.contains(";slot:") && !key.endsWith(";slot:" + ("widget".equals(target.kind()) ? "label" : "help"))) + { + continue; + } + ours.add(key); + if(target.contents() == null || target.contents().stream().noneMatch(content -> content.getContent() != null && content.getContent().length() > 40)) + { + missing.add(key); + } + } + assertThat(missing).isEmpty(); + List expected = new ArrayList<>(List.of("widget:document_generation_preview;slot:label", "table:document_generation_template;section:labelPreview", + "possibleValueSource:document_generation_preview_data;slot:help", "possibleValueSource:document_generation_preview_resolution;slot:help")); + for(String table : List.of(GenerationTables.TEMPLATE, GenerationTables.REVISION)) + { + String process = "process:" + GenerationPreviewProcesses.name(table); + expected.addAll(List.of(process + ";slot:help", process + ";step:options", process + ";step:result", process + ";field:dataSource", process + ";field:sampleName", + process + ";field:sourceTable", process + ";field:sourceRecordId", process + ";field:stockId", process + ";field:targetDpi")); + } + assertThat(ours).containsExactlyInAnyOrderElementsOf(expected); + } + + /*************************************************************************** + ** A host validates its instance when it starts: nothing the preview + ** registers (the action, its fields and choice lists, the widget, the + ** section) is a validation error. + ***************************************************************************/ + @Test + void whatThePreviewRegistersPassesInstanceValidation() + { + List reasons = List.of(); + try + { + new QInstanceValidator().validate(instance); + } + catch(QInstanceValidationException invalid) + { + reasons = invalid.getReasons(); + } + assertThat(reasons).noneMatch(reason -> reason.contains("preview_layout") || reason.contains("document_generation_preview") || reason.contains("labelPreview") + || reason.contains("Label preview") || reason.contains("GenerationPreview")); + } + + /*************************************************************************** + ** Choosing a record needs `generate` and nothing more, so a person who may + ** preview a published revision with a record can search for one; each + ** record offered is still one that person can read. + ***************************************************************************/ + @Test + void theRecordSelectorNeedsGenerateOnly() throws Exception + { + var search = new SearchPossibleValueSourceInput().withPossibleValueSourceName(GenerationAuthoringProcesses.sourceRecordChoices("orders")).withSearchTerm(""); + login(true, "generate"); + assertThat(new SearchPossibleValueSourceAction().execute(search).getResults()).extracting(value -> String.valueOf(value.getId())).containsExactly("1"); + login(true, "view", "configure"); + assertThatThrownBy(() -> new SearchPossibleValueSourceAction().execute(search)).isInstanceOf(QException.class); + login(false, "generate"); + assertThatThrownBy(() -> new SearchPossibleValueSourceAction().execute(search)).isInstanceOf(QException.class); + } + + /*************************************************************************** + ** The QBit on a memory backend, one source table with a record lock, a + ** published label with samples and a pinned context script. + ***************************************************************************/ + @BeforeEach + void setup() throws Exception + { + MemoryRecordStore.getInstance().reset(); + instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.setEnforcePermissionsInCoreActions(true); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.addSecurityKeyType(new QSecurityKeyType().withName("tenant")); + instance.addTable(new QTableMetaData().withName("orders").withBackendName("memory").withPrimaryKeyField("id") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS)) + .withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("tenant").withFieldName("tenantId")) + .withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("name", QFieldType.STRING)).withField(new QFieldMetaData("tenantId", QFieldType.INTEGER))); + answer = new GenerationPorts.Preview(List.of(new GenerationPorts.PreviewPage(PNG, 812, 203)), true, true, List.of(), 120L, HASH); + config = new DocumentGenerationQBitConfig().withBackendName("memory").withSourceTables(List.of("orders")).withAudit(audits::add) + .withRenderer(new GenerationPorts.IsolatedRenderer() + { + /*************************************************************************** + ** Accept every revision. + ***************************************************************************/ + public void validate(String actor, GenerationPorts.Revision candidate) + { + } + + /*************************************************************************** + ** Remember the context generation delivers, to compare with a preview's. + ***************************************************************************/ + public GenerationPorts.Content render(GenerationPorts.RenderRequest request) + { + rendered = request.context(); + return (new GenerationPorts.Content(new byte[] {1, 2, 3})); + } + + /*************************************************************************** + ** Remember the job and answer as the test arranged. + ***************************************************************************/ + @Override + public GenerationPorts.Preview preview(GenerationPorts.PreviewRequest request) throws QException + { + previews++; + previewed = request; + if(failure != null) + { + throw (failure); + } + return (answer); + } + }).withIntake(new GenerationPorts.RetainedOutputIntake() + { + /*************************************************************************** + ** Retain fixed bytes for the generation this fixture compares with. + ***************************************************************************/ + public GenerationPorts.Receipt retain(GenerationPorts.IntakeRequest request) + { + return (new GenerationPorts.Receipt("file", "version", request.sha256(), 3L, true, true)); + } + + /*************************************************************************** + ** The owner still authorises the version it retained. + ***************************************************************************/ + @Override + public GenerationPorts.Receipt authorize(String actor, GenerationPorts.Source source, String version) + { + return (new GenerationPorts.Receipt("file", "version", "0".repeat(64), 3L, true, true)); + } + }).withPrinting(request -> "job") + .withContextExecution(new GenerationPorts.ContextExecution() + { + /*************************************************************************** + ** Every pinned script of this fixture is published. + ***************************************************************************/ + @Override + public void validatePublished(String actor, String artifact) + { + } + + /*************************************************************************** + ** Count the run and derive one value from the record, or fail as arranged. + ***************************************************************************/ + public Map prepare(GenerationPorts.ContextRequest request) throws QException + { + scripts++; + if(scriptFailure != null) + { + throw (scriptFailure); + } + return (Map.of("fromScript", request.record().get("name"))); + } + }); + config.withRetainedAccess(reference -> + { + GenerationSecurity.source(reference.source()); + return (reference); + }); + new DocumentGenerationQBitProducer().withQBitConfig(config).produce(instance); + new QInstanceEnricher(instance).enrich(); + QContext.init(instance, new QSession().withUser(new QUser().withIdReference("setup")).withPermissions("orders.read", "orders.write") + .withSecurityKeyValue("tenant", 1).withSecurityKeyValue("tenant", 2)); + new InsertAction().execute(new InsertInput("orders").withRecords(List.of(new QRecord().withValue("id", 1).withValue("name", "canonical").withValue("tenantId", 1), + new QRecord().withValue("id", 2).withValue("name", "another tenant").withValue("tenantId", 2)))); + login(true, "view", "configure", "publish", "generate", "print"); + DocumentDefinitionService definitions = new DocumentDefinitionService(config); + int type = definitions.createType("label"); + stock = definitions.createStock("4 x 1", new GenerationPorts.Stock(101600, 25400, 0, 0, 0, 0, 203)); + definitions.bind(type, "orders", true, true); + template = definitions.createTemplate("label", type, stock, "orders"); + definitions.saveDraft(template, stock, new DocumentDefinitionService.Draft(null, SCENE, "script-7", List.of())); + revision = definitions.publish(template); + scripts = 0; + audits.clear(); + } + + /*************************************************************************** + ** Release the context and the in-memory records. + ***************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + MemoryRecordStore.getInstance().reset(); + } + + /*************************************************************************** + ** Sign in as the fixture person with exactly these document permissions, + ** within one tenant, with or without read access to the source table. + ***************************************************************************/ + private void login(boolean sourceRead, String... suffixes) + { + QSession session = new QSession().withUser(new QUser().withIdReference("alice")).withSecurityKeyValue("tenant", 1).withPermissions("orders.write"); + for(String suffix : suffixes) + { + session.withPermissions("document-generation." + suffix); + } + if(sourceRead) + { + session.withPermissions("orders.read"); + } + QContext.init(instance, session); + } + + /*************************************************************************** + ** The current draft with sample data or longest values. + ***************************************************************************/ + private DocumentPreviewService.Options draft(DocumentPreviewService.Data data) + { + return (draft(data, null)); + } + + /*************************************************************************** + ** The current draft with a record. + ***************************************************************************/ + private DocumentPreviewService.Options draft(DocumentPreviewService.Data data, String recordId) + { + return (new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL, data, null, recordId, null, null)); + } + + /*************************************************************************** + ** The published revision. + ***************************************************************************/ + private DocumentPreviewService.Options published(DocumentPreviewService.Data data, String recordId) + { + return (new DocumentPreviewService.Options(null, revision, GenerationPorts.Format.ZPL, data, null, recordId, null, null)); + } + + /*************************************************************************** + ** The request of the action's result step for a run whose accepted form + ** left these choices in its state: a run of the person signed in, in this + ** session, named to the widget by its identifier alone. + ***************************************************************************/ + private Map ran(Map chosen) throws QException + { + String run = UUID.randomUUID().toString(); + ProcessState state = new ProcessState(); + state.setStateAccess(ProcessStateAccess.capture(GenerationPreviewProcesses.name(chosen.containsKey("previewTemplateId") ? GenerationTables.TEMPLATE : GenerationTables.REVISION), run)); + state.getValues().put(GenerationPreviewProcesses.CHOICES, new LinkedHashMap<>(chosen)); + RunProcessAction.getStateProvider().put(new UUIDAndTypeStateKey(UUID.fromString(run), StateType.PROCESS_STATUS), state); + return (Map.of("processUUID", run)); + } + + /*************************************************************************** + ** The widget request as a client builds it for a process step: the + ** identifier of the run and every plain process value (a text, a number, a + ** boolean), each as text. A structured value is not sent. + ***************************************************************************/ + private static Map request(RunProcessOutput step) + { + Map parameters = new HashMap<>(); + step.getValues().forEach((key, value) -> + { + if(value instanceof String || value instanceof Number || value instanceof Boolean) + { + parameters.put(key, String.valueOf(value)); + } + }); + parameters.put("processUUID", step.getProcessUUID()); + return (parameters); + } + + /*************************************************************************** + ** A process request as it arrives from a person's client, so the run is + ** owned by that person's session. + ***************************************************************************/ + private static RunProcessInput user(RunProcessInput input) + { + input.setInputSource(QInputSource.USER); + return (input); + } + + /*************************************************************************** + ** The widget through the standard render action, permission rule included. + ***************************************************************************/ + private CompositeWidgetData fetch(Map parameters) throws QException + { + RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(GenerationPreviewWidget.NAME)); + input.setQueryParams(new HashMap<>(parameters)); + return ((CompositeWidgetData) new RenderWidgetAction().execute(input).getWidgetData()); + } + + /*************************************************************************** + ** The widget as a reader sees it: each text, and each picture by its + ** alternative text. + ***************************************************************************/ + private static List lines(CompositeWidgetData data) + { + List lines = new ArrayList<>(); + for(var block : data.getBlocks()) + { + lines.add(block instanceof ImageBlockData image ? "IMAGE " + image.getValues().getAlt() : ((TextBlockData) block).getValues().getText()); + } + return (lines); + } + + /*************************************************************************** + ** The colour of each block, absent for a picture or plain text. + ***************************************************************************/ + private static List colors(CompositeWidgetData data) + { + List colors = new ArrayList<>(); + for(var block : data.getBlocks()) + { + colors.add(block instanceof TextBlockData text && text.getStyles() != null ? text.getStyles().getColor() : null); + } + return (colors); + } + + /*************************************************************************** + ** The number of rows in every table of the QBit and in the source table. + ***************************************************************************/ + private Map counts() throws QException + { + QSession current = QContext.getQSession(); + QContext.init(instance, new QSession().withUser(new QUser().withIdReference("counter")).withPermissions("orders.read", "document-generation.view") + .withSecurityKeyValue("tenant", 1).withSecurityKeyValue("tenant", 2)); + try + { + Map counts = new LinkedHashMap<>(); + for(String table : instance.getTables().keySet()) + { + counts.put(table, table.startsWith("document_generation_") ? GenerationStore.query(table, new QQueryFilter()).size() + : new QueryAction().execute(new QueryInput(table)).getRecords().size()); + } + return (counts); + } + finally + { + QContext.init(instance, current); + } + } +} diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java index e715484..d65d445 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java @@ -76,19 +76,21 @@ class GenerationProcessesTest void templateRecordSeparatesAuthoringFromAdvancedIdentities() { var table = instance.getTable(GenerationTables.TEMPLATE); - assertThat(table.getSections()).extracting(section -> section.getName()).containsExactly("overview", "layout", "context", "advanced"); + assertThat(table.getSections()).extracting(section -> section.getName()).containsExactly("overview", "labelPreview", "layout", "context", "advanced"); + assertThat(table.getSections().get(1).getWidgetName()).isEqualTo(GenerationPreviewWidget.NAME); + assertThat(table.getSections().get(1).getFieldNames()).isNullOrEmpty(); assertThat(table.getSections().getFirst().getFieldNames()).contains("typeId", "stockId", "publishedRevisionId") .doesNotContain("pdfSource", "zplSource", "scriptArtifact", "assetIds"); - assertThat(table.getSections().get(1).getCollapsible().getInitiallyOpen()).isFalse(); - assertThat(table.getSections().get(3).getCollapsible().getInitiallyOpen()).isFalse(); - assertThat(table.getSections().stream().flatMap(section -> section.getFieldNames().stream()).toList()) + assertThat(table.getSections().get(2).getCollapsible().getInitiallyOpen()).isFalse(); + assertThat(table.getSections().get(4).getCollapsible().getInitiallyOpen()).isFalse(); + assertThat(table.getSections().stream().filter(section -> section.getWidgetName() == null).flatMap(section -> section.getFieldNames().stream()).toList()) .containsExactlyInAnyOrderElementsOf(table.getFields().keySet()); assertThat(table.getField("typeId").getPossibleValueSourceName()).isEqualTo(GenerationTables.TYPE); assertThat(table.getField("stockId").getPossibleValueSourceName()).isEqualTo(GenerationTables.STOCK); assertThat(table.getField("publishedRevisionId").getPossibleValueSourceName()).isEqualTo(GenerationTables.REVISION); assertThat(table.getField("scriptArtifact").getPossibleValueSourceName()).isNull(); var process = instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "preview")); - assertThat(process.getLabel()).isEqualTo("Preview draft"); + assertThat(process.getLabel()).isEqualTo("Generate retained preview file"); assertThat(process.getFrontendStep("result").getLabel()).contains("open or download"); assertThat(process.getFrontendStep("result").getViewFields()).anyMatch(field -> "downloadUrl".equals(field.getName())); } diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java index 4b24792..cfc4fc6 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationRegistrationHooksTest.java @@ -34,16 +34,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals; /******************************************************************************* ** The registration points for the preview, asset and upgrade packages exist and - ** are called, and add nothing: a host that registers the QBit gets exactly the - ** tables, processes, choices, widgets and template sections it got before. + ** are called. The preview package has filled its own: a host that registers + ** the QBit gets the "Preview layout" action on templates and revisions, its + ** two choice lists, the preview widget and the "Label preview" section, and + ** otherwise exactly what it got before. The asset and upgrade points still + ** add nothing. *******************************************************************************/ class GenerationRegistrationHooksTest { /*************************************************************************** - ** The complete default wiring, as the released foundation registers it. + ** The complete default wiring: what the released foundation registered, + ** plus the layout preview. ***************************************************************************/ @Test - void theProducerRegistersExactlyWhatItDidBeforeTheHooks() throws Exception + void theProducerRegistersWhatItDidBeforeTheHooksAndTheLayoutPreview() throws Exception { QInstance instance = instance(); new DocumentGenerationQBitProducer().withQBitConfig(config()).produce(instance); @@ -54,40 +58,50 @@ class GenerationRegistrationHooksTest assertEquals(List.of("document_generation_binding_author_create", "document_generation_queue_document_advance", "document_generation_queue_document_cancel", "document_generation_queue_document_reconcileItem", "document_generation_queue_document_recover", "document_generation_queue_document_status", "document_generation_request_document_download", "document_generation_request_document_reconcile", "document_generation_request_document_reprint", - "document_generation_request_document_status", "document_generation_stock_author_create", "document_generation_stock_author_rename", + "document_generation_request_document_status", "document_generation_revision_preview_layout", "document_generation_stock_author_create", + "document_generation_stock_author_rename", "document_generation_template_author_asset", "document_generation_template_author_catalog", "document_generation_template_author_context", "document_generation_template_author_create", "document_generation_template_author_edit", "document_generation_template_author_naming", "document_generation_template_author_packingSlip", "document_generation_template_author_preview", "document_generation_template_author_publish", "document_generation_template_author_removeAsset", "document_generation_template_author_rename", "document_generation_template_author_replaceAsset", - "document_generation_type_author_create", "document_generation_type_author_rename", "orders_document_enqueue", "orders_document_generate"), + "document_generation_template_preview_layout", "document_generation_type_author_create", "document_generation_type_author_rename", "orders_document_enqueue", "orders_document_generate"), List.copyOf(new TreeSet<>(instance.getProcesses().keySet())), "processes"); - assertEquals(List.of("document_generation_asset", "document_generation_context_publications", "document_generation_formats", "document_generation_preview_source", - "document_generation_preview_source_orders", "document_generation_retained_assets", "document_generation_revision", + assertEquals(List.of("document_generation_asset", "document_generation_context_publications", "document_generation_formats", "document_generation_preview_data", + "document_generation_preview_resolution", "document_generation_preview_source", "document_generation_preview_source_orders", "document_generation_retained_assets", + "document_generation_revision", "document_generation_source_tables", "document_generation_stock", "document_generation_template", "document_generation_type"), List.copyOf(new TreeSet<>(instance.getPossibleValueSources().keySet())), "possible value sources"); - assertEquals(List.of(), + assertEquals(List.of("document_generation_preview"), List.copyOf(new TreeSet<>(instance.getWidgets().keySet())), "widgets"); - assertEquals(List.of("overview", "layout", "context", "advanced"), + assertEquals(List.of("overview", "labelPreview", "layout", "context", "advanced"), instance.getTable(GenerationTables.TEMPLATE).getSections().stream().map(QFieldSection::getName).toList(), "template sections"); } /*************************************************************************** - ** Each registration point, called directly, changes nothing. + ** The asset and upgrade points, called directly, change nothing. The + ** preview point, called on a host that registered no record selectors, + ** still defines the action, without the source table field. ***************************************************************************/ @Test - void eachRegistrationPointAddsNothingYet() throws Exception + void theAssetAndUpgradePointsAddNothingAndThePreviewPointStandsAlone() throws Exception { DocumentGenerationQBitConfig config = config(); assertEquals(List.of(), GenerationAssetTables.tables(config)); QInstance instance = instance(); - GenerationPreviewProcesses.register(instance, config); GenerationAssetProcesses.register(instance, config); GenerationUpgradeProcesses.register(instance, config); assertEquals(List.of(), List.copyOf(instance.getProcesses().keySet())); assertEquals(List.of("orders"), List.copyOf(instance.getTables().keySet())); + GenerationPreviewProcesses.register(instance, config); + assertEquals(List.of("document_generation_revision_preview_layout", "document_generation_template_preview_layout"), List.copyOf(new TreeSet<>(instance.getProcesses().keySet()))); + assertEquals(List.of("dataSource", "sampleName", "sourceRecordId", "stockId", "targetDpi"), instance.getProcess("document_generation_template_preview_layout") + .getFrontendStep("options").getFormFields().stream().map(QFieldMetaData::getName).toList()); + assertEquals(List.of("document_generation_preview"), List.copyOf(instance.getWidgets().keySet())); + assertEquals(List.of("orders"), List.copyOf(instance.getTables().keySet())); QTableMetaData table = new QTableMetaData().withName("template"); GenerationPreviewWidget.section(table); - assertEquals(new QTableMetaData().withName("template").getSections(), table.getSections()); + assertEquals(List.of("labelPreview"), table.getSections().stream().map(QFieldSection::getName).toList()); + assertEquals("document_generation_preview", table.getSections().getFirst().getWidgetName()); } /***************************************************************************