From c7f5ea96e260e8ff4f0f774fd0d3a60752a6f8bb Mon Sep 17 00:00:00 2001 From: i Date: Mon, 7 Sep 2026 15:09:19 -0400 Subject: [PATCH] Preserve typed prepaint block variants --- .github/workflows/android-prepaint.yml | 3 + android-prepaint/README.md | 5 + .../ib/prepaint/PrepaintActivity.java | 109 ++++++------ .../ib/prepaint/PrepaintDocument.java | 156 +++++++++++++----- .../ib/prepaint/PrepaintDocumentTest.java | 54 ++++-- .../tests/TypedPrepaintModelSmoke.java | 78 +++++++++ .../tests/refusal/ArbitraryBlock.java | 6 + .../tests/refusal/IntegerHeadingLevel.java | 6 + android-prepaint/tests/verify_typed_model.sh | 28 ++++ 9 files changed, 326 insertions(+), 119 deletions(-) create mode 100644 android-prepaint/tests/TypedPrepaintModelSmoke.java create mode 100644 android-prepaint/tests/refusal/ArbitraryBlock.java create mode 100644 android-prepaint/tests/refusal/IntegerHeadingLevel.java create mode 100644 android-prepaint/tests/verify_typed_model.sh diff --git a/.github/workflows/android-prepaint.yml b/.github/workflows/android-prepaint.yml index c75ec43..e9642b2 100644 --- a/.github/workflows/android-prepaint.yml +++ b/.github/workflows/android-prepaint.yml @@ -38,6 +38,9 @@ jobs: with: gradle-version: "8.13" + - name: Exercise typed prepaint model and compile refusals + run: sh tests/verify_typed_model.sh + - name: Test, lint, and build debug APK run: gradle --no-daemon :app:testDebugUnitTest :app:lintDebug :app:verifyPrepaintBoundary diff --git a/android-prepaint/README.md b/android-prepaint/README.md index b643496..8945961 100644 --- a/android-prepaint/README.md +++ b/android-prepaint/README.md @@ -44,6 +44,11 @@ IB storage inspector or a later browser shell. The boundary check rejects `android.webkit`, `WebView`, an Internet permission, or a debug APK larger than 2 MiB. +`sh tests/verify_typed_model.sh` exercises the parser without Android and proves +that the checked wire variants remain distinct block types. It also compiles two +intentionally invalid consumers and requires rejection: an arbitrary string-kind +block and an integer outside the six-case heading-level type. + ## Boundary Idriç owns HTML extraction, URL policy, ICU execution, and the `InformationView`. diff --git a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintActivity.java b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintActivity.java index 362f121..545ffaa 100644 --- a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintActivity.java +++ b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintActivity.java @@ -279,7 +279,7 @@ private void renderRevision(PrepaintDocument.Revision revision) { currentRevision = revision; int oldScroll = scroll.getScrollY(); page.removeAllViews(); - if (PrepaintDocument.TEXT_SOURCE.equals(document.sourceKind)) { + if (document.sourceKind == PrepaintDocument.SourceKind.PLAIN_TEXT) { status.setText("IB PREPAINT · plain text"); } else { status.setText("IB PREPAINT " + (revisionIndex + 1) + "/" @@ -307,54 +307,55 @@ private void renderRevision(PrepaintDocument.Revision revision) { } private void addInformationBlock(PrepaintDocument.Block block) { - switch (block.kind) { - case PrepaintDocument.Block.HEADING: - float headingSize = block.level == 1 ? 20 : block.level == 2 ? 17 : 15; - TextView heading = text(block.values.get(0), headingSize, FOREGROUND); - heading.setTypeface(hack, Typeface.BOLD); - addBlock(heading, 0, block.level == 1 ? 8 : 4, 0, 8); - break; - case PrepaintDocument.Block.TEXT: - TextView paragraph = text(block.values.get(0), 15, FOREGROUND); - paragraph.setLineSpacing(0, 1.18f); - paragraph.setTextIsSelectable(true); - addBlock(paragraph, 0, 0, 0, 14); - break; - case PrepaintDocument.Block.LINK: - String label = block.values.get(0); - String target = block.values.get(1); - TextView link = text(label.equals(target) ? target : label + "\n" + target, - 14, LINK); - link.setPadding(dp(8), dp(5), dp(8), dp(5)); - link.setBackground(box(SURFACE, dp(4))); - link.setOnClickListener(ignored -> requestLink(target)); - addBlock(link, 0, 2, 0, 14); - break; - case PrepaintDocument.Block.ROW: - addRow(block); - break; - case PrepaintDocument.Block.FORM: - TextView form = text("[ " + block.values.get(0) + " ] → " - + block.values.get(1), 14, FOREGROUND); - form.setPadding(dp(12), dp(10), dp(12), dp(10)); - form.setBackground(box(SURFACE, dp(4))); - addBlock(form, 0, 3, 0, 14); - break; - case PrepaintDocument.Block.IMAGE: - addImage(block); - break; - default: - throw new IllegalStateException("unhandled block " + block.kind); + if (block instanceof PrepaintDocument.HeadingBlock) { + addHeading((PrepaintDocument.HeadingBlock) block); + } else if (block instanceof PrepaintDocument.TextBlock) { + PrepaintDocument.TextBlock textBlock = (PrepaintDocument.TextBlock) block; + TextView paragraph = text(textBlock.text, 15, FOREGROUND); + paragraph.setLineSpacing(0, 1.18f); + paragraph.setTextIsSelectable(true); + addBlock(paragraph, 0, 0, 0, 14); + } else if (block instanceof PrepaintDocument.LinkBlock) { + PrepaintDocument.LinkBlock linkBlock = (PrepaintDocument.LinkBlock) block; + TextView link = text(linkBlock.label.equals(linkBlock.target) + ? linkBlock.target + : linkBlock.label + "\n" + linkBlock.target, + 14, LINK); + link.setPadding(dp(8), dp(5), dp(8), dp(5)); + link.setBackground(box(SURFACE, dp(4))); + link.setOnClickListener(ignored -> requestLink(linkBlock.target)); + addBlock(link, 0, 2, 0, 14); + } else if (block instanceof PrepaintDocument.RowBlock) { + addRow((PrepaintDocument.RowBlock) block); + } else if (block instanceof PrepaintDocument.FormBlock) { + PrepaintDocument.FormBlock formBlock = (PrepaintDocument.FormBlock) block; + TextView form = text("[ " + formBlock.label + " ] → " + + formBlock.action, 14, FOREGROUND); + form.setPadding(dp(12), dp(10), dp(12), dp(10)); + form.setBackground(box(SURFACE, dp(4))); + addBlock(form, 0, 3, 0, 14); + } else if (block instanceof PrepaintDocument.ImageBlock) { + addImage((PrepaintDocument.ImageBlock) block); + } else { + throw new AssertionError("unhandled prepaint block type"); } } - private void addRow(PrepaintDocument.Block block) { + private void addHeading(PrepaintDocument.HeadingBlock block) { + int level = block.level.wireNumber; + float headingSize = level == 1 ? 20 : level == 2 ? 17 : 15; + TextView heading = text(block.text, headingSize, FOREGROUND); + heading.setTypeface(hack, Typeface.BOLD); + addBlock(heading, 0, level == 1 ? 8 : 4, 0, 8); + } + + private void addRow(PrepaintDocument.RowBlock block) { LinearLayout row = new LinearLayout(this); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.TOP); row.setPadding(dp(1), dp(1), dp(1), dp(1)); row.setBackgroundColor(RULE); - for (String cellText : block.values) { + for (String cellText : block.cells) { TextView cell = text(cellText, 13, FOREGROUND); cell.setTextIsSelectable(true); cell.setPadding(dp(9), dp(9), dp(9), dp(9)); @@ -367,17 +368,13 @@ private void addRow(PrepaintDocument.Block block) { addBlock(row, 0, 0, 0, 3); } - private void addImage(PrepaintDocument.Block block) { - String source = block.values.get(0); - String alternate = block.values.get(1); - String caption = block.values.get(2); - String target = block.values.get(3); - Drawable drawable = loadDrawable(source); + private void addImage(PrepaintDocument.ImageBlock block) { + Drawable drawable = loadDrawable(block.source); if (drawable == null) { - TextView missing = text("[image] " + alternate, 13, SECONDARY); - if (!target.isEmpty()) { + TextView missing = text("[image] " + block.alternateText, 13, SECONDARY); + if (!block.linkTarget.isEmpty()) { missing.setTextColor(LINK); - missing.setOnClickListener(ignored -> requestLink(target)); + missing.setOnClickListener(ignored -> requestLink(block.linkTarget)); } addBlock(missing, 0, 3, 0, 12); return; @@ -386,17 +383,17 @@ private void addImage(PrepaintDocument.Block block) { ImageView image = new ImageView(this); image.setAdjustViewBounds(true); image.setScaleType(ImageView.ScaleType.FIT_CENTER); - image.setContentDescription(alternate); + image.setContentDescription(block.alternateText); image.setImageDrawable(drawable); - if (!target.isEmpty()) { + if (!block.linkTarget.isEmpty()) { image.setClickable(true); image.setFocusable(true); - image.setOnClickListener(ignored -> requestLink(target)); + image.setOnClickListener(ignored -> requestLink(block.linkTarget)); } - addBlock(image, 0, 5, 0, caption.isEmpty() ? 14 : 5); + addBlock(image, 0, 5, 0, block.caption.isEmpty() ? 14 : 5); - if (!caption.isEmpty()) { - TextView captionView = text(caption, 12, SECONDARY); + if (!block.caption.isEmpty()) { + TextView captionView = text(block.caption, 12, SECONDARY); captionView.setLineSpacing(0, 1.12f); addBlock(captionView, 0, 0, 0, 14); } diff --git a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java index b10dbdc..c06350d 100644 --- a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java +++ b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java @@ -11,16 +11,14 @@ final class PrepaintDocument { static final String FORMAT = "ib-prepaint"; static final String VERSION = "1"; - static final String ARTIFACT_SOURCE = "artifact"; - static final String TEXT_SOURCE = "text"; private static final long MAX_CHARACTERS = 4L * 1024L * 1024L; private static final int MAX_REVISIONS = 32; private static final int MAX_BLOCKS_PER_REVISION = 4096; final List revisions; - final String sourceKind; + final SourceKind sourceKind; - private PrepaintDocument(List revisions, String sourceKind) { + private PrepaintDocument(List revisions, SourceKind sourceKind) { this.revisions = Collections.unmodifiableList(new ArrayList<>(revisions)); this.sourceKind = sourceKind; } @@ -50,7 +48,7 @@ static PrepaintDocument fromPlainText(String text, String title) { finishParagraph(paragraph, blocks); } else if (UrlRecognition.isAbsoluteHttpUrl(trimmed)) { finishParagraph(paragraph, blocks); - blocks.add(Block.of(Block.LINK, trimmed, trimmed)); + blocks.add(new LinkBlock(trimmed, trimmed)); } else { if (paragraph.length() != 0) { paragraph.append('\n'); @@ -63,7 +61,7 @@ static PrepaintDocument fromPlainText(String text, String title) { String visibleTitle = title == null || title.trim().isEmpty() ? "Plain text" : title.trim(); Revision revision = new Revision(0, true, "", "", visibleTitle, blocks); - return new PrepaintDocument(Collections.singletonList(revision), TEXT_SOURCE); + return new PrepaintDocument(Collections.singletonList(revision), SourceKind.PLAIN_TEXT); } static PrepaintDocument parse(Reader source) throws IOException { @@ -134,34 +132,33 @@ static PrepaintDocument parse(Reader source) throws IOException { break; case "heading": require(fields.size() == 3, lineNumber, "heading needs level and text"); - int level = parseHeadingLevel(fields.get(1), lineNumber); - current.blocks.add(Block.heading(level, fields.get(2))); + HeadingLevel level = parseHeadingLevel(fields.get(1), lineNumber); + current.blocks.add(new HeadingBlock(level, fields.get(2))); break; case "text": require(fields.size() == 2, lineNumber, "text needs a value"); - current.blocks.add(Block.of(Block.TEXT, fields.get(1))); + current.blocks.add(new TextBlock(fields.get(1))); break; case "link": require(fields.size() == 3, lineNumber, "link needs label and target"); - current.blocks.add(Block.of(Block.LINK, fields.get(1), fields.get(2))); + current.blocks.add(new LinkBlock(fields.get(1), fields.get(2))); break; case "row": require(fields.size() >= 2, lineNumber, "row needs at least one cell"); - current.blocks.add(Block.of(Block.ROW, - fields.subList(1, fields.size()).toArray(new String[0]))); + current.blocks.add(new RowBlock(fields.subList(1, fields.size()))); break; case "form": require(fields.size() == 3, lineNumber, "form needs label and action"); - current.blocks.add(Block.of(Block.FORM, fields.get(1), fields.get(2))); + current.blocks.add(new FormBlock(fields.get(1), fields.get(2))); break; case "image": require(fields.size() == 4 || fields.size() == 5, lineNumber, "image needs source, alternate text, caption, and optional link"); - current.blocks.add(fields.size() == 4 - ? Block.of(Block.IMAGE, - fields.get(1), fields.get(2), fields.get(3), "") - : Block.of(Block.IMAGE, - fields.get(1), fields.get(2), fields.get(3), fields.get(4))); + current.blocks.add(new ImageBlock( + fields.get(1), + fields.get(2), + fields.get(3), + fields.size() == 4 ? "" : fields.get(4))); break; default: throw parseError(lineNumber, "unknown record " + record); @@ -171,14 +168,14 @@ static PrepaintDocument parse(Reader source) throws IOException { require(sawHeader, lineNumber, "missing header"); require(current == null, lineNumber, "unterminated revision"); require(!revisions.isEmpty(), lineNumber, "no revisions"); - return new PrepaintDocument(revisions, ARTIFACT_SOURCE); + return new PrepaintDocument(revisions, SourceKind.ARTIFACT); } private static void finishParagraph(StringBuilder paragraph, List blocks) { if (paragraph.length() == 0) { return; } - blocks.add(Block.of(Block.TEXT, paragraph.toString())); + blocks.add(new TextBlock(paragraph.toString())); paragraph.setLength(0); } @@ -222,12 +219,11 @@ private static long parseSequence(String value, int lineNumber) throws IOExcepti } } - private static int parseHeadingLevel(String value, int lineNumber) throws IOException { + private static HeadingLevel parseHeadingLevel(String value, int lineNumber) + throws IOException { try { int level = Integer.parseInt(value); - require(level >= 1 && level <= 6, lineNumber, - "heading level must be between 1 and 6"); - return level; + return HeadingLevel.fromWireNumber(level, lineNumber); } catch (NumberFormatException error) { throw parseError(lineNumber, "invalid heading level"); } @@ -273,6 +269,36 @@ private static IOException parseError(int lineNumber, String message) { return new IOException("prepaint line " + lineNumber + ": " + message); } + enum SourceKind { + ARTIFACT, + PLAIN_TEXT + } + + enum HeadingLevel { + ONE(1), + TWO(2), + THREE(3), + FOUR(4), + FIVE(5), + SIX(6); + + final int wireNumber; + + HeadingLevel(int wireNumber) { + this.wireNumber = wireNumber; + } + + private static HeadingLevel fromWireNumber(int number, int lineNumber) + throws IOException { + for (HeadingLevel level : values()) { + if (level.wireNumber == number) { + return level; + } + } + throw parseError(lineNumber, "heading level must be between 1 and 6"); + } + } + static final class Revision { final long sequence; final boolean complete; @@ -281,8 +307,8 @@ static final class Revision { final String title; final List blocks; - Revision(long sequence, boolean complete, String requestedUrl, - String resolvedUrl, String title, List blocks) { + private Revision(long sequence, boolean complete, String requestedUrl, + String resolvedUrl, String title, List blocks) { this.sequence = sequence; this.complete = complete; this.requestedUrl = requestedUrl; @@ -292,32 +318,72 @@ static final class Revision { } } - static final class Block { - static final String HEADING = "heading"; - static final String TEXT = "text"; - static final String LINK = "link"; - static final String ROW = "row"; - static final String FORM = "form"; - static final String IMAGE = "image"; + abstract static class Block { + private Block() { + } + } - final String kind; - final int level; - final List values; + static final class HeadingBlock extends Block { + final HeadingLevel level; + final String text; - private Block(String kind, int level, String... values) { - this.kind = kind; + private HeadingBlock(HeadingLevel level, String text) { this.level = level; - List copy = new ArrayList<>(); - Collections.addAll(copy, values); - this.values = Collections.unmodifiableList(copy); + this.text = text; + } + } + + static final class TextBlock extends Block { + final String text; + + private TextBlock(String text) { + this.text = text; + } + } + + static final class LinkBlock extends Block { + final String label; + final String target; + + private LinkBlock(String label, String target) { + this.label = label; + this.target = target; + } + } + + static final class RowBlock extends Block { + final List cells; + + private RowBlock(List cells) { + if (cells.isEmpty()) { + throw new IllegalArgumentException("row must contain at least one cell"); + } + this.cells = Collections.unmodifiableList(new ArrayList<>(cells)); } + } - static Block heading(int level, String text) { - return new Block(HEADING, level, text); + static final class FormBlock extends Block { + final String label; + final String action; + + private FormBlock(String label, String action) { + this.label = label; + this.action = action; } + } - static Block of(String kind, String... values) { - return new Block(kind, 0, values); + static final class ImageBlock extends Block { + final String source; + final String alternateText; + final String caption; + final String linkTarget; + + private ImageBlock(String source, String alternateText, String caption, + String linkTarget) { + this.source = source; + this.alternateText = alternateText; + this.caption = caption; + this.linkTarget = linkTarget; } } diff --git a/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/PrepaintDocumentTest.java b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/PrepaintDocumentTest.java index 1f1038b..9da7021 100644 --- a/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/PrepaintDocumentTest.java +++ b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/PrepaintDocumentTest.java @@ -31,10 +31,22 @@ public void parsesCompleteReplacementRevisions() throws Exception { assertEquals(2, document.revisions.get(0).blocks.size()); assertEquals(5, document.revisions.get(1).sequence); assertTrue(document.revisions.get(1).complete); - assertEquals(PrepaintDocument.Block.IMAGE, - document.revisions.get(1).blocks.get(0).kind); - assertEquals("/full-figure", - document.revisions.get(1).blocks.get(0).values.get(3)); + assertTrue(document.revisions.get(0).blocks.get(0) + instanceof PrepaintDocument.HeadingBlock); + PrepaintDocument.HeadingBlock heading = (PrepaintDocument.HeadingBlock) + document.revisions.get(0).blocks.get(0); + assertEquals(PrepaintDocument.HeadingLevel.ONE, heading.level); + assertEquals("Status", heading.text); + assertTrue(document.revisions.get(0).blocks.get(1) + instanceof PrepaintDocument.RowBlock); + PrepaintDocument.RowBlock row = (PrepaintDocument.RowBlock) + document.revisions.get(0).blocks.get(1); + assertEquals(2, row.cells.size()); + assertTrue(document.revisions.get(1).blocks.get(0) + instanceof PrepaintDocument.ImageBlock); + PrepaintDocument.ImageBlock image = (PrepaintDocument.ImageBlock) + document.revisions.get(1).blocks.get(0); + assertEquals("/full-figure", image.linkTarget); } @Test @@ -45,8 +57,9 @@ public void unescapesTextWithoutChangingFieldBoundaries() throws Exception { + "text\tone\\ttwo\\nthree\\\\four\n" + "end\n"); - assertEquals("one\ttwo\nthree\\four", - document.revisions.get(0).blocks.get(0).values.get(0)); + PrepaintDocument.TextBlock text = (PrepaintDocument.TextBlock) + document.revisions.get(0).blocks.get(0); + assertEquals("one\ttwo\nthree\\four", text.text); } @Test @@ -57,7 +70,9 @@ public void acceptsAnUnlinkedImageFromTheOriginalVersionOneShape() throws Except + "image\tcontent://image\talternate\tcaption\n" + "end\n"); - assertEquals("", document.revisions.get(0).blocks.get(0).values.get(3)); + PrepaintDocument.ImageBlock image = (PrepaintDocument.ImageBlock) + document.revisions.get(0).blocks.get(0); + assertEquals("", image.linkTarget); } @Test @@ -79,13 +94,15 @@ public void prepaintsOrdinaryTextAsParagraphs() throws Exception { "First paragraph.\nStill first.\n\nSecond paragraph.\n", "notes.txt"); - assertEquals(PrepaintDocument.TEXT_SOURCE, document.sourceKind); + assertEquals(PrepaintDocument.SourceKind.PLAIN_TEXT, document.sourceKind); assertEquals("notes.txt", document.revisions.get(0).title); assertEquals(2, document.revisions.get(0).blocks.size()); - assertEquals("First paragraph.\nStill first.", - document.revisions.get(0).blocks.get(0).values.get(0)); - assertEquals("Second paragraph.", - document.revisions.get(0).blocks.get(1).values.get(0)); + PrepaintDocument.TextBlock first = (PrepaintDocument.TextBlock) + document.revisions.get(0).blocks.get(0); + PrepaintDocument.TextBlock second = (PrepaintDocument.TextBlock) + document.revisions.get(0).blocks.get(1); + assertEquals("First paragraph.\nStill first.", first.text); + assertEquals("Second paragraph.", second.text); } @Test @@ -95,12 +112,13 @@ public void prepaintsAPlainTextUrlListAsLinks() throws Exception { "urls.txt"); assertEquals(2, document.revisions.get(0).blocks.size()); - assertEquals(PrepaintDocument.Block.LINK, - document.revisions.get(0).blocks.get(0).kind); - assertEquals("https://example.com/one", - document.revisions.get(0).blocks.get(0).values.get(1)); - assertEquals(PrepaintDocument.Block.LINK, - document.revisions.get(0).blocks.get(1).kind); + assertTrue(document.revisions.get(0).blocks.get(0) + instanceof PrepaintDocument.LinkBlock); + PrepaintDocument.LinkBlock first = (PrepaintDocument.LinkBlock) + document.revisions.get(0).blocks.get(0); + assertEquals("https://example.com/one", first.target); + assertTrue(document.revisions.get(0).blocks.get(1) + instanceof PrepaintDocument.LinkBlock); } @Test diff --git a/android-prepaint/tests/TypedPrepaintModelSmoke.java b/android-prepaint/tests/TypedPrepaintModelSmoke.java new file mode 100644 index 0000000..c5379f8 --- /dev/null +++ b/android-prepaint/tests/TypedPrepaintModelSmoke.java @@ -0,0 +1,78 @@ +package org.isomorphisms.ib.prepaint; + +import java.io.IOException; +import java.io.StringReader; + +public final class TypedPrepaintModelSmoke { + public static void main(String[] arguments) throws Exception { + PrepaintDocument document = PrepaintDocument.parse(new StringReader( + "ib-prepaint\t1\n" + + "revision\t4\tcomplete\n" + + "heading\t2\tTyped heading\n" + + "text\tordinary prose\n" + + "link\tlabel\t/target\n" + + "row\tleft\tright\n" + + "form\tSearch\t/search\n" + + "image\tcontent://figure\talt\tcaption\t/figure\n" + + "end\n")); + + check(document.sourceKind == PrepaintDocument.SourceKind.ARTIFACT, + "structured input must retain artifact source kind"); + check(document.revisions.get(0).blocks.get(0) + instanceof PrepaintDocument.HeadingBlock, + "heading record must become HeadingBlock"); + PrepaintDocument.HeadingBlock heading = (PrepaintDocument.HeadingBlock) + document.revisions.get(0).blocks.get(0); + check(heading.level == PrepaintDocument.HeadingLevel.TWO, + "heading level must retain its checked six-case value"); + check("Typed heading".equals(heading.text), "heading text changed"); + check(document.revisions.get(0).blocks.get(1) + instanceof PrepaintDocument.TextBlock, + "text record must become TextBlock"); + check(document.revisions.get(0).blocks.get(2) + instanceof PrepaintDocument.LinkBlock, + "link record must become LinkBlock"); + check(document.revisions.get(0).blocks.get(3) + instanceof PrepaintDocument.RowBlock, + "row record must become RowBlock"); + check(document.revisions.get(0).blocks.get(4) + instanceof PrepaintDocument.FormBlock, + "form record must become FormBlock"); + check(document.revisions.get(0).blocks.get(5) + instanceof PrepaintDocument.ImageBlock, + "image record must become ImageBlock"); + + PrepaintDocument.ImageBlock image = (PrepaintDocument.ImageBlock) + document.revisions.get(0).blocks.get(5); + check("content://figure".equals(image.source), "image source changed"); + check("alt".equals(image.alternateText), "image alternate text changed"); + check("caption".equals(image.caption), "image caption changed"); + check("/figure".equals(image.linkTarget), "image link target changed"); + + PrepaintDocument plain = PrepaintDocument.parseOrPlainText( + new StringReader("ordinary prose"), "notes.txt"); + check(plain.sourceKind == PrepaintDocument.SourceKind.PLAIN_TEXT, + "plain input must retain plain-text source kind"); + expectParseFailure("heading\t0\ttoo low", "heading level 0"); + expectParseFailure("heading\t7\ttoo high", "heading level 7"); + expectParseFailure("row", "empty row"); + } + + private static void expectParseFailure(String record, String description) + throws Exception { + try { + PrepaintDocument.parse(new StringReader( + "ib-prepaint\t1\nrevision\t1\tcomplete\n" + + record + "\nend\n")); + throw new AssertionError(description + " was accepted"); + } catch (IOException expected) { + // The wire boundary must reject before constructing a semantic block. + } + } + + private static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/android-prepaint/tests/refusal/ArbitraryBlock.java b/android-prepaint/tests/refusal/ArbitraryBlock.java new file mode 100644 index 0000000..e3325f6 --- /dev/null +++ b/android-prepaint/tests/refusal/ArbitraryBlock.java @@ -0,0 +1,6 @@ +package org.isomorphisms.ib.prepaint; + +final class ArbitraryBlock { + PrepaintDocument.Block invalid = + PrepaintDocument.Block.of("image", "only one untyped field"); +} diff --git a/android-prepaint/tests/refusal/IntegerHeadingLevel.java b/android-prepaint/tests/refusal/IntegerHeadingLevel.java new file mode 100644 index 0000000..37b6e84 --- /dev/null +++ b/android-prepaint/tests/refusal/IntegerHeadingLevel.java @@ -0,0 +1,6 @@ +package org.isomorphisms.ib.prepaint; + +final class IntegerHeadingLevel { + PrepaintDocument.HeadingBlock invalid = + new PrepaintDocument.HeadingBlock(7, "outside the wire domain"); +} diff --git a/android-prepaint/tests/verify_typed_model.sh b/android-prepaint/tests/verify_typed_model.sh new file mode 100644 index 0000000..3cae183 --- /dev/null +++ b/android-prepaint/tests/verify_typed_model.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +android_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +classes=$(mktemp -d) +diagnostics=$(mktemp -d) +cleanup() { + rm -rf "$classes" "$diagnostics" +} +trap cleanup EXIT HUP INT TERM + +javac --release 17 -d "$classes" \ + "$android_root/app/src/main/java/org/isomorphisms/ib/prepaint/UrlRecognition.java" \ + "$android_root/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java" \ + "$android_root/tests/TypedPrepaintModelSmoke.java" + +java -cp "$classes" org.isomorphisms.ib.prepaint.TypedPrepaintModelSmoke + +for refusal in "$android_root"/tests/refusal/*.java; do + log="$diagnostics/$(basename "$refusal").log" + if javac --release 17 -cp "$classes" -d "$classes" "$refusal" >"$log" 2>&1; then + printf 'expected compile-time refusal: %s\n' "$refusal" >&2 + exit 1 + fi + grep -Eq 'cannot find symbol|incompatible types|has private access' "$log" +done + +printf 'typed prepaint model: positive behavior and refusals passed\n'