|
| 1 | +package io.github.easy4j.opencli.contract; |
| 2 | + |
| 3 | +import io.github.easy4j.opencli.OpenCliProperties; |
| 4 | +import io.github.easy4j.opencli.core.OpenCliAdapterChannel; |
| 5 | +import io.github.easy4j.opencli.core.OpenCliAdapterCommandRequest; |
| 6 | +import io.github.easy4j.opencli.core.OpenCliExecutor; |
| 7 | +import io.github.easy4j.opencli.core.OpenCliResult; |
| 8 | +import java.io.File; |
| 9 | +import java.lang.reflect.InvocationTargetException; |
| 10 | +import java.lang.reflect.Method; |
| 11 | +import java.nio.charset.StandardCharsets; |
| 12 | +import java.util.ArrayList; |
| 13 | +import java.util.Arrays; |
| 14 | +import java.util.Base64; |
| 15 | +import java.util.Collections; |
| 16 | +import java.util.LinkedHashMap; |
| 17 | +import java.util.List; |
| 18 | +import java.util.Map; |
| 19 | +import org.junit.jupiter.api.Test; |
| 20 | +import static org.junit.jupiter.api.Assertions.*; |
| 21 | + |
| 22 | +/** Public binary/API contract: missing methods fail assertions rather than preventing the RED build. */ |
| 23 | +class OpenCliStructuredArgvContractTest { |
| 24 | + private static Class<?> type(String simpleName) { |
| 25 | + return assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core." + simpleName), |
| 26 | + "required ordered-option API is not implemented"); |
| 27 | + } |
| 28 | + |
| 29 | + private static Object call(Class<?> owner, Object receiver, String name, Class<?>[] parameterTypes, Object... args) { |
| 30 | + Method method = assertDoesNotThrow(() -> owner.getMethod(name, parameterTypes), |
| 31 | + "required public method is not implemented: " + name); |
| 32 | + try { |
| 33 | + return method.invoke(receiver, args); |
| 34 | + } catch (InvocationTargetException ex) { |
| 35 | + Throwable cause = ex.getCause(); |
| 36 | + if (cause instanceof RuntimeException) { |
| 37 | + throw (RuntimeException) cause; |
| 38 | + } |
| 39 | + throw new AssertionError("unexpected checked failure", cause); |
| 40 | + } catch (ReflectiveOperationException ex) { |
| 41 | + throw new AssertionError("public API is inaccessible", ex); |
| 42 | + } |
| 43 | + } |
| 44 | + |
| 45 | + private static Object schema(String form, String name, boolean repeatable) { |
| 46 | + Class<?> owner = type("OpenCliOptionSchema"); |
| 47 | + return "value".equals(form) |
| 48 | + ? call(owner, null, form, new Class<?>[]{String.class, boolean.class}, name, repeatable) |
| 49 | + : call(owner, null, form, new Class<?>[]{String.class}, name); |
| 50 | + } |
| 51 | + |
| 52 | + private static Object value(Object definition, Object value) { |
| 53 | + return call(type("OpenCliOption"), null, "value", |
| 54 | + new Class<?>[]{type("OpenCliOptionSchema"), Object.class}, definition, value); |
| 55 | + } |
| 56 | + |
| 57 | + private static Object flag(String form, Object definition) { |
| 58 | + return call(type("OpenCliOption"), null, form, |
| 59 | + new Class<?>[]{type("OpenCliOptionSchema")}, definition); |
| 60 | + } |
| 61 | + |
| 62 | + private static void add(Object builder, Object occurrence) { |
| 63 | + call(builder.getClass(), builder, "option", new Class<?>[]{type("OpenCliOption")}, occurrence); |
| 64 | + } |
| 65 | + |
| 66 | + private static List<String> run(OpenCliAdapterCommandRequest request) { |
| 67 | + OpenCliProperties p = new OpenCliProperties(); |
| 68 | + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; |
| 69 | + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); |
| 70 | + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", |
| 71 | + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), |
| 72 | + ContractProbe.class.getName()))); |
| 73 | + p.setCommandTimeoutMillis(10000L); |
| 74 | + OpenCliResult result = new OpenCliAdapterChannel(new OpenCliExecutor(p), "demo").invoke(request); |
| 75 | + assertTrue(result.isSuccess()); |
| 76 | + String[] lines = result.getStdout().split("\r?\n"); |
| 77 | + List<String> actual = new ArrayList<>(); |
| 78 | + for (int i = 1; i < lines.length; i++) { |
| 79 | + assertTrue(lines[i].startsWith("arg:")); |
| 80 | + actual.add(new String(Base64.getDecoder().decode(lines[i].substring(4)), StandardCharsets.UTF_8)); |
| 81 | + } |
| 82 | + assertEquals("argc:" + actual.size(), lines[0]); |
| 83 | + return actual; |
| 84 | + } |
| 85 | + |
| 86 | + @Test |
| 87 | + void orderedRepeatedValuesAndExplicitFalseReachChild() { |
| 88 | + Object tags = schema("value", "--tag", true); |
| 89 | + Object enabled = schema("value", "--enabled", false); |
| 90 | + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); |
| 91 | + add(b, value(tags, " A ")); |
| 92 | + add(b, value(enabled, false)); |
| 93 | + add(b, value(tags, "")); |
| 94 | + assertEquals(Arrays.asList("demo", "echo", "--tag", " A ", "--enabled", "false", "--tag", ""), run(b.build())); |
| 95 | + } |
| 96 | + |
| 97 | + @Test |
| 98 | + void explicitNegationIsDifferentFromAbsenceAndPresence() { |
| 99 | + Object cache = schema("negatableFlag", "--cache", false); |
| 100 | + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); |
| 101 | + add(b, flag("negated", cache)); |
| 102 | + assertEquals(Arrays.asList("demo", "echo", "--no-cache"), run(b.build())); |
| 103 | + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder present = OpenCliAdapterCommandRequest.builder().subcommand("echo"); |
| 104 | + add(present, flag("present", cache)); |
| 105 | + assertEquals(Arrays.asList("demo", "echo", "--cache"), run(present.build())); |
| 106 | + assertEquals(Arrays.asList("demo", "echo"), run(OpenCliAdapterCommandRequest.builder().subcommand("echo").build())); |
| 107 | + } |
| 108 | + |
| 109 | + @Test |
| 110 | + void flagSchemaCannotSilentlyConsumeAValue() { |
| 111 | + Object verbose = schema("flag", "--verbose", false); |
| 112 | + assertThrows(IllegalArgumentException.class, () -> value(verbose, false)); |
| 113 | + assertThrows(IllegalArgumentException.class, () -> flag("negated", verbose)); |
| 114 | + } |
| 115 | + |
| 116 | + @Test |
| 117 | + void nonrepeatableOptionCannotAppearTwice() { |
| 118 | + Object once = schema("value", "--limit", false); |
| 119 | + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); |
| 120 | + add(b, value(once, 1)); |
| 121 | + add(b, value(once, 2)); |
| 122 | + assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs()); |
| 123 | + } |
| 124 | + |
| 125 | + @Test |
| 126 | + void legacyAndOrderedOptionsCannotCollide() { |
| 127 | + Object limit = schema("value", "--limit", true); |
| 128 | + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo") |
| 129 | + .options(Collections.<String, Object>singletonMap("limit", "1")); |
| 130 | + add(b, value(limit, "2")); |
| 131 | + assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs()); |
| 132 | + } |
| 133 | + |
| 134 | + @Test |
| 135 | + void mutableOccurrenceValueIsCapturedWhenCreated() { |
| 136 | + StringBuilder text = new StringBuilder(" before "); |
| 137 | + Object occurrence = value(schema("value", "--text", false), text); |
| 138 | + text.append("after"); |
| 139 | + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); |
| 140 | + add(b, occurrence); |
| 141 | + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(b.build())); |
| 142 | + } |
| 143 | + |
| 144 | + @Test |
| 145 | + void malformedSchemaIdentifiersAreRejected() { |
| 146 | + Class<?> owner = type("OpenCliOptionSchema"); |
| 147 | + assertThrows(IllegalArgumentException.class, |
| 148 | + () -> call(owner, null, "flag", new Class<?>[]{String.class}, "--x y")); |
| 149 | + assertThrows(IllegalArgumentException.class, |
| 150 | + () -> call(owner, null, "flag", new Class<?>[]{String.class}, "")); |
| 151 | + } |
| 152 | + |
| 153 | + @Test |
| 154 | + void legacyMapIsCapturedRatherThanAliased() { |
| 155 | + Map<String, Object> options = new LinkedHashMap<>(); |
| 156 | + options.put("text", " before "); |
| 157 | + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo").options(options).build(); |
| 158 | + options.put("text", "after"); |
| 159 | + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request)); |
| 160 | + } |
| 161 | + |
| 162 | + @Test |
| 163 | + void mutableLegacyValueCannotChangeAnExistingRequest() { |
| 164 | + StringBuilder text = new StringBuilder(" before "); |
| 165 | + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo") |
| 166 | + .options(Collections.<String, Object>singletonMap("text", text)).build(); |
| 167 | + text.append("after"); |
| 168 | + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request)); |
| 169 | + } |
| 170 | + |
| 171 | + @Test |
| 172 | + void reusingBuilderDoesNotMutatePreviousRequest() { |
| 173 | + Object tags = schema("value", "--tag", true); |
| 174 | + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); |
| 175 | + add(b, value(tags, "A")); |
| 176 | + OpenCliAdapterCommandRequest first = b.build(); |
| 177 | + add(b, value(tags, "B")); |
| 178 | + assertEquals(Arrays.asList("demo", "echo", "--tag", "A"), run(first)); |
| 179 | + } |
| 180 | +} |
0 commit comments