Skip to content

Commit d383513

Browse files
authored
test(release-smoke): cover the render-pptx and render-docx artifacts (#440)
The consumer smoke harness is the authoritative "a real user can install this" gate, but it only ever exercised the PDF stack. Both POI backends could ship with a broken pom, a missing ServiceLoader resource, or an unpublished coordinate and the harness would still report green — and render-pptx is the headline artifact of this release. Add s7 (core + render-pptx) and s8 (core + render-docx + render-pdf). Both inspect the emitted OPC package with java.util.zip rather than Apache POI: the point is to prove the published artifacts work for a consumer who installed nothing else, so a scenario must not pull a parsing library of its own to make its assertions pass. s7 asserts more than a valid ZIP header, which an empty file would satisfy: exactly one slide part, the 16:9 slide dimensions in EMU, editable text runs, native shapes, and no picture at all — the failure mode that would mean the backend silently rasterised everything. It also resolves the provider by format. It needs 2.1.0 or later, since neither the fixed-layout backend nor DocumentPageSize.SLIDE_16_9 exists in 2.0.0. s8 carries render-pdf because the combination does not work without it: opening a DocumentSession resolves a FontMetricsProvider, and render-pdf is the only artifact that publishes one. render-docx declares it at test scope for its own suite, so the module's tests pass while a consumer installing core + render-docx alone fails at create() before any export runs. render-docx/README.md now states that requirement. Bump the harness's default version from the release script. Four files hard-coded the version to smoke-test when none is passed, and the script never touched them, so a post-release run that accepted the prefilled default re-verified the *previous* release and reported green — the one failure mode a release gate must not have. Each pattern is anchored to its own construct, and the bump sits on the final-release path so a release candidate correctly leaves it alone. Verified: full reactor clean verify green (1518 tests, 0 failures). Both scenarios run green against a locally installed 2.1.0-SNAPSHOT; s8 fails without render-pdf, which is how the packaging gap surfaced. All three cut-release dry-run modes pass — the full cut bumps all six sites and stages the four files, the RC path performs zero smoke bumps. The <p:pic> assertion was checked against a generated deck that does contain a picture, so it is not vacuously true.
1 parent e75bf5b commit d383513

9 files changed

Lines changed: 404 additions & 3 deletions

File tree

render-docx/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ The semantic DOCX export backend for GraphCompose, backed by Apache POI. It carr
1010
Add it (at compile scope) only when you export `.docx`. It is **not** included by
1111
`graph-compose`, `graph-compose-core`, or `graph-compose-bundle` — DOCX is opt-in.
1212

13+
**It is not sufficient on its own.** Opening a `DocumentSession` resolves a
14+
`FontMetricsProvider` so text can be measured, and `graph-compose-render-pdf` is the
15+
only artifact that publishes one. A classpath of `graph-compose-core` +
16+
`graph-compose-render-docx` fails at `create()` with `MissingBackendException` before
17+
any export happens. Add the PDF backend alongside it — or depend on `graph-compose`,
18+
which is core + render-pdf already:
19+
20+
```xml
21+
<dependency>
22+
<groupId>io.github.demchaav</groupId>
23+
<artifactId>graph-compose-render-pdf</artifactId>
24+
<version>2.0.0</version>
25+
<scope>runtime</scope>
26+
</dependency>
27+
```
28+
1329
## Usage
1430

1531
```java

scripts/cut-release.ps1

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,42 @@ function Update-ModuleReadmeInstallVersion($readmePath, $newVersion) {
404404
}
405405
}
406406

407+
function Update-ReleaseSmokeDefaultVersion($repoRoot, $newVersion) {
408+
# The consumer-smoke harness hard-codes the version it tests when none is
409+
# passed. Left behind, a post-release run that accepts the prefilled default
410+
# re-verifies the PREVIOUS release and reports green — the one failure mode a
411+
# release gate must not have. Each pattern is anchored to its own construct so
412+
# a bump cannot smear across unrelated version strings in the same file.
413+
$targets = @(
414+
@{ Path = 'scripts/release-smoke/run.sh'; Pattern = '(?<=^GC_VERSION=")[\w\.\-]+(?=")'; Label = 'run.sh default' },
415+
@{ Path = 'scripts/release-smoke/run.sh'; Pattern = '(?<=tests gc\.version=)[\w\.\-]+'; Label = 'run.sh usage' },
416+
@{ Path = 'scripts/release-smoke/run.ps1'; Pattern = "(?<=\[string\]\`$Version = ')[\w\.\-]+(?=')"; Label = 'run.ps1 default' },
417+
@{ Path = 'scripts/release-smoke/run.ps1'; Pattern = '(?<=# isolated, tests )[\w\.\-]+'; Label = 'run.ps1 usage' },
418+
@{ Path = '.github/workflows/release-smoke.yml'; Pattern = "(?<=^\s{8}default: ')[\w\.\-]+(?=')"; Label = 'workflow input default' },
419+
@{ Path = 'scripts/release-smoke/README.md'; Pattern = '(?<=defaults to the current published release \(`)[\w\.\-]+(?=`\))'; Label = 'README prose' }
420+
)
421+
422+
foreach ($target in $targets) {
423+
$path = Join-Path $repoRoot $target.Path
424+
if (-not (Test-Path $path)) {
425+
Note "skip (no file): $($target.Path)"
426+
continue
427+
}
428+
$content = Get-Content $path -Raw
429+
$updated = [regex]::Replace($content, $target.Pattern, $newVersion, 'Multiline')
430+
if ($content -eq $updated) {
431+
Note "no change: $($target.Path) [$($target.Label)] (already $newVersion?)"
432+
continue
433+
}
434+
if ($DryRun) {
435+
Write-Host " [DRY RUN] Bump $($target.Path) [$($target.Label)] -> $newVersion" -ForegroundColor Yellow
436+
} else {
437+
[System.IO.File]::WriteAllText($path, $updated)
438+
Note "bumped release-smoke $($target.Label): $($target.Path) -> $newVersion"
439+
}
440+
}
441+
}
442+
407443
function Update-IndexHtmlVersion($indexHtmlPath, $newVersion) {
408444
if (-not (Test-Path $indexHtmlPath)) {
409445
Note "skip (no file): $indexHtmlPath"
@@ -815,6 +851,9 @@ try {
815851
Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $moduleReadme) $Version
816852
}
817853
Update-IndexHtmlVersion (Join-Path $repoRoot 'web/index.html') $Version
854+
# The smoke harness's default version must follow the release, or the
855+
# post-release run silently re-verifies the previous one.
856+
Update-ReleaseSmokeDefaultVersion $repoRoot $Version
818857
} else {
819858
Note "pre-release: skipped README / module-README / web install-snippet bumps (stay on last stable)"
820859
}
@@ -941,7 +980,13 @@ try {
941980
'benchmarks/pom.xml',
942981
'README.md',
943982
'CHANGELOG.md',
944-
'web/index.html'
983+
'web/index.html',
984+
# Bumped by Update-ReleaseSmokeDefaultVersion so the post-release smoke run
985+
# defaults to the version just published, not the previous one.
986+
'scripts/release-smoke/run.sh',
987+
'scripts/release-smoke/run.ps1',
988+
'scripts/release-smoke/README.md',
989+
'.github/workflows/release-smoke.yml'
945990
)
946991
# qa + coverage exist only in the 2.0 aggregator layout; add them to the commit
947992
# only when present so the script stays layout-agnostic (the 1.x single-artifact

scripts/release-smoke/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ build never touches them. Run them explicitly with the harness below.
1919
| `s4-templates` | `graph-compose-templates` | a built-in template composes and renders through the PDF stack |
2020
| `s5-testing` | `graph-compose` + `graph-compose-testing` | the consumer testing helper (`LayoutSnapshotAssertions`) resolves and round-trips a layout snapshot |
2121
| `s6-bundle` | `graph-compose-bundle` | the batteries-included aggregate renders a templated document, exposes the bundled fonts (`DefaultFonts.bundledFontNames()`), and makes the colour-emoji set resolvable (`GraphComposeEmoji.isAvailable()`) |
22+
| `s7-core-render-pptx` | `graph-compose-core` + `graph-compose-render-pptx` | the PPTX backend is discovered by format, and `toPptxBytes()` produces a real OPC package — one slide part, 16:9 slide dimensions in EMU, editable text runs, and **no** full-slide picture in vector mode. **Requires 2.1.0+**: the fixed-layout backend and `DocumentPageSize.SLIDE_16_9` do not exist in 2.0.0, so this scenario cannot pass against an earlier version |
23+
| `s8-core-render-docx` | `graph-compose-core` + `graph-compose-render-docx` + `graph-compose-render-pdf` | the semantic Word exporter is on the consumer's compile classpath (it is named directly, not discovered through the ServiceLoader) and `export(new DocxSemanticBackend())` produces a `word/document.xml` carrying the text. The PDF backend is in the set because it is **required**: opening a session resolves a `FontMetricsProvider` and render-pdf is the only artifact that publishes one, while render-docx declares it at test scope only — so core + render-docx alone cannot construct a session |
24+
25+
Both new scenarios inspect the emitted OPC package with `java.util.zip` rather than
26+
Apache POI. The point is to prove the *published* artifacts work for a consumer who
27+
installed nothing else, so a scenario must not pull a parsing library of its own to make
28+
its assertions pass.
2229

2330
The "must pull core + render-pdf" (wrapper) and "must pull the documented
2431
aggregate" (bundle) assertions are proven positively: `s1` / `s6` can only

scripts/release-smoke/run.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ $repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
2929
$mvnw = Join-Path $repoRoot 'mvnw.cmd'
3030
$settings = Join-Path $here 'settings.xml'
3131

32-
$scenarios = @('s1-graph-compose', 's2-core-only', 's3-core-render-pdf', 's4-templates', 's5-testing', 's6-bundle')
32+
$scenarios = @('s1-graph-compose', 's2-core-only', 's3-core-render-pdf', 's4-templates', 's5-testing', 's6-bundle', 's7-core-render-pptx', 's8-core-render-docx')
3333
$repo = Join-Path $repoRoot 'target\release-smoke-m2\repo'
3434
New-Item -ItemType Directory -Force -Path $repo | Out-Null
3535

scripts/release-smoke/run.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ REPO_ROOT="$(cd "$HERE/../.." && pwd)"
2424
MVNW="$REPO_ROOT/mvnw"
2525
SETTINGS="$HERE/settings.xml"
2626

27-
SCENARIOS=(s1-graph-compose s2-core-only s3-core-render-pdf s4-templates s5-testing s6-bundle)
27+
SCENARIOS=(s1-graph-compose s2-core-only s3-core-render-pdf s4-templates s5-testing s6-bundle s7-core-render-pptx s8-core-render-docx)
2828
REPO="$REPO_ROOT/target/release-smoke-m2/repo"
2929

3030
# Default version under test: the currently published release. Release smoke must
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<!-- Standalone consumer smoke project: no <parent>, never part of the
8+
GraphCompose reactor. The lean core plus the PPTX backend — the
9+
coordinate a PowerPoint consumer actually installs. -->
10+
<groupId>com.demcha.smoke</groupId>
11+
<artifactId>graph-compose-smoke-core-render-pptx</artifactId>
12+
<version>1.0.0</version>
13+
14+
<properties>
15+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
16+
<maven.compiler.release>17</maven.compiler.release>
17+
<gc.version>2.0.0</gc.version>
18+
<junit.version>5.11.4</junit.version>
19+
<assertj.version>3.27.3</assertj.version>
20+
<surefire.version>3.5.2</surefire.version>
21+
</properties>
22+
23+
<dependencies>
24+
<dependency>
25+
<groupId>io.github.demchaav</groupId>
26+
<artifactId>graph-compose-core</artifactId>
27+
<version>${gc.version}</version>
28+
</dependency>
29+
<!-- Declared at runtime scope on purpose: the consumer compiles against
30+
core only and discovers the backend through META-INF/services. The
31+
PDF stack arrives transitively — the backend reuses its font
32+
measurement and the clip raster pass — which this scenario asserts
33+
rather than hides. -->
34+
<dependency>
35+
<groupId>io.github.demchaav</groupId>
36+
<artifactId>graph-compose-render-pptx</artifactId>
37+
<version>${gc.version}</version>
38+
<scope>runtime</scope>
39+
</dependency>
40+
41+
<dependency>
42+
<groupId>org.junit.jupiter</groupId>
43+
<artifactId>junit-jupiter</artifactId>
44+
<version>${junit.version}</version>
45+
<scope>test</scope>
46+
</dependency>
47+
<dependency>
48+
<groupId>org.assertj</groupId>
49+
<artifactId>assertj-core</artifactId>
50+
<version>${assertj.version}</version>
51+
<scope>test</scope>
52+
</dependency>
53+
</dependencies>
54+
55+
<build>
56+
<plugins>
57+
<plugin>
58+
<groupId>org.apache.maven.plugins</groupId>
59+
<artifactId>maven-surefire-plugin</artifactId>
60+
<version>${surefire.version}</version>
61+
</plugin>
62+
</plugins>
63+
</build>
64+
</project>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.demcha.smoke;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentPageSize;
5+
import com.demcha.compose.document.api.DocumentSession;
6+
import com.demcha.compose.document.backend.fixed.BackendProviders;
7+
import org.junit.jupiter.api.Test;
8+
9+
import java.io.ByteArrayInputStream;
10+
import java.io.ByteArrayOutputStream;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.HashMap;
13+
import java.util.Map;
14+
import java.util.zip.ZipEntry;
15+
import java.util.zip.ZipInputStream;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
/**
20+
* Scenario 7 — {@code graph-compose-core} + {@code graph-compose-render-pptx}
21+
* resolved from Maven Central produces a real, editable PowerPoint deck.
22+
*
23+
* <p>The deck is inspected with {@link ZipInputStream} rather than Apache POI:
24+
* the point is to prove the <em>published</em> artifacts work for a consumer who
25+
* installed nothing else, so the scenario must not pull a parsing library of its
26+
* own to make its assertions pass.</p>
27+
*
28+
* <p>An empty file with a valid ZIP header would satisfy a naive smoke test, so
29+
* the assertions go further: the slide part must exist, carry real text runs, and
30+
* consist of native shapes rather than one full-slide picture — the failure mode
31+
* that would mean the backend silently fell back to rasterising everything.</p>
32+
*/
33+
class CoreRenderPptxTest {
34+
35+
/** EMU per PostScript point, the unit OOXML stores slide dimensions in. */
36+
private static final int EMU_PER_POINT = 12700;
37+
38+
@Test
39+
void coreWithRenderPptxProducesAnEditableDeck() throws Exception {
40+
byte[] deck;
41+
try (DocumentSession document = GraphCompose.document()
42+
.pageSize(DocumentPageSize.SLIDE_16_9)
43+
.margin(48f, 48f, 48f, 48f)
44+
.create()) {
45+
document.add(document.dsl().paragraph()
46+
.text("graph-compose-core + graph-compose-render-pptx renders via the SPI.")
47+
.build());
48+
deck = document.toPptxBytes();
49+
}
50+
51+
assertThat(deck).isNotEmpty();
52+
assertThat(new String(deck, 0, 2, StandardCharsets.US_ASCII))
53+
.describedAs("a .pptx is an OPC package, so it must start with the ZIP signature")
54+
.isEqualTo("PK");
55+
56+
Map<String, byte[]> parts = unzip(deck);
57+
assertThat(parts).containsKey("[Content_Types].xml");
58+
assertThat(parts)
59+
.describedAs("one resolved page must become one slide part")
60+
.containsKey("ppt/slides/slide1.xml");
61+
assertThat(parts.keySet().stream().filter(name -> name.startsWith("ppt/slides/slide")))
62+
.hasSize(1);
63+
64+
String presentation = text(parts.get("ppt/presentation.xml"));
65+
assertThat(presentation)
66+
.describedAs("SLIDE_16_9 is 960x540 pt, which OOXML stores in EMU")
67+
.contains("cx=\"" + (960 * EMU_PER_POINT) + "\"")
68+
.contains("cy=\"" + (540 * EMU_PER_POINT) + "\"");
69+
70+
String slide = text(parts.get("ppt/slides/slide1.xml"));
71+
assertThat(slide)
72+
.describedAs("the paragraph must land as an editable text run, not as pixels")
73+
.contains("<a:t>");
74+
assertThat(countOccurrences(slide, "<p:sp>"))
75+
.describedAs("the slide must be built from native shapes")
76+
.isPositive();
77+
// POI writes both elements without attributes, verified against a generated
78+
// deck that does contain a picture — so neither assertion is vacuous, and
79+
// "<p:sp>" cannot accidentally match "<p:spTree" or "<p:spPr>".
80+
assertThat(countOccurrences(slide, "<p:pic>"))
81+
.describedAs("a text-only document must produce no pictures at all in vector mode")
82+
.isZero();
83+
}
84+
85+
@Test
86+
void thePptxProviderIsDiscoveredByFormat() {
87+
assertThat(BackendProviders.fixedLayout("pptx").format()).isEqualTo("pptx");
88+
}
89+
90+
private static Map<String, byte[]> unzip(byte[] archive) throws Exception {
91+
Map<String, byte[]> parts = new HashMap<>();
92+
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive))) {
93+
ZipEntry entry;
94+
while ((entry = zip.getNextEntry()) != null) {
95+
if (entry.isDirectory()) {
96+
continue;
97+
}
98+
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
99+
zip.transferTo(bytes);
100+
parts.put(entry.getName(), bytes.toByteArray());
101+
}
102+
}
103+
return parts;
104+
}
105+
106+
private static String text(byte[] part) {
107+
assertThat(part).isNotNull();
108+
return new String(part, StandardCharsets.UTF_8);
109+
}
110+
111+
private static int countOccurrences(String haystack, String needle) {
112+
int count = 0;
113+
for (int at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length())) {
114+
count++;
115+
}
116+
return count;
117+
}
118+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<!-- Standalone consumer smoke project: no <parent>, never part of the
8+
GraphCompose reactor. The lean core plus the semantic Word exporter. -->
9+
<groupId>com.demcha.smoke</groupId>
10+
<artifactId>graph-compose-smoke-core-render-docx</artifactId>
11+
<version>1.0.0</version>
12+
13+
<properties>
14+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
15+
<maven.compiler.release>17</maven.compiler.release>
16+
<gc.version>2.0.0</gc.version>
17+
<junit.version>5.11.4</junit.version>
18+
<assertj.version>3.27.3</assertj.version>
19+
<surefire.version>3.5.2</surefire.version>
20+
</properties>
21+
22+
<dependencies>
23+
<dependency>
24+
<groupId>io.github.demchaav</groupId>
25+
<artifactId>graph-compose-core</artifactId>
26+
<version>${gc.version}</version>
27+
</dependency>
28+
<!-- Compile scope, unlike the fixed-layout backends: the DOCX exporter is
29+
named directly by the caller (session.export(new DocxSemanticBackend()))
30+
rather than discovered through the ServiceLoader. -->
31+
<dependency>
32+
<groupId>io.github.demchaav</groupId>
33+
<artifactId>graph-compose-render-docx</artifactId>
34+
<version>${gc.version}</version>
35+
</dependency>
36+
<!-- Required, and not transitive: opening a DocumentSession resolves a
37+
FontMetricsProvider, and render-pdf is the only artifact that publishes
38+
one. render-docx declares it at test scope for its own suite, so a
39+
consumer installing core + render-docx alone cannot construct a session
40+
at all. This scenario therefore pins the combination that actually
41+
works — see render-docx/README.md. -->
42+
<dependency>
43+
<groupId>io.github.demchaav</groupId>
44+
<artifactId>graph-compose-render-pdf</artifactId>
45+
<version>${gc.version}</version>
46+
<scope>runtime</scope>
47+
</dependency>
48+
49+
<dependency>
50+
<groupId>org.junit.jupiter</groupId>
51+
<artifactId>junit-jupiter</artifactId>
52+
<version>${junit.version}</version>
53+
<scope>test</scope>
54+
</dependency>
55+
<dependency>
56+
<groupId>org.assertj</groupId>
57+
<artifactId>assertj-core</artifactId>
58+
<version>${assertj.version}</version>
59+
<scope>test</scope>
60+
</dependency>
61+
</dependencies>
62+
63+
<build>
64+
<plugins>
65+
<plugin>
66+
<groupId>org.apache.maven.plugins</groupId>
67+
<artifactId>maven-surefire-plugin</artifactId>
68+
<version>${surefire.version}</version>
69+
</plugin>
70+
</plugins>
71+
</build>
72+
</project>

0 commit comments

Comments
 (0)