diff --git a/build.gradle b/build.gradle index f065399dc..56d4dbbe0 100644 --- a/build.gradle +++ b/build.gradle @@ -217,6 +217,7 @@ dependencies { implementation libs.jing // RELAX NG validation for XML::LibXML implementation libs.snakeyaml.engine // YAML processing implementation libs.tomlj // TOML processing + implementation libs.zxing.core // QR encoding for Text::QRCode implementation libs.commons.csv // CSV processing implementation libs.commonmark // CommonMark rendering implementation libs.commonmark.autolink // GFM autolinks diff --git a/dev/design/jcpan-compiler-tooling-followup.md b/dev/design/jcpan-compiler-tooling-followup.md index 9ff51562b..feecbbe47 100644 --- a/dev/design/jcpan-compiler-tooling-followup.md +++ b/dev/design/jcpan-compiler-tooling-followup.md @@ -37,7 +37,7 @@ The following are not treated as PerlOnJava regressions because their current di ## Progress Tracking -### Current Status: implementation complete; PR ready for review +### Current Status: implementation complete; PR #963 CI green ### Completed Phases @@ -59,11 +59,28 @@ The following are not treated as PerlOnJava regressions because their current di - Opened PR #962 from `fix/jcpan-compiler-tooling-followup`. - GitHub Actions passed on Ubuntu and Windows; the Windows run specifically confirmed the platform-default newline fix in `pipe_jperl_shebang.t`. +- [x] Phase 7: post-merge warning cleanup (2026-08-15) + - Replaced deprecated `Zstd.decompressedSize` calls with + `Zstd.getFrameContentSize` and reject unknown or invalid frame sizes before + allocating decoder buffers. +- [x] Phase 8: core-suite regression audit (2026-08-15) + - Compared the reported core files against isolated current-master and + historical baseline builds instead of treating aggregate TAP counts as + deterministic. + - Fixed PVLV filehandle handling so `-t` follows the glob's IO slot without + stringification and `close` warnings retain ASCII and Unicode glob names. + - Restored `op/gv.t` from 253/304 to 255/304 and `uni/gv.t` from 176/206 to + 178/206. `re/pat_advanced.t`, `re/pat_advanced_thr.t`, and + `test_pl/examples.t` reproduce their higher reported counts. The remaining + `japh/abigail.t` 109/130 result matches both current master and the + documented historical baseline, so it is not a PR #963 regression. + - Added a four-case system-Perl oracle and verified it with both PerlOnJava + backends; the full `make` suite passes. + - PR #963 CI passed on Ubuntu and Windows at commit `b48f504d8`. ### Next Steps -1. Review PR #962. -2. Merge after approval. +1. Hand the unified PR #963 back for user testing and review. ### Open Questions diff --git a/docs/reference/xs-compatibility.md b/docs/reference/xs-compatibility.md index e0d480bb9..e17c9b305 100644 --- a/docs/reference/xs-compatibility.md +++ b/docs/reference/xs-compatibility.md @@ -26,6 +26,8 @@ These modules have optimized Java implementations built into PerlOnJava: | Exporter::Lexical | ExporterLexical.java | 0.02 | Installs lexical subs into the enclosing compile-time scope | | HTML::Content::Extractor | HTMLContentExtractor.java | 0.17 | Uses jsoup's HTML5 parser with a small legacy tree-compatibility layer | | Crypt::Twofish2 | CryptTwofish2.java | 1.03 | Uses BouncyCastle Twofish; ECB, stateful zero-IV CBC, and CFB1 | +| Math::Cephes | MathCephes.java | 0.5308 | Implements the normal and chi-square distribution functions used by CPAN statistics modules | +| Text::QRCode | TextQRCode.java | 0.05 | Uses ZXing in forced byte mode with libqrencode-compatible mask selection | ## Modules with PP Fallbacks or Shims diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c1999f26..70675ff7e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ snappy-java = "1.1.10.8" sqlite-jdbc = "3.53.2.1" tomlj = "1.1.1" zstd-jni = "1.5.7-8" +zxing = "3.5.4" [libraries] asm = { module = "org.ow2.asm:asm", version.ref = "asm" } @@ -37,6 +38,7 @@ snappy-java = { module = "org.xerial.snappy:snappy-java", version.ref = "snappy- sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } tomlj = { module = "org.tomlj:tomlj", version.ref = "tomlj" } zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd-jni" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } [plugins] cyclonedx = "org.cyclonedx.bom:2.3.0" diff --git a/src/main/java/com/booking/sereal/Decoder.java b/src/main/java/com/booking/sereal/Decoder.java index edca41ed6..939763abb 100644 --- a/src/main/java/com/booking/sereal/Decoder.java +++ b/src/main/java/com/booking/sereal/Decoder.java @@ -334,7 +334,13 @@ private void uncompressZstd() throws SerealException { int len = (int) read_varint(); byte[] compressedData = Arrays.copyOfRange(originalData.array, position, position + len); - long decompressedSize = Zstd.decompressedSize(compressedData); + long decompressedSize = Zstd.getFrameContentSize(compressedData); + if (Zstd.isError(decompressedSize)) { + String message = decompressedSize == -1 + ? "Zstd frame content size is unknown" + : Zstd.getErrorName(decompressedSize); + throw new SerealException(message); + } if (decompressedSize > this.maxSize) { throw new SerealException("The expected uncompressed size is larger than the allowed maximum size"); diff --git a/src/main/java/com/booking/sereal/TokenDecoder.java b/src/main/java/com/booking/sereal/TokenDecoder.java index aef8bd412..95555827c 100644 --- a/src/main/java/com/booking/sereal/TokenDecoder.java +++ b/src/main/java/com/booking/sereal/TokenDecoder.java @@ -336,7 +336,13 @@ private void uncompressZstd() throws SerealException { int len = (int) readVarint(); byte[] compressedData = Arrays.copyOfRange(originalData.array, position, position + len); - long decompressedSize = Zstd.decompressedSize(compressedData); + long decompressedSize = Zstd.getFrameContentSize(compressedData); + if (Zstd.isError(decompressedSize)) { + String message = decompressedSize == -1 + ? "Zstd frame content size is unknown" + : Zstd.getErrorName(decompressedSize); + throw new SerealException(message); + } if (decompressedSize > Integer.MAX_VALUE) { throw new SerealException("Decompressed size exceeds integer MAX_VALUE: " + decompressedSize); } diff --git a/src/main/java/com/google/zxing/qrcode/encoder/PerlOnJavaByteModeEncoder.java b/src/main/java/com/google/zxing/qrcode/encoder/PerlOnJavaByteModeEncoder.java new file mode 100644 index 000000000..037a19290 --- /dev/null +++ b/src/main/java/com/google/zxing/qrcode/encoder/PerlOnJavaByteModeEncoder.java @@ -0,0 +1,60 @@ +package com.google.zxing.qrcode.encoder; + +import com.google.zxing.WriterException; +import com.google.zxing.common.BitArray; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; +import com.google.zxing.qrcode.decoder.Mode; +import com.google.zxing.qrcode.decoder.Version; + +import java.nio.charset.Charset; + +/** Accesses ZXing's package-level encoder primitives to force QR byte mode. */ +public final class PerlOnJavaByteModeEncoder { + private PerlOnJavaByteModeEncoder() { + } + + public static QRCode encode(String text, Charset charset, ErrorCorrectionLevel level, + int requestedVersion, int mask) throws WriterException { + BitArray data = new BitArray(); + Encoder.append8BitBytes(text, data, charset); + + Version version = requestedVersion > 0 + ? Version.getVersionForNumber(requestedVersion) + : smallestVersion(data, level); + BitArray headerAndData = new BitArray(); + Encoder.appendModeInfo(Mode.BYTE, headerAndData); + Encoder.appendLengthInfo(data.getSizeInBytes(), version, Mode.BYTE, headerAndData); + headerAndData.appendBitArray(data); + + Version.ECBlocks ecBlocks = version.getECBlocksForLevel(level); + int totalCodewords = version.getTotalCodewords(); + int dataCodewords = totalCodewords - ecBlocks.getTotalECCodewords(); + if (!Encoder.willFit(headerAndData.getSize(), version, level)) { + throw new WriterException("Data too big for requested version"); + } + Encoder.terminateBits(dataCodewords, headerAndData); + BitArray finalBits = Encoder.interleaveWithECBytes( + headerAndData, totalCodewords, dataCodewords, ecBlocks.getNumBlocks()); + + ByteMatrix matrix = new ByteMatrix( + version.getDimensionForVersion(), version.getDimensionForVersion()); + MatrixUtil.buildMatrix(finalBits, level, version, mask, matrix); + QRCode result = new QRCode(); + result.setMode(Mode.BYTE); + result.setECLevel(level); + result.setVersion(version); + result.setMaskPattern(mask); + result.setMatrix(matrix); + return result; + } + + private static Version smallestVersion(BitArray data, ErrorCorrectionLevel level) + throws WriterException { + for (int number = 1; number <= 40; number++) { + Version version = Version.getVersionForNumber(number); + int bits = 4 + Mode.BYTE.getCharacterCountBits(version) + data.getSize(); + if (Encoder.willFit(bits, version, level)) return version; + } + throw new WriterException("Data too big"); + } +} diff --git a/src/main/java/org/perlonjava/frontend/parser/Variable.java b/src/main/java/org/perlonjava/frontend/parser/Variable.java index 3b57853bb..8bccdc03e 100644 --- a/src/main/java/org/perlonjava/frontend/parser/Variable.java +++ b/src/main/java/org/perlonjava/frontend/parser/Variable.java @@ -519,6 +519,7 @@ static Node parseArrayHashAccessInBraces(Parser parser, Node operand, boolean is } case "{" -> { // Hash access + preprocessBackslashQuotesInInterpolatedHashAccess(parser, parser.tokenIndex); operand = ParseInfix.parseInfixOperation(parser, operand, 0); if (operand == null) { throw new PerlCompilerException(parser.tokenIndex, "syntax error: Missing closing brace", parser.ctx.errorUtil); @@ -534,6 +535,10 @@ static Node parseArrayHashAccessInBraces(Parser parser, Node operand, boolean is switch (text) { case "[", "{", "@*", "$*", "%*", "&*", "$#", "@", "%" -> { // Dereference followed by access: $var->[0] or $var->{key} + if (text.equals("{")) { + preprocessBackslashQuotesInInterpolatedHashAccess( + parser, parser.tokenIndex); + } parser.tokenIndex = previousIndex; // Re-parse "->" operand = ParseInfix.parseInfixOperation(parser, operand, 0); if (operand == null) { @@ -565,6 +570,41 @@ static Node parseArrayHashAccessInBraces(Parser parser, Node operand, boolean is return operand; } + /** + * Inside a double-quoted string, quotes delimiting an interpolated hash key + * are escaped for the outer string: {@code "$ref->{\"key\"}"}. The string + * lexer preserves those backslashes, but the embedded expression parser must + * see an ordinary quoted key. + */ + private static void preprocessBackslashQuotesInInterpolatedHashAccess( + Parser parser, int openingBraceIndex) { + if (!parser.preprocessBracedBackslashQuotesInInterpolation) { + return; + } + + int scan = openingBraceIndex; + int braceLevel = 0; + while (scan < parser.tokens.size()) { + String text = parser.tokens.get(scan).text; + if ("{".equals(text)) { + braceLevel++; + scan++; + } else if ("}".equals(text)) { + braceLevel--; + if (braceLevel == 0) { + return; + } + scan++; + } else if ("\\".equals(text) + && scan + 1 < parser.tokens.size() + && "\"".equals(parser.tokens.get(scan + 1).text)) { + parser.tokens.remove(scan); + } else { + scan++; + } + } + } + /** * Parses array and hash access operations following a variable. * @@ -640,6 +680,7 @@ static Node parseArrayHashAccess(Parser parser, Node operand, boolean isRegex) { } case "{" -> { // Hash access + preprocessBackslashQuotesInInterpolatedHashAccess(parser, parser.tokenIndex); int savedIndex = parser.tokenIndex; Node result = null; try { @@ -664,6 +705,10 @@ static Node parseArrayHashAccess(Parser parser, Node operand, boolean isRegex) { switch (text) { case "[", "{", "@*", "$*", "%*", "&*", "$#", "@", "%" -> { // Dereference followed by access: $var->[0] or $var->{key} + if (text.equals("{")) { + preprocessBackslashQuotesInInterpolatedHashAccess( + parser, parser.tokenIndex); + } parser.tokenIndex = previousIndex; // Re-parse "->" Node result = ParseInfix.parseInfixOperation(parser, operand, 0); if (result == null) { diff --git a/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java b/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java index 6dc220f80..ca01efd3a 100644 --- a/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/FileTestOperator.java @@ -26,8 +26,8 @@ * 1. -R, -W, -X, -O (for real uid/gid) are not implemented due to lack of * straightforward Java equivalents. *

- * 2. -t (tty check) is not implemented as it's specific to file handles - * rather than file paths. + * 2. -t (tty check) is implemented for open file handles through their + * native or synthetic descriptor; path operands still return undef. *

* 3. -p, -S, -b, and -c are approximated using file names or paths, as Java * doesn't provide direct equivalents. @@ -349,37 +349,27 @@ public static RuntimeScalar fileTest(String operator, RuntimeScalar fileHandle) return fileTest(operator, new RuntimeScalar(dirPath.toString())); } } - // Special handling for -t on standard streams (STDIN, STDOUT, STDERR) + // -t operates on the handle's IO slot, not on the name of the glob + // which happens to contain it. This matters for PVLVs and detached + // globs such as `$_ = *name; *$_ = *STDOUT{IO}`: stringifying or + // resolving the outer glob loses the real descriptor. if (operator.equals("-t")) { - String globName = null; - if (fileHandle.value instanceof RuntimeGlob rg) { - globName = rg.globName; - } else if (fileHandle.value instanceof RuntimeIO rio) { - globName = rio.globName; - } - if (globName != null) { - int fd = -1; - if (globName.endsWith("::STDIN") || globName.equals("STDIN")) { - fd = 0; - } else if (globName.endsWith("::STDOUT") || globName.equals("STDOUT")) { - fd = 1; - } else if (globName.endsWith("::STDERR") || globName.equals("STDERR")) { - fd = 2; - } - if (fd >= 0) { - try { - boolean isTty = FFMPosix.get().isatty(fd) != 0; - getGlobalVariable("main::!").set(0); - return getScalarBoolean(isTty); - } catch (Exception e) { - // Fall back to System.console() check for fd 0 - if (fd == 0) { - boolean isTty = System.console() != null; - getGlobalVariable("main::!").set(0); - return getScalarBoolean(isTty); - } - } - } + RuntimeScalar descriptor = fh.fileno(); + if (!descriptor.getDefinedBoolean()) { + getGlobalVariable("main::!").set(9); + updateLastStat(fileHandle, false, 9); + return scalarUndef; + } + int fd = descriptor.getInt(); + try { + boolean isTty = FFMPosix.get().isatty(fd) != 0; + getGlobalVariable("main::!").set(0); + return getScalarBoolean(isTty); + } catch (Exception e) { + // Preserve a defined false result for an open, non-terminal + // handle when the platform cannot perform isatty(). + getGlobalVariable("main::!").set(0); + return scalarFalse; } } // Fallback for non-file handles (pipes, sockets, etc.) diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index e6751329b..2d3ae6241 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -8,6 +8,7 @@ import org.perlonjava.runtime.nativ.NativeUtils; import org.perlonjava.runtime.nativ.ffm.FFMPosix; import org.perlonjava.runtime.perlmodule.Socket; +import org.perlonjava.runtime.perlmodule.Warnings; import org.perlonjava.runtime.runtimetypes.*; import java.io.File; @@ -922,6 +923,12 @@ public static RuntimeScalar close(int ctx, RuntimeBase... args) { // Handle case where the filehandle is invalid/corrupted if (fh == null) { + if (unopenedWarningsEnabled()) { + String name = filehandleShortName(handle); + String message = "close() on unopened filehandle" + + (name == null || name.isEmpty() ? "" : " " + name); + WarnDie.warn(new RuntimeScalar(message), new RuntimeScalar("")); + } // Return false (undef in boolean context) for invalid filehandle return new RuntimeScalar(); } @@ -933,6 +940,20 @@ public static RuntimeScalar close(int ctx, RuntimeBase... args) { return fh.close(); } + private static boolean unopenedWarningsEnabled() { + return getGlobalVariable("main::" + Character.toString('W' - 'A' + 1)).getBoolean() + || Warnings.warningManager.isWarningEnabled("unopened") + || Warnings.warningManager.isWarningEnabled("all"); + } + + private static String filehandleShortName(RuntimeScalar handle) { + if (!(handle.value instanceof RuntimeGlob glob) || glob.globName == null) { + return null; + } + int separator = glob.globName.lastIndexOf("::"); + return separator >= 0 ? glob.globName.substring(separator + 2) : glob.globName; + } + /** * Prints the elements to the specified file handle according to the format string. * diff --git a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java index 3da32164c..2aadcfa76 100644 --- a/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/SystemOperator.java @@ -678,17 +678,15 @@ private static CommandResult executeCommand(String command, boolean captureOutpu // For backticks: stdout will be captured (default behavior), // stderr goes through Perl STDERR handle - // Always redirect stdin from /dev/null to prevent subprocess blocking - // This prevents the subprocess from waiting for input that will never come - try { - processBuilder.redirectInput(ProcessBuilder.Redirect.from(new java.io.File("/dev/null"))); - } catch (Exception e) { - // Fallback for systems where /dev/null might not be available - // This should be rare, but provides robustness - } - process = processBuilder.start(); + // system() and qx// subprocesses are deliberately non-interactive in + // PerlOnJava. Closing the ProcessBuilder pipe is the only portable + // way to guarantee EOF here. Redirect.from("/dev/null") left nested + // jperl launchers waiting forever on macOS (for example an old CPAN + // Makefile.PL which reads configuration answers from STDIN). + closeChildStdin(process); + final Process finalProcess = process; final StringBuilder finalOutput = output; @@ -770,6 +768,7 @@ private static CommandResult executeCommandDirect(List commandArgs) { copyPerlEnvToProcessBuilder(processBuilder); process = processBuilder.start(); + closeChildStdin(process); // Route stdout and stderr through Perl handles so that // Perl-level redirections are honored @@ -824,6 +823,7 @@ private static CommandResult executeCommandDirectCapture(List commandArg // Route stderr through Perl STDERR handle (not INHERIT which bypasses Perl redirections) process = processBuilder.start(); + closeChildStdin(process); final Process finalProcess = process; final StringBuilder finalOutput = output; @@ -917,6 +917,14 @@ private static void closeQuietly(BufferedReader reader) { } } + private static void closeChildStdin(Process process) { + try { + process.getOutputStream().close(); + } catch (IOException ignored) { + // The child may have exited before its stdin was closed. + } + } + /** * Writes bytes to the current Perl-level STDERR handle. * This ensures output goes through any Perl-level redirections (e.g., diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/CompressZlib.java b/src/main/java/org/perlonjava/runtime/perlmodule/CompressZlib.java index c5d40178b..9add47cb7 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/CompressZlib.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/CompressZlib.java @@ -617,7 +617,15 @@ public static RuntimeList inflateMethod(RuntimeArray args, int ctx) { RuntimeScalar outputScalar = new RuntimeScalar(outputStr); outputScalar.type = RuntimeScalarType.BYTE_STRING; result.add(outputScalar); - result.add(new RuntimeScalar(status)); + if (ctx != RuntimeContextType.LIST) { + return result; + } + RuntimeScalar statusScalar = new RuntimeScalar(); + statusScalar.type = RuntimeScalarType.DUALVAR; + statusScalar.value = new DualVar( + new RuntimeScalar(status), + new RuntimeScalar(status == 1 ? "stream end" : "")); + result.add(statusScalar); return result; } diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/MathCephes.java b/src/main/java/org/perlonjava/runtime/perlmodule/MathCephes.java new file mode 100644 index 000000000..5a39aff1b --- /dev/null +++ b/src/main/java/org/perlonjava/runtime/perlmodule/MathCephes.java @@ -0,0 +1,117 @@ +package org.perlonjava.runtime.perlmodule; + +import org.perlonjava.runtime.runtimetypes.RuntimeArray; +import org.perlonjava.runtime.runtimetypes.RuntimeList; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; + +/** Java implementations of the Cephes distribution functions used by CPAN statistics modules. */ +public final class MathCephes extends PerlModuleBase { + public static final String XS_VERSION = "0.5308"; + + public MathCephes() { + // Math::Cephes.pm aliases its public functions from this SWIG package. + super("Math::Cephesc", false); + } + + public static void initialize() { + MathCephes module = new MathCephes(); + try { + module.registerMethod("ndtr", null); + module.registerMethod("ndtri", null); + module.registerMethod("chdtrc", null); + } catch (NoSuchMethodException e) { + throw new IllegalStateException("Unable to initialize Math::Cephes", e); + } + } + + public static RuntimeList ndtr(RuntimeArray args, int ctx) { + double x = args.get(0).getDouble(); + return scalar(0.5 * regularizedGammaQ(0.5, x * x / 2.0), x < 0.0); + } + + private static RuntimeList scalar(double upperTail, boolean complement) { + return new RuntimeScalar(complement ? upperTail : 1.0 - upperTail).getList(); + } + + public static RuntimeList ndtri(RuntimeArray args, int ctx) { + double p = args.get(0).getDouble(); + if (p <= 0.0) return new RuntimeScalar(Double.NEGATIVE_INFINITY).getList(); + if (p >= 1.0) return new RuntimeScalar(Double.POSITIVE_INFINITY).getList(); + return new RuntimeScalar(inverseNormal(p)).getList(); + } + + public static RuntimeList chdtrc(RuntimeArray args, int ctx) { + double degreesOfFreedom = args.get(0).getDouble(); + double x = args.get(1).getDouble(); + if (degreesOfFreedom <= 0.0 || x < 0.0) return new RuntimeScalar(0.0).getList(); + return new RuntimeScalar(regularizedGammaQ(degreesOfFreedom / 2.0, x / 2.0)).getList(); + } + + private static double regularizedGammaQ(double a, double x) { + if (x == 0.0) return 1.0; + if (x < a + 1.0) { + double sum = 1.0 / a; + double term = sum; + for (int n = 1; n < 10000; n++) { + term *= x / (a + n); + sum += term; + if (Math.abs(term) < Math.abs(sum) * 1.0e-15) break; + } + return 1.0 - sum * Math.exp(-x + a * Math.log(x) - logGamma(a)); + } + + double b = x + 1.0 - a; + double c = 1.0 / 1.0e-300; + double d = 1.0 / b; + double h = d; + for (int i = 1; i < 10000; i++) { + double an = -i * (i - a); + b += 2.0; + d = an * d + b; + if (Math.abs(d) < 1.0e-300) d = 1.0e-300; + c = b + an / c; + if (Math.abs(c) < 1.0e-300) c = 1.0e-300; + d = 1.0 / d; + double delta = d * c; + h *= delta; + if (Math.abs(delta - 1.0) < 1.0e-15) break; + } + return Math.exp(-x + a * Math.log(x) - logGamma(a)) * h; + } + + private static double logGamma(double x) { + double[] coefficients = { + 676.5203681218851, -1259.1392167224028, 771.32342877765313, + -176.61502916214059, 12.507343278686905, -0.13857109526572012, + 9.9843695780195716e-6, 1.5056327351493116e-7 + }; + if (x < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * x)) - logGamma(1.0 - x); + x -= 1.0; + double sum = 0.99999999999980993; + for (int i = 0; i < coefficients.length; i++) sum += coefficients[i] / (x + i + 1.0); + double t = x + coefficients.length - 0.5; + return 0.5 * Math.log(2.0 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(sum); + } + + // Peter J. Acklam's inverse-normal rational approximation. + private static double inverseNormal(double p) { + double[] a = {-39.69683028665376, 220.9460984245205, -275.9285104469687, + 138.3577518672690, -30.66479806614716, 2.506628277459239}; + double[] b = {-54.47609879822406, 161.5858368580409, -155.6989798598866, + 66.80131188771972, -13.28068155288572}; + double[] c = {-0.007784894002430293, -0.3223964580411365, -2.400758277161838, + -2.549732539343734, 4.374664141464968, 2.938163982698783}; + double[] d = {0.007784695709041462, 0.3224671290700398, 2.445134137142996, + 3.754408661907416}; + if (p < 0.02425) { + double q = Math.sqrt(-2.0 * Math.log(p)); + return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) + / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0); + } + if (p > 0.97575) return -inverseNormal(1.0 - p); + double q = p - 0.5; + double r = q * q; + return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q + / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0); + } +} diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/TextQRCode.java b/src/main/java/org/perlonjava/runtime/perlmodule/TextQRCode.java new file mode 100644 index 000000000..732de2b09 --- /dev/null +++ b/src/main/java/org/perlonjava/runtime/perlmodule/TextQRCode.java @@ -0,0 +1,229 @@ +package org.perlonjava.runtime.perlmodule; + +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; +import com.google.zxing.qrcode.encoder.ByteMatrix; +import com.google.zxing.qrcode.encoder.Encoder; +import com.google.zxing.qrcode.encoder.PerlOnJavaByteModeEncoder; +import com.google.zxing.qrcode.encoder.QRCode; +import org.perlonjava.runtime.runtimetypes.PerlCompilerException; +import org.perlonjava.runtime.runtimetypes.RuntimeArray; +import org.perlonjava.runtime.runtimetypes.RuntimeHash; +import org.perlonjava.runtime.runtimetypes.RuntimeList; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; +import org.perlonjava.runtime.runtimetypes.RuntimeScalarType; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.EnumMap; +import java.util.Locale; +import java.util.Map; + +/** + * Java replacement for Text::QRCode's libqrencode-backed XS function. + * The Perl wrapper and public API remain the unmodified CPAN implementation. + * ZXing is Apache-2.0 licensed; Text::QRCode is licensed under the same terms + * as Perl itself. + */ +public final class TextQRCode extends PerlModuleBase { + public static final String XS_VERSION = "0.05"; + private static final String MODULE = "Text::QRCode"; + + public TextQRCode() { + super(MODULE, false); + } + + public static void initialize() { + TextQRCode module = new TextQRCode(); + try { + module.registerMethod("_plot", null); + } catch (NoSuchMethodException e) { + throw new IllegalStateException("Unable to initialize " + MODULE, e); + } + } + + public static RuntimeList _plot(RuntimeArray args, int ctx) { + if (args.size() < 2 || args.get(0) == null || !args.get(0).defined().getBoolean()) { + throw new PerlCompilerException("Usage: Text::QRCode::_plot(text, params)"); + } + + String text = args.get(0).toString(); + RuntimeHash params = args.get(1).type == RuntimeScalarType.HASHREFERENCE + ? args.get(1).hashDeref() + : new RuntimeHash(); + + ErrorCorrectionLevel level = errorCorrectionLevel(params); + Map hints = new EnumMap<>(EncodeHintType.class); + + int version = intParam(params, "version", 0); + if (version > 0) { + if (version > 40) { + throw new PerlCompilerException("Failed to encode the input data: XS error"); + } + hints.put(EncodeHintType.QR_VERSION, version); + } + + String mode = stringParam(params, "mode", "8-bit"); + boolean caseSensitive = boolParam(params, "casesensitive", false); + Charset byteCharset = null; + switch (mode) { + case "8-bit" -> byteCharset = containsWideCharacter(text) + ? StandardCharsets.UTF_8 : StandardCharsets.ISO_8859_1; + case "numerical" -> { + if (!text.matches("[0-9]*")) { + throw new PerlCompilerException("Failed to encode the input data: XS error"); + } + } + case "alpha-numerical" -> { + if (!caseSensitive) { + text = text.toUpperCase(Locale.ROOT); + } + } + case "kanji" -> hints.put(EncodeHintType.CHARACTER_SET, "Shift_JIS"); + default -> throw new PerlCompilerException("Invalid mode: XS error"); + } + + try { + ByteMatrix matrix = encodeWithLibqrencodeMaskSelection( + text, level, hints, byteCharset, version); + RuntimeArray rows = new RuntimeArray(); + for (int y = 0; y < matrix.getHeight(); y++) { + RuntimeArray row = new RuntimeArray(); + for (int x = 0; x < matrix.getWidth(); x++) { + RuntimeArray.push(row, new RuntimeScalar(matrix.get(x, y) == 1 ? "*" : " ")); + } + RuntimeArray.push(rows, row.createAnonymousReference()); + } + return rows.createAnonymousReference().getList(); + } catch (WriterException | IllegalArgumentException e) { + throw new PerlCompilerException("Failed to encode the input data: XS error"); + } + } + + /** + * libqrencode and ZXing use slightly different interpretations of the + * finder-like-pattern mask penalty. Text::QRCode exposes the bitmap, so + * choose among ZXing's standards-compliant masks using libqrencode's + * published scoring algorithm to preserve the XS module's output. + */ + private static ByteMatrix encodeWithLibqrencodeMaskSelection( + String text, ErrorCorrectionLevel level, Map baseHints, + Charset byteCharset, int version) + throws WriterException { + ByteMatrix best = null; + int bestPenalty = Integer.MAX_VALUE; + for (int mask = 0; mask < 8; mask++) { + Map hints = new EnumMap<>(baseHints); + hints.put(EncodeHintType.QR_MASK_PATTERN, mask); + QRCode code = byteCharset == null + ? Encoder.encode(text, level, hints) + : PerlOnJavaByteModeEncoder.encode(text, byteCharset, level, version, mask); + ByteMatrix matrix = code.getMatrix(); + int penalty = libqrencodePenalty(matrix); + if (penalty < bestPenalty) { + bestPenalty = penalty; + best = matrix; + } + } + return best; + } + + private static int libqrencodePenalty(ByteMatrix matrix) { + int width = matrix.getWidth(); + int black = 0; + int penalty = 0; + for (int y = 0; y < width; y++) { + for (int x = 0; x < width; x++) { + if (matrix.get(x, y) == 1) black++; + if (x > 0 && y > 0) { + int value = matrix.get(x, y); + if (value == matrix.get(x - 1, y) + && value == matrix.get(x, y - 1) + && value == matrix.get(x - 1, y - 1)) { + penalty += 3; + } + } + } + penalty += libqrencodeRunPenalty(matrix, y, true); + } + for (int x = 0; x < width; x++) { + penalty += libqrencodeRunPenalty(matrix, x, false); + } + int blackPercent = (200 * black + width * width) / (width * width) / 2; + return penalty + (Math.abs(blackPercent - 50) / 5) * 10; + } + + private static int libqrencodeRunPenalty(ByteMatrix matrix, int line, boolean horizontal) { + int width = matrix.getWidth(); + int[] runs = new int[width + 1]; + int head; + int first = horizontal ? matrix.get(0, line) : matrix.get(line, 0); + if (first == 1) { + runs[0] = -1; + head = 1; + } else { + head = 0; + } + runs[head] = 1; + int previous = first; + for (int i = 1; i < width; i++) { + int value = horizontal ? matrix.get(i, line) : matrix.get(line, i); + if (value != previous) { + runs[++head] = 1; + previous = value; + } else { + runs[head]++; + } + } + + int count = head + 1; + int penalty = 0; + for (int i = 0; i < count; i++) { + if (runs[i] >= 5) penalty += 3 + runs[i] - 5; + if ((i & 1) != 0 && i >= 3 && i < count - 2 && runs[i] % 3 == 0) { + int unit = runs[i] / 3; + if (runs[i - 2] == unit && runs[i - 1] == unit + && runs[i + 1] == unit && runs[i + 2] == unit + && (i == 3 || runs[i - 3] >= 4 * unit + || i + 4 >= count || runs[i + 3] >= 4 * unit)) { + penalty += 40; + } + } + } + return penalty; + } + + private static ErrorCorrectionLevel errorCorrectionLevel(RuntimeHash params) { + String level = stringParam(params, "level", "L"); + if (level.isEmpty()) return ErrorCorrectionLevel.L; + return switch (Character.toUpperCase(level.charAt(0))) { + case 'M' -> ErrorCorrectionLevel.M; + case 'Q' -> ErrorCorrectionLevel.Q; + case 'H' -> ErrorCorrectionLevel.H; + default -> ErrorCorrectionLevel.L; + }; + } + + private static String stringParam(RuntimeHash params, String key, String fallback) { + if (!params.exists(key).getBoolean()) return fallback; + RuntimeScalar value = params.get(key); + return value != null && value.defined().getBoolean() ? value.toString() : fallback; + } + + private static int intParam(RuntimeHash params, String key, int fallback) { + if (!params.exists(key).getBoolean()) return fallback; + RuntimeScalar value = params.get(key); + return value != null && value.defined().getBoolean() ? value.getInt() : fallback; + } + + private static boolean boolParam(RuntimeHash params, String key, boolean fallback) { + if (!params.exists(key).getBoolean()) return fallback; + RuntimeScalar value = params.get(key); + return value != null && value.defined().getBoolean() ? value.getBoolean() : fallback; + } + + private static boolean containsWideCharacter(String text) { + return text.codePoints().anyMatch(codePoint -> codePoint > 0xff); + } +} diff --git a/src/main/perl/lib/ExtUtils/MakeMaker.pm b/src/main/perl/lib/ExtUtils/MakeMaker.pm index 10df6e72a..5a0185bd7 100644 --- a/src/main/perl/lib/ExtUtils/MakeMaker.pm +++ b/src/main/perl/lib/ExtUtils/MakeMaker.pm @@ -1314,20 +1314,33 @@ sub _shell_mkdir { sub _current_perl_path { my $perl = $ENV{PERLONJAVA_EXECUTABLE} || $Config{perlpath} || $^X; - return $perl if File::Spec->file_name_is_absolute($perl); + return _makefile_shell_path($perl) + if File::Spec->file_name_is_absolute($perl); for my $base ($ENV{PWD}, getcwd()) { next unless defined $base && length $base; my $candidate = File::Spec->catfile($base, $perl); - return abs_path($candidate) || $candidate if -x $candidate; + return _makefile_shell_path(abs_path($candidate) || $candidate) + if -x $candidate; } for my $dir (File::Spec->path()) { my $candidate = File::Spec->catfile($dir, $perl); - return abs_path($candidate) || $candidate if -x $candidate; + return _makefile_shell_path(abs_path($candidate) || $candidate) + if -x $candidate; } - return $perl; + return _makefile_shell_path($perl); +} + +# Generated Makefiles deliberately use a POSIX shell, including on the +# Windows CI image. A native path such as D:\a\project\jperl.bat is therefore +# parsed as shell escapes and collapses to D:aprojectjperl.bat. Windows APIs +# accept forward slashes, and the POSIX shell preserves them. +sub _makefile_shell_path { + my ($path) = @_; + $path =~ tr{\\}{/} if $^O eq 'MSWin32'; + return $path; } # Helper: generate a shell cp command for Makefile. @@ -1347,7 +1360,7 @@ sub _shell_cp { my $autosplit_dir = $autodir; if ($should_autosplit) { $autosplit_dir =~ s/'/'\\''/g; - $autosplit = " && if grep -q '^__END__\$\$' '$dest'; then \$(PERL) -MAutoSplit -e 'autosplit(\$\$ARGV[0], \$\$ARGV[1], 0, 1, 1)' '$dest' '$autosplit_dir'; fi"; + $autosplit = " && if grep -Eq '^__END__;?[[:space:]]*\$\$' '$dest'; then \$(PERL) -MAutoSplit -e 'autosplit(\$\$ARGV[0], \$\$ARGV[1], 0, 1, 1)' '$dest' '$autosplit_dir'; fi"; } return "\t\@if [ -f '$src' ]; then rm -f '$dest' && cp '$src' '$dest'$autosplit; else echo 'PerlOnJava: skipping missing source: $src'; fi"; } @@ -1391,7 +1404,7 @@ sub _shell_fixin { for ($file, $payload, $payload_name) { s/'/'\\''/g; } - return "\t\@\$(PERL) -e 'my (\$\$f,\$\$payload,\$\$payload_name,\$\$perl)=\@ARGV; open my \$\$in,q{<},\$\$f or exit 0; my \@lines=<\$\$in>; close \$\$in; exit 0 unless \@lines && \$\$lines[0] =~ /^#!.*\\bperl(?:\\s+(.*))?\\r?\\n?\\z/; my \$\$args=defined \$\$1 ? q{ }.\$\$1 : q{}; open my \$\$payload_fh,q{>},\$\$payload or die \$\$!; print \$\$payload_fh \@lines; close \$\$payload_fh or die \$\$!; chmod 0644,\$\$payload; my \$\$d=chr 36; my \$\$at=chr 64; open my \$\$out,q{>},\$\$f or die \$\$!; print \$\$out qq{#!/bin/sh\\n}; print \$\$out q{dir=}.\$\$d.q{(dirname \"}.\$\$d.q{0\")}.qq{\\n}; print \$\$out q{exec \"}.\$\$perl.q{\"}.\$\$args.q{ \"}.\$\$d.q{dir/}.\$\$payload_name.q{\" \"}.\$\$d.\$\$at.q{\"}.qq{\\n}; close \$\$out or die \$\$!; chmod 0755,\$\$f;' '$file' '$payload' '$payload_name' '\$(PERL)'"; + return "\t\@\$(PERL) -e 'my (\$\$f,\$\$payload,\$\$payload_name,\$\$perl)=\@ARGV; open my \$\$in,q{<},\$\$f or exit 0; my \@lines=<\$\$in>; close \$\$in; exit 0 unless \@lines && \$\$lines[0] =~ /^#!.*\\bperl(?:\\s+(.*))?\\r?\\n?\\z/; my \$\$args=defined \$\$1 ? q{ }.\$\$1 : q{}; open my \$\$payload_fh,q{>},\$\$payload or die \$\$!; print \$\$payload_fh \@lines; close \$\$payload_fh or die \$\$!; chmod 0644,\$\$payload; my \$\$d=chr 36; my \$\$at=chr 64; chmod 0644,\$\$f or die \$\$!; open my \$\$out,q{>},\$\$f or die \$\$!; print \$\$out qq{#!/bin/sh\\n}; print \$\$out q{dir=}.\$\$d.q{(dirname \"}.\$\$d.q{0\")}.qq{\\n}; print \$\$out q{exec \"}.\$\$perl.q{\"}.\$\$args.q{ \"}.\$\$d.q{dir/}.\$\$payload_name.q{\" \"}.\$\$d.\$\$at.q{\"}.qq{\\n}; close \$\$out or die \$\$!; chmod 0755,\$\$f;' '$file' '$payload' '$payload_name' '\$(PERL)'"; } # Helper: generate postamble for File::ShareDir::Install diff --git a/src/test/resources/unit/io_compress_regressions.t b/src/test/resources/unit/io_compress_regressions.t index 5f8d0a2bf..ab9b130a9 100644 --- a/src/test/resources/unit/io_compress_regressions.t +++ b/src/test/resources/unit/io_compress_regressions.t @@ -2,7 +2,7 @@ use strict; use warnings; -use Test::More tests => 31; +use Test::More tests => 33; use File::Spec; { @@ -198,6 +198,16 @@ sub _io_compress_eval_proto_regression ($) { 1 } my $deflater = Compress::Zlib::deflateInit(); ok(defined $deflater && !defined $deflater->msg, 'Compress::Zlib stream objects provide msg method'); +my $compressed = Compress::Zlib::compress('stream payload'); +my $compressed_for_list = $compressed; +my $scalar_inflater = Compress::Zlib::inflateInit(); +is($scalar_inflater->inflate($compressed), 'stream payload', + 'Compress::Zlib inflate returns data in scalar context'); +my $list_inflater = Compress::Zlib::inflateInit(); +my ($inflated, $inflate_status) = $list_inflater->inflate($compressed_for_list); +is("$inflated:$inflate_status:" . (0 + $inflate_status), 'stream payload:stream end:1', + 'Compress::Zlib inflate returns data and dual-valued status in list context'); + SKIP: { require Compress::Raw::Zlib; my ($scanner, $scan_status) = eval { Compress::Raw::Zlib::_inflateScanInit() }; diff --git a/src/test/resources/unit/makemaker_autosplit_autoloader_only.t b/src/test/resources/unit/makemaker_autosplit_autoloader_only.t index 18be88118..bcdf63fa9 100644 --- a/src/test/resources/unit/makemaker_autosplit_autoloader_only.t +++ b/src/test/resources/unit/makemaker_autosplit_autoloader_only.t @@ -17,9 +17,13 @@ print {$pod_pm} "package Local::PodOnly;\n1;\n__END__\n=head1 NAME\n\nLocal::Pod close $pod_pm or die "close POD-only module: $!"; open my $loader_pm, '>', 'lib/Local/Loader.pm' or die "create AutoLoader module: $!"; -print {$loader_pm} "package Local::Loader;\nuse AutoLoader;\n1;\n__END__\nsub deferred { 42 }\n"; +print {$loader_pm} "package Local::Loader;\nuse AutoLoader;\n1;\n__END__;\nsub deferred { 42 }\n"; close $loader_pm or die "close AutoLoader module: $!"; +open my $makefile_pl, '>', 'Makefile.PL' or die "create Makefile.PL: $!"; +print {$makefile_pl} "# generated by the unit test\n"; +close $makefile_pl or die "close Makefile.PL: $!"; + use ExtUtils::MakeMaker; WriteMakefile( NAME => 'Local::AutosplitSelection', VERSION => '0.001', @@ -37,6 +41,11 @@ unlike($makefile, qr/autosplit\([^\n]+Local\/PodOnly\.pm/, 'POD-only __END__ markers do not trigger AutoSplit'); ok($makefile =~ qr/autosplit\([^\n]+Local\/Loader\.pm/ || $makefile =~ qr/pm_to_blib\(\{\@ARGV\}/, - 'modules using AutoLoader remain covered by the staging rule'); + 'modules using AutoLoader with __END__; remain covered by the staging rule'); + +my $status = system($ENV{MAKE} || 'make', 'pm_to_blib'); +is($status, 0, 'AutoLoader module stages successfully'); +ok(-f 'blib/lib/auto/Local/Loader/autosplit.ix', + 'AutoSplit accepts the __END__; marker'); done_testing(); diff --git a/src/test/resources/unit/makemaker_exe_files.t b/src/test/resources/unit/makemaker_exe_files.t index 6c22a50a8..8e9e2f2b8 100644 --- a/src/test/resources/unit/makemaker_exe_files.t +++ b/src/test/resources/unit/makemaker_exe_files.t @@ -35,7 +35,7 @@ else { or die "create script/demo: $!"; print {$script} "#!perl -w\nprint qq(ok\\n);\n"; close $script or die "close script/demo: $!"; - chmod 0755, 'script/demo' or die "chmod script/demo: $!"; + chmod 0555, 'script/demo' or die "chmod read-only script/demo: $!"; open my $makefile_pl, '>', 'Makefile.PL' or die "create Makefile.PL: $!"; @@ -52,7 +52,7 @@ else { my $make = $Config::Config{make} || 'make'; my $status = system($make, 'pure_all'); - is($status, 0, 'pure_all target succeeds'); + is($status, 0, 'pure_all target succeeds for a read-only source script'); my @staged_candidates = ( 'blib/script/demo', diff --git a/src/test/resources/unit/math_cephes_java_xs.t b/src/test/resources/unit/math_cephes_java_xs.t new file mode 100644 index 000000000..d018c8331 --- /dev/null +++ b/src/test/resources/unit/math_cephes_java_xs.t @@ -0,0 +1,14 @@ +use strict; +use warnings; +use Test::More; + +plan skip_all => 'PerlOnJava Java XS bridge test' unless $^X =~ /jperl/; +plan tests => 4; + +require XSLoader; +XSLoader::load('Math::Cephes', '0.5308'); + +ok(abs(Math::Cephesc::ndtr(0) - 0.5) < 1e-12, 'normal CDF at zero'); +ok(abs(Math::Cephesc::ndtr(1.96) - 0.9750021049) < 1e-8, 'normal CDF'); +ok(abs(Math::Cephesc::ndtri(0.975) - 1.9599639845) < 1e-8, 'inverse normal CDF'); +ok(abs(Math::Cephesc::chdtrc(1, 7.0756) - 0.007813) < 1e-4, 'chi-square upper tail'); diff --git a/src/test/resources/unit/pvlv_filehandle.t b/src/test/resources/unit/pvlv_filehandle.t new file mode 100644 index 000000000..4053e9ee2 --- /dev/null +++ b/src/test/resources/unit/pvlv_filehandle.t @@ -0,0 +1,32 @@ +use strict; +use warnings; +use Test::More tests => 4; + +$_ = *pvlv_tty; +delete $::{pvlv_tty}; +*$_ = *STDOUT{IO}; +ok defined -t $_, 'file test uses a PVLV glob IO slot without stringifying it'; + +my $warning = ''; +{ + local $SIG{__WARN__} = sub { $warning .= shift }; + $_ = *pvlv_unopened; + close $_; +} +like $warning, qr/close\(\) on unopened filehandle pvlv_unopened/, + 'close warning retains the PVLV glob name'; + +{ + use utf8; + $_ = *pvlv_tty_ò; + delete $::{pvlv_tty_ò}; + *$_ = *STDOUT{IO}; + ok defined -t $_, 'Unicode PVLV file test preserves its IO slot'; + + $warning = ''; + local $SIG{__WARN__} = sub { $warning .= shift }; + $_ = *pvlv_unopened_ò; + close $_; + like $warning, qr/close\(\) on unopened filehandle pvlv_unopened_ò/, + 'close warning retains a Unicode PVLV glob name'; +} diff --git a/src/test/resources/unit/string_interpolation_escaped_hash_key.t b/src/test/resources/unit/string_interpolation_escaped_hash_key.t new file mode 100644 index 000000000..1245a9a90 --- /dev/null +++ b/src/test/resources/unit/string_interpolation_escaped_hash_key.t @@ -0,0 +1,11 @@ +use strict; +use warnings; +use Test::More tests => 2; + +my $set = { set_number => 7 }; +is("target G0.S$set->{\"set_number\"}", 'target G0.S7', + 'escaped quoted key interpolates through a hash reference'); + +my %set = (set_type => 'xy'); +is("type $set{\"set_type\"}", 'type xy', + 'escaped quoted key interpolates through a hash'); diff --git a/src/test/resources/unit/system_child_stdin_eof.t b/src/test/resources/unit/system_child_stdin_eof.t new file mode 100644 index 000000000..93052f792 --- /dev/null +++ b/src/test/resources/unit/system_child_stdin_eof.t @@ -0,0 +1,12 @@ +use strict; +use warnings; +use File::Temp qw(tempfile); +use Test::More tests => 1; + +my ($child_fh, $child_name) = tempfile(SUFFIX => '.pl', UNLINK => 1); +print {$child_fh} 'exit defined() ? 42 : 0;' . "\n"; +close $child_fh; + +my $status = system(qq{"$^X" "$child_name"}); + +is($status, 0, 'string-form system child observes EOF on noninteractive stdin'); diff --git a/src/test/resources/unit/text_qrcode_java_xs.t b/src/test/resources/unit/text_qrcode_java_xs.t new file mode 100644 index 000000000..d7adf64b5 --- /dev/null +++ b/src/test/resources/unit/text_qrcode_java_xs.t @@ -0,0 +1,16 @@ +use strict; +use warnings; +use Test::More; + +plan skip_all => 'PerlOnJava Java XS bridge test' unless $^X =~ /jperl/; +plan tests => 4; + +require XSLoader; +XSLoader::load('Text::QRCode', '0.05'); + +my $matrix = Text::QRCode::_plot('Some text here.', {}); +is(ref($matrix), 'ARRAY', '_plot returns an array reference'); +is(scalar(@$matrix), 21, 'version 1 QR code has 21 rows'); +is(scalar(@{$matrix->[0]}), 21, 'version 1 QR code has 21 columns'); +is(join('', @{$matrix->[0]}), '******* * ** *******', + 'QR matrix matches the libqrencode reference first row');