fromId(long id) {
- return Optional.ofNullable(BY_ID.get(id));
+ @Override
+ public String toString() {
+ return String.format("0x%016X", id);
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/org/mcaccess/prism/BackendInfo.java b/src/main/java/org/mcaccess/prism/BackendInfo.java
new file mode 100644
index 0000000..f502f06
--- /dev/null
+++ b/src/main/java/org/mcaccess/prism/BackendInfo.java
@@ -0,0 +1,13 @@
+package org.mcaccess.prism;
+
+/**
+ * Describes a backend registered with PRISM.
+ *
+ * Whether a backend is usable at this moment is runtime availability, which is reported through {@link AvailabilityListener} and can be read from a live backend through {@link Backend#getFeatures()} and {@link BackendFeature#IS_SUPPORTED_AT_RUNTIME}.
+ *
+ * @param id The backend's identifier.
+ * @param name The human-readable name, for example "NVDA" or "SAPI".
+ * @param priority The backend's priority; higher is more preferred.
+ */
+public record BackendInfo(BackendId id, String name, int priority) {
+}
diff --git a/src/main/java/org/mcaccess/prism/Context.java b/src/main/java/org/mcaccess/prism/Context.java
index eb9bd5d..eca37d0 100644
--- a/src/main/java/org/mcaccess/prism/Context.java
+++ b/src/main/java/org/mcaccess/prism/Context.java
@@ -1,13 +1,18 @@
package org.mcaccess.prism;
import org.mcaccess.prism.natives.NativeLoader;
+import org.mcaccess.prism.natives.PrismAvailabilityCallback;
+import org.mcaccess.prism.natives.PrismConfig;
import org.mcaccess.prism.natives.prism_h;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
+import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.Executor;
import java.util.function.Consumer;
+import java.util.stream.IntStream;
/**
* The PRISM context manages backend registration and lifecycle.
@@ -15,6 +20,7 @@
public final class Context implements AutoCloseable {
private final MemorySegment handle;
private final Arena arena;
+ private final boolean polling;
private volatile boolean closed = false;
/**
@@ -30,28 +36,59 @@ public Context() {
* @param configurer consumer to configure the context builder
*/
public Context(Consumer configurer) {
- this(buildFromConfigurer(configurer));
- }
-
- private static Builder buildFromConfigurer(Consumer configurer) {
- Objects.requireNonNull(configurer, "configurer must not be null");
- Builder builder = new Builder();
- configurer.accept(builder);
- return builder;
+ this(new Builder().apply(configurer));
}
private Context(Builder builder) {
NativeLoader.load();
this.arena = Arena.ofShared();
+ this.polling = builder.availabilityListener != null;
MemorySegment configSeg = prism_h.prism_config_init(arena);
+ if (this.polling) {
+ AvailabilityListener listener = builder.availabilityListener;
+ Executor executor = builder.availabilityExecutor;
+ MemorySegment stub = PrismAvailabilityCallback.allocate(
+ (userdata, backend, name, available) -> dispatch(listener, executor, backend, name, available),
+ arena);
+ PrismConfig.availability_callback(configSeg, stub);
+ PrismConfig.availability_userdata(configSeg, MemorySegment.NULL);
+ PrismConfig.availability_poll_interval_ms(configSeg, builder.pollIntervalMs);
+ PrismConfig.availability_debounce_samples(configSeg, builder.debounceSamples);
+ PrismConfig.availability_backoff_max_ms(configSeg, builder.backoffMaxMs);
+ PrismConfig.availability_auto_power_manage(configSeg, builder.autoPowerManage);
+ }
+
this.handle = prism_h.prism_init(configSeg);
- if (this.handle == null || this.handle.equals(MemorySegment.NULL)) {
+ if (this.handle.address() == 0) {
arena.close();
throw new PrismException.NotInitialized("PRISM could not be initialized");
}
}
+ /**
+ * Hands one availability transition to the application.
+ *
+ * Invoked on PRISM's poll thread, which performs no further scans until this returns and which must never see a
+ * Java exception. Both concerns are why the listener is optionally routed through an executor and why every
+ * failure is swallowed here.
+ */
+ private static void dispatch(AvailabilityListener listener, Executor executor, long backend, MemorySegment name,
+ boolean available) {
+ BackendId id = new BackendId(backend);
+ String backendName = Backend.readCString(name);
+ Runnable task = () -> listener.onAvailabilityChanged(id, backendName, available);
+ try {
+ if (executor != null) {
+ executor.execute(task);
+ } else {
+ task.run();
+ }
+ } catch (Throwable ignored) {
+ // A throw here would unwind into native code.
+ }
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -76,6 +113,24 @@ public int getBackendsCount() {
return (int) prism_h.prism_registry_count(handle);
}
+ /**
+ * Lists every backend registered with PRISM, in registry index order.
+ *
+ * Runtime availability is reported through {@link AvailabilityListener}, and can be read from a live backend through {@link Backend#getFeatures()} and {@link BackendFeature#IS_SUPPORTED_AT_RUNTIME}.
+ * Sort by {@link BackendInfo#priority()} for preference order.
+ *
+ * @return an immutable list of the registered backends
+ */
+ public List getRegisteredBackends() {
+ checkClosed();
+ return IntStream.range(0, getBackendsCount())
+ .mapToObj(i -> {
+ BackendId id = getIdOf(i);
+ return new BackendInfo(id, getNameOf(id), getPriorityOf(id));
+ })
+ .toList();
+ }
+
/**
* Gets the backend ID for the backend at the specified index.
*
@@ -88,7 +143,7 @@ public BackendId getIdOf(int index) {
if (id == 0) {
throw new IndexOutOfBoundsException("Invalid backend index: " + index);
}
- return BackendId.fromId(id).orElse(BackendId.INVALID);
+ return new BackendId(id);
}
/**
@@ -116,7 +171,7 @@ public Optional findIdOf(String name) {
if (id == 0) {
return Optional.empty();
}
- return Optional.of(BackendId.fromId(id).orElse(BackendId.INVALID));
+ return Optional.of(new BackendId(id));
}
}
@@ -127,8 +182,7 @@ public Optional findIdOf(String name) {
* @return backend name
*/
public String getNameOf(BackendId id) {
- Objects.requireNonNull(id, "id must not be null");
- return getNameOf(id.getId());
+ return getNameOf(id.id());
}
/**
@@ -140,7 +194,7 @@ public String getNameOf(BackendId id) {
public String getNameOf(long id) {
checkClosed();
MemorySegment ptr = prism_h.prism_registry_name(handle, id);
- if (ptr == null || ptr.equals(MemorySegment.NULL)) {
+ if (ptr.address() == 0) {
throw new IllegalArgumentException("Backend ID not found: 0x" + Long.toHexString(id));
}
return Backend.readCString(ptr);
@@ -153,8 +207,7 @@ public String getNameOf(long id) {
* @return priority integer (higher means preferred)
*/
public int getPriorityOf(BackendId id) {
- Objects.requireNonNull(id, "id must not be null");
- return getPriorityOf(id.getId());
+ return getPriorityOf(id.id());
}
/**
@@ -175,8 +228,7 @@ public int getPriorityOf(long id) {
* @return {@code true} if exists, {@code false} otherwise
*/
public boolean exists(BackendId id) {
- Objects.requireNonNull(id, "id must not be null");
- return exists(id.getId());
+ return exists(id.id());
}
/**
@@ -197,8 +249,7 @@ public boolean exists(long id) {
* @return newly created Backend instance
*/
public Backend create(BackendId id) {
- Objects.requireNonNull(id, "id must not be null");
- return create(id.getId());
+ return create(id.id());
}
/**
@@ -210,10 +261,10 @@ public Backend create(BackendId id) {
public Backend create(long id) {
checkClosed();
MemorySegment ptr = prism_h.prism_registry_create(handle, id);
- if (ptr == null || ptr.equals(MemorySegment.NULL)) {
+ if (ptr.address() == 0) {
throw new PrismException.InvalidParam("Invalid or unsupported backend: 0x" + Long.toHexString(id));
}
- return new Backend(ptr, true);
+ return new Backend(ptr, ptr.address());
}
/**
@@ -224,10 +275,10 @@ public Backend create(long id) {
public Backend createBest() {
checkClosed();
MemorySegment ptr = prism_h.prism_registry_create_best(handle);
- if (ptr == null || ptr.equals(MemorySegment.NULL)) {
+ if (ptr.address() == 0) {
throw new PrismException.BackendNotAvailable("No suitable PRISM backend available on this system");
}
- return new Backend(ptr, true);
+ return new Backend(ptr, ptr.address());
}
/**
@@ -237,8 +288,7 @@ public Backend createBest() {
* @return acquired Backend instance
*/
public Backend acquire(BackendId id) {
- Objects.requireNonNull(id, "id must not be null");
- return acquire(id.getId());
+ return acquire(id.id());
}
/**
@@ -250,10 +300,10 @@ public Backend acquire(BackendId id) {
public Backend acquire(long id) {
checkClosed();
MemorySegment ptr = prism_h.prism_registry_acquire(handle, id);
- if (ptr == null || ptr.equals(MemorySegment.NULL)) {
+ if (ptr.address() == 0) {
throw new PrismException.InvalidParam("Invalid or unsupported backend: 0x" + Long.toHexString(id));
}
- return new Backend(ptr, false);
+ return new Backend(ptr, new BackendId(id));
}
/**
@@ -264,19 +314,49 @@ public Backend acquire(long id) {
public Backend acquireBest() {
checkClosed();
MemorySegment ptr = prism_h.prism_registry_acquire_best(handle);
- if (ptr == null || ptr.equals(MemorySegment.NULL)) {
+ if (ptr.address() == 0) {
throw new PrismException.BackendNotAvailable("No suitable PRISM backend available on this system");
}
- return new Backend(ptr, false);
+ return new Backend(ptr, getIdOf(Backend.readCString(prism_h.prism_backend_name(ptr))));
+ }
+
+ /**
+ * Pauses the availability poll thread. While paused it performs no scans.
+ *
+ * A no-op if this context was not configured with an availability listener, or if polling is already paused.
+ */
+ public void pauseAvailabilityPolling() {
+ checkClosed();
+ if (polling) {
+ prism_h.prism_availability_poll_pause(handle);
+ }
+ }
+
+ /**
+ * Resumes the availability poll thread.
+ *
+ * On resume PRISM performs an immediate re-synchronising scan rather than waiting for the next interval, and that scan is not debounced: any backend whose availability differs from the state last reported produces a callback at once. A change that occurred and reversed entirely while paused is therefore not reported.
+ */
+ public void resumeAvailabilityPolling() {
+ checkClosed();
+ if (polling) {
+ prism_h.prism_availability_poll_resume(handle);
+ }
+ }
+
+ /**
+ * Reports whether this build of PRISM honours automatic power management of the poll thread.
+ */
+ public static boolean isAutoPowerManagementSupported() {
+ NativeLoader.load();
+ return prism_h.prism_availability_auto_power_supported();
}
@Override
public void close() {
if (!closed) {
closed = true;
- if (handle != null && !handle.equals(MemorySegment.NULL)) {
- prism_h.prism_shutdown(handle);
- }
+ prism_h.prism_shutdown(handle);
arena.close();
}
}
@@ -295,6 +375,72 @@ private void checkClosed() {
* Builder for configuring and creating a {@link Context}.
*/
public static final class Builder {
+ AvailabilityListener availabilityListener;
+ Executor availabilityExecutor;
+ int pollIntervalMs;
+ int debounceSamples;
+ int backoffMaxMs;
+ boolean autoPowerManage = true;
+
+ /**
+ * Polls for availability changes and reports each confirmed transition to {@code listener}.
+ *
+ * Without a listener the context runs no poll thread and incurs no cost. The listener is invoked on PRISM's poll thread, which performs no further scans until it returns, so supply an executor to move any non-trivial work elsewhere.
+ *
+ * @param listener Invoked on each confirmed availability transition, or null for no polling.
+ * @param executor Runs the listener. Pass null to run it directly on PRISM's poll thread.
+ */
+ public Builder availabilityListener(AvailabilityListener listener, Executor executor) {
+ this.availabilityListener = listener;
+ this.availabilityExecutor = executor;
+ return this;
+ }
+
+ /**
+ * @param listener Invoked on each confirmed availability transition, or null for no polling.
+ */
+ public Builder availabilityListener(AvailabilityListener listener) {
+ return availabilityListener(listener, null);
+ }
+
+ /**
+ * @param pollIntervalMs Base interval between scans. 0 selects PRISM's default.
+ */
+ public Builder pollIntervalMs(int pollIntervalMs) {
+ this.pollIntervalMs = pollIntervalMs;
+ return this;
+ }
+
+ /**
+ * @param debounceSamples Consecutive agreeing samples needed before a change is confirmed. 0 selects PRISM's default.
+ */
+ public Builder debounceSamples(int debounceSamples) {
+ this.debounceSamples = debounceSamples;
+ return this;
+ }
+
+ /**
+ * @param backoffMaxMs Upper bound for adaptive backoff of the interval while availability is unchanging. The interval is exponential and returns to the base interval as soon as any change is observed.
+ */
+ public Builder backoffMaxMs(int backoffMaxMs) {
+ this.backoffMaxMs = backoffMaxMs;
+ return this;
+ }
+
+ /**
+ * @param autoPowerManage Pause the poll thread automatically across OS suspend and resume. Ignored on builds and platforms without power-management support; see
+ * {@link Context#isAutoPowerManagementSupported()}.
+ */
+ public Builder autoPowerManage(boolean autoPowerManage) {
+ this.autoPowerManage = autoPowerManage;
+ return this;
+ }
+
+ public Builder apply(Consumer configurer) {
+ configurer.accept(this);
+ return this;
+ }
+
public Context build() {
return new Context(this);
}
diff --git a/src/main/java/org/mcaccess/prism/PrismError.java b/src/main/java/org/mcaccess/prism/PrismError.java
new file mode 100644
index 0000000..d16c5f6
--- /dev/null
+++ b/src/main/java/org/mcaccess/prism/PrismError.java
@@ -0,0 +1,83 @@
+package org.mcaccess.prism;
+
+import java.util.function.Function;
+
+public enum PrismError {
+ OK(0, null),
+ NOT_INITIALIZED(1, PrismException.NotInitialized::new),
+ INVALID_PARAM(2, PrismException.InvalidParam::new),
+ NOT_IMPLEMENTED(3, PrismException.NotImplemented::new),
+ NO_VOICES(4, PrismException.NoVoices::new),
+ VOICE_NOT_FOUND(5, PrismException.VoiceNotFound::new),
+ SPEAK_FAILURE(6, PrismException.SpeakFailure::new),
+ MEMORY_FAILURE(7, PrismException.MemoryFailure::new),
+ RANGE_OUT_OF_BOUNDS(8, PrismException.RangeOutOfBounds::new),
+ INTERNAL(9, PrismException.Internal::new),
+ NOT_SPEAKING(10, PrismException.NotSpeaking::new),
+ NOT_PAUSED(11, PrismException.NotPaused::new),
+ ALREADY_PAUSED(12, PrismException.AlreadyPaused::new),
+ INVALID_UTF8(13, PrismException.InvalidUtf8::new),
+ INVALID_OPERATION(14, PrismException.InvalidOperation::new),
+ ALREADY_INITIALIZED(15, PrismException.AlreadyInitialized::new),
+ BACKEND_NOT_AVAILABLE(16, PrismException.BackendNotAvailable::new),
+ UNKNOWN(17, PrismException.Unknown::new),
+ INVALID_AUDIO_FORMAT(18, PrismException.InvalidAudioFormat::new),
+ INTERNAL_BACKEND_LIMIT_EXCEEDED(19, PrismException.InternalBackendLimitExceeded::new),
+ BACKEND_ENTERED_UNDEFINED_STATE(20, PrismException.BackendEnteredUndefinedState::new),
+ LIBRARY_LOAD_FAILED(21, PrismException.LibraryLoadFailed::new),
+ LIBRARY_INVALID(22, PrismException.LibraryInvalid::new),
+ INCOMPATIBLE_ABI(23, PrismException.IncompatibleAbi::new);
+
+ private final int code;
+ private final Function factory;
+
+ PrismError(int code, Function factory) {
+ this.code = code;
+ this.factory = factory;
+ }
+
+ /**
+ * Gets the raw integer error code corresponding to the native C enum value.
+ */
+ public int getCode() {
+ return code;
+ }
+
+ /**
+ * Returns true if this error represents successful operation (PRISM_OK).
+ */
+ public boolean isSuccess() {
+ return this == OK;
+ }
+
+ /**
+ * Creates the exception type that represents this error.
+ *
+ * @param message The human-readable description, normally from prism_error_string.
+ * @return A new exception of the subtype matching this error.
+ * @throws IllegalStateException if called on {@link #OK}, which is not an error.
+ */
+ public PrismException newException(String message) {
+ if (factory == null) {
+ throw new IllegalStateException("PRISM_OK does not represent an error");
+ }
+ return factory.apply(message);
+ }
+
+ /**
+ * Cached because {@link #values()} clones its array on every call, and this is on the path of every native result that is not OK.
+ */
+ private static final PrismError[] BY_CODE = values();
+
+ /**
+ * Converts a raw native integer error code into a type-safe PrismError.
+ *
+ * the C enum is contiguous, so it can be indexed. Newer values will throw unknown.
+ *
+ * @param code The integer error code returned from a native C function.
+ * @return The corresponding PrismError, or {@link #UNKNOWN} if the code is unrecognised.
+ */
+ public static PrismError fromCode(int code) {
+ return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : UNKNOWN;
+ }
+}
diff --git a/src/main/java/org/mcaccess/prism/PrismException.java b/src/main/java/org/mcaccess/prism/PrismException.java
index 1c3f422..5fe480b 100644
--- a/src/main/java/org/mcaccess/prism/PrismException.java
+++ b/src/main/java/org/mcaccess/prism/PrismException.java
@@ -21,32 +21,7 @@ public static void throwIfError(int result) {
message = "Unknown PRISM error (code " + result + ")";
}
- throw switch (result) {
- case 1 -> new NotInitialized(message);
- case 2 -> new InvalidParam(message);
- case 3 -> new NotImplemented(message);
- case 4 -> new NoVoices(message);
- case 5 -> new VoiceNotFound(message);
- case 6 -> new SpeakFailure(message);
- case 7 -> new MemoryFailure(message);
- case 8 -> new RangeOutOfBounds(message);
- case 9 -> new Internal(message);
- case 10 -> new NotSpeaking(message);
- case 11 -> new NotPaused(message);
- case 12 -> new AlreadyPaused(message);
- case 13 -> new InvalidUtf8(message);
- case 14 -> new InvalidOperation(message);
- case 15 -> new AlreadyInitialized(message);
- case 16 -> new BackendNotAvailable(message);
- case 17 -> new Unknown(message);
- case 18 -> new InvalidAudioFormat(message);
- case 19 -> new InternalBackendLimitExceeded(message);
- case 20 -> new BackendEnteredUndefinedState(message);
- case 21 -> new LibraryLoadFailed(message);
- case 22 -> new LibraryInvalid(message);
- case 23 -> new IncompatibleAbi(message);
- default -> new PrismException(message);
- };
+ throw PrismError.fromCode(result).newException(message);
}
/**
diff --git a/src/main/java/org/mcaccess/prism/PrismLog.java b/src/main/java/org/mcaccess/prism/PrismLog.java
new file mode 100644
index 0000000..5f2e111
--- /dev/null
+++ b/src/main/java/org/mcaccess/prism/PrismLog.java
@@ -0,0 +1,165 @@
+package org.mcaccess.prism;
+
+import org.mcaccess.prism.natives.NativeLoader;
+import org.mcaccess.prism.natives.PrismLogCallback;
+import org.mcaccess.prism.natives.PrismLogHandler;
+import org.mcaccess.prism.natives.prism_h;
+
+import java.lang.foreign.Arena;
+import java.lang.foreign.MemorySegment;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Exposes prism's logging. All of these are safe to call from any thread, and before a {@link Context} is created or after it is closed.
+ */
+public final class PrismLog {
+
+ /**
+ * Severity levels, ordered from most to least verbose. {@link #NONE} silences logging entirely.
+ */
+ public enum Level {
+ TRACE(0),
+ DEBUG(1),
+ INFO(2),
+ WARN(3),
+ ERROR(4),
+ NONE(5);
+
+ private final int code;
+
+ Level(int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ private static final Level[] BY_CODE = values();
+
+ /**
+ * Maps a native {@code PrismLogLevel} to a Level.
+ *
+ * Codes are contiguous from 0 so a constant's code is its index. If the code is not known, returns None.
+ */
+ static Level fromCode(int code) {
+ return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : NONE;
+ }
+ }
+
+ /**
+ * In the Prism documentation it is said that replacing a listener does not immediately stop sending messages that were queued. We also do not have a way to check whether everything drained.
+ * Thus, we choose an automatic arena with a list of listener segments to ensure that they are not dropped, crashing the process. This should not grow by any significant magnitude.
+ */
+ private static final Arena STUB_ARENA = Arena.ofAuto();
+ private static final List INSTALLED = new ArrayList<>();
+
+ /**
+ * Routes PRISM's diagnostic output to {@code listener}.
+ *
+ * Prism will not log anything without setting a listener.
+ * Typical usage:
+ *
+ *
{@code
+ * PrismLog.setListener((level, source, message) -> {
+ * switch (level) {
+ * case ERROR -> LOGGER.error("[prism/{}] {}", source, message);
+ * case WARN -> LOGGER.warn("[prism/{}] {}", source, message);
+ * default -> LOGGER.info("[prism/{}] {}", source, message);
+ * }
+ * });
+ * }
+ *
+ * The listener is called from PRISM's logging thread and must not call back into {@link PrismLog}.
+ *
+ * @param listener Receives each message, or null to stop delivery.
+ */
+ public static synchronized void setListener(PrismLogListener listener) {
+ NativeLoader.load();
+ MemorySegment handler = PrismLogHandler.allocate(STUB_ARENA);
+ INSTALLED.add(handler);
+
+ if (listener == null) {
+ PrismLogHandler.fn(handler, MemorySegment.NULL);
+ } else {
+ MemorySegment stub = PrismLogCallback.allocate(
+ (userdata, level, source, message) -> deliver(listener, level, source, message), STUB_ARENA);
+ INSTALLED.add(stub);
+ PrismLogHandler.fn(handler, stub);
+ }
+ PrismLogHandler.userdata(handler, MemorySegment.NULL);
+
+ // in prism_set_log_handler, The previous handler is returned by value and dropped, so capture it in a temporary discarded arena.
+ try (Arena discarded = Arena.ofConfined()) {
+ prism_h.prism_set_log_handler(discarded, handler);
+ }
+ }
+
+ private static void deliver(PrismLogListener listener, int level, MemorySegment source, MemorySegment message) {
+ try {
+ listener.onLog(Level.fromCode(level), Backend.readCString(source), Backend.readCString(message));
+ } catch (Throwable t) {
+ // Must not propagate into PRISM's logging thread, and must not be reported through PRISM.
+ Thread current = Thread.currentThread();
+ Thread.UncaughtExceptionHandler h = current.getUncaughtExceptionHandler();
+ if (h != null) {
+ h.uncaughtException(current, t);
+ }
+ }
+ }
+
+ /**
+ * Stops delivery of PRISM's diagnostic output. Equivalent to {@code setListener(null)}.
+ */
+ public static void clearListener() {
+ setListener(null);
+ }
+
+ /**
+ * Sets the minimum level PRISM will emit.
+ *
+ * @return The level that was previously in effect.
+ */
+ public static Level setLevel(Level level) {
+ if (level == null) {
+ throw new NullPointerException("level must not be null");
+ }
+ NativeLoader.load();
+ return Level.fromCode(prism_h.prism_set_log_level(level.getCode()));
+ }
+
+ /**
+ * Writes a message into PRISM's log, so application events interleave with PRISM's own.
+ *
+ * @param level The severity to record it at.
+ * @param source A short tag identifying the origin, for example the mod id.
+ * @param message The message.
+ */
+ public static void log(Level level, String source, String message) {
+ if (level == null) {
+ throw new NullPointerException("level must not be null");
+ }
+ if (source == null) {
+ throw new NullPointerException("source must not be null");
+ }
+ if (message == null) {
+ throw new NullPointerException("message must not be null");
+ }
+ NativeLoader.load();
+ try (Arena arena = Arena.ofConfined()) {
+ prism_h.prism_log(level.getCode(), arena.allocateFrom(source), arena.allocateFrom(message));
+ }
+ }
+
+ /**
+ * Blocks until everything already queued has been written. PRISM logs on an internal thread, so a crash can otherwise lose the last few messages.
+ */
+ public static void flush() {
+ NativeLoader.load();
+ prism_h.prism_log_flush();
+ }
+
+ private PrismLog() {
+ }
+}
diff --git a/src/main/java/org/mcaccess/prism/PrismLogListener.java b/src/main/java/org/mcaccess/prism/PrismLogListener.java
new file mode 100644
index 0000000..e871d2a
--- /dev/null
+++ b/src/main/java/org/mcaccess/prism/PrismLogListener.java
@@ -0,0 +1,19 @@
+package org.mcaccess.prism;
+
+/**
+ * Receives PRISM's internal diagnostic messages.
+ *
+ * PRISM discards its own log output until a listener is installed, so this is the only way to see what it is logging. Install one with {@link PrismLog#setListener(PrismLogListener)}.
+ *
+ * Invoked from PRISM's logging thread. Avoid invoking it concurrently with itself, or from a thread the application owns.
+ * Implementations must synchronise any shared state they touch, and must not call back into {@link PrismLog} or any other PRISM logging function.
+ */
+@FunctionalInterface
+public interface PrismLogListener {
+ /**
+ * @param level Severity of the message.
+ * @param source The PRISM subsystem that emitted it.
+ * @param message The message text.
+ */
+ void onLog(PrismLog.Level level, String source, String message);
+}
diff --git a/src/test/java/org/mcaccess/prism/BackendConcurrencyTest.java b/src/test/java/org/mcaccess/prism/BackendConcurrencyTest.java
new file mode 100644
index 0000000..e281c45
--- /dev/null
+++ b/src/test/java/org/mcaccess/prism/BackendConcurrencyTest.java
@@ -0,0 +1,112 @@
+package org.mcaccess.prism;
+
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class BackendConcurrencyTest {
+
+ private static final int THREADS = 8;
+ private static final int ITERATIONS = 400;
+
+ @Test
+ void oneBackendSurvivesConcurrentIndependentOperations() throws Exception {
+ try (Context ctx = new Context(); Backend backend = ctx.acquireBest()) {
+ long features = backend.getFeatures();
+ runConcurrently(backend, backend, features);
+ }
+ }
+
+ @Test
+ void twoHandlesToTheSameCachedInstanceSurviveConcurrentUse() throws Exception {
+ try (Context ctx = new Context();
+ Backend a = ctx.acquireBest();
+ Backend b = ctx.acquireBest()) {
+ assertThat(a.getName()).isEqualTo(b.getName());
+ runConcurrently(a, b, a.getFeatures());
+ }
+ }
+
+ private void runConcurrently(Backend first, Backend second, long features) throws Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+ CountDownLatch start = new CountDownLatch(1);
+ List failures = new CopyOnWriteArrayList<>();
+ try {
+ List> futures = new java.util.ArrayList<>();
+ for (int t = 0; t < THREADS; t++) {
+ Backend target = (t % 2 == 0) ? first : second;
+ futures.add(pool.submit((Callable) () -> {
+ start.await();
+ for (int i = 0; i < ITERATIONS; i++) {
+ try {
+ target.getName();
+ target.getFeatures();
+ if (BackendFeature.SUPPORTS_IS_SPEAKING.isSupportedBy(features)) {
+ target.isSpeaking();
+ }
+ if (BackendFeature.SUPPORTS_GET_VOLUME.isSupportedBy(features)) {
+ target.getVolume();
+ }
+ if (BackendFeature.SUPPORTS_GET_RATE.isSupportedBy(features)) {
+ target.getRate();
+ }
+ if (BackendFeature.SUPPORTS_COUNT_VOICES.isSupportedBy(features)) {
+ target.getVoicesCount();
+ }
+ } catch (PrismException expected) {
+ // A backend may legitimately refuse an operation; only crashes and races matter here.
+ } catch (Throwable t2) {
+ failures.add(t2);
+ return null;
+ }
+ }
+ return null;
+ }));
+ }
+ start.countDown();
+ for (Future> f : futures) {
+ f.get(60, TimeUnit.SECONDS);
+ }
+ } finally {
+ pool.shutdownNow();
+ assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
+ }
+ assertThat(failures).isEmpty();
+ }
+
+ @Test
+ void closeIsIdempotentUnderConcurrency() throws Exception {
+ try (Context ctx = new Context()) {
+ Backend backend = ctx.acquireBest();
+ ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+ CountDownLatch start = new CountDownLatch(1);
+ try {
+ List> futures = new java.util.ArrayList<>();
+ for (int t = 0; t < THREADS; t++) {
+ futures.add(pool.submit((Callable) () -> {
+ start.await();
+ backend.close();
+ return null;
+ }));
+ }
+ start.countDown();
+ for (Future> f : futures) {
+ f.get(30, TimeUnit.SECONDS);
+ }
+ } finally {
+ pool.shutdownNow();
+ assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
+ }
+ assertThat(backend.isClosed()).isTrue();
+ }
+ }
+}
diff --git a/src/test/java/org/mcaccess/prism/BackendFeatureTest.java b/src/test/java/org/mcaccess/prism/BackendFeatureTest.java
new file mode 100644
index 0000000..8736d6f
--- /dev/null
+++ b/src/test/java/org/mcaccess/prism/BackendFeatureTest.java
@@ -0,0 +1,61 @@
+package org.mcaccess.prism;
+
+import java.util.EnumSet;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class BackendFeatureTest {
+
+ @Test
+ void eachFeatureIsNamedByItsOwnBitAlone() {
+ for (BackendFeature feature : BackendFeature.values()) {
+ assertThat(BackendFeature.decode(feature.getMask()))
+ .as("%s must decode to exactly itself", feature)
+ .containsExactly(feature);
+ }
+ }
+
+ @Test
+ void masksAreDistinctAndSingleBit() {
+ long seen = 0L;
+ for (BackendFeature feature : BackendFeature.values()) {
+ long mask = feature.getMask();
+ assertThat(Long.bitCount(mask)).as("%s must occupy one bit", feature).isEqualTo(1);
+ assertThat(seen & mask).as("%s must not reuse another feature's bit", feature).isZero();
+ seen |= mask;
+ }
+ }
+
+ @Test
+ void bitOneStaysReserved() {
+ assertThat(BackendFeature.decode(1L << 1)).isEmpty();
+ }
+
+ @Test
+ void emptyMaskNamesNothing() {
+ assertThat(BackendFeature.decode(0L)).isEmpty();
+ for (BackendFeature feature : BackendFeature.values()) {
+ assertThat(feature.isSupportedBy(0L)).isFalse();
+ }
+ }
+
+ @Test
+ void fullMaskNamesEverything() {
+ long all = 0L;
+ for (BackendFeature feature : BackendFeature.values()) {
+ all |= feature.getMask();
+ }
+ assertThat(BackendFeature.decode(all))
+ .isEqualTo(EnumSet.allOf(BackendFeature.class));
+ }
+
+ @Test
+ void unrecognisedBitsAreIgnored() {
+ long unknown = 1L << 40;
+ assertThat(BackendFeature.decode(unknown)).isEmpty();
+ assertThat(BackendFeature.decode(BackendFeature.SUPPORTS_SPEAK.getMask() | unknown))
+ .containsExactly(BackendFeature.SUPPORTS_SPEAK);
+ }
+}
diff --git a/src/test/java/org/mcaccess/prism/BackendFeaturesTest.java b/src/test/java/org/mcaccess/prism/BackendFeaturesTest.java
deleted file mode 100644
index 729fae0..0000000
--- a/src/test/java/org/mcaccess/prism/BackendFeaturesTest.java
+++ /dev/null
@@ -1,108 +0,0 @@
-package org.mcaccess.prism;
-
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-class BackendFeaturesTest {
-
- @Test
- void testEmptyFeatures() {
- BackendFeatures features = BackendFeatures.fromBits(0L);
- assertThat(features.isSupportedAtRuntime()).isFalse();
- assertThat(features.supportsSpeak()).isFalse();
- assertThat(features.supportsSpeakToMemory()).isFalse();
- assertThat(features.supportsBraille()).isFalse();
- assertThat(features.supportsOutput()).isFalse();
- assertThat(features.supportsIsSpeaking()).isFalse();
- assertThat(features.supportsStop()).isFalse();
- assertThat(features.supportsPause()).isFalse();
- assertThat(features.supportsResume()).isFalse();
- assertThat(features.supportsSetVolume()).isFalse();
- assertThat(features.supportsGetVolume()).isFalse();
- assertThat(features.supportsSetRate()).isFalse();
- assertThat(features.supportsGetRate()).isFalse();
- assertThat(features.supportsSetPitch()).isFalse();
- assertThat(features.supportsGetPitch()).isFalse();
- assertThat(features.supportsRefreshVoices()).isFalse();
- assertThat(features.supportsCountVoices()).isFalse();
- assertThat(features.supportsGetVoiceName()).isFalse();
- assertThat(features.supportsGetVoiceLanguage()).isFalse();
- assertThat(features.supportsGetVoice()).isFalse();
- assertThat(features.supportsSetVoice()).isFalse();
- assertThat(features.supportsGetChannels()).isFalse();
- assertThat(features.supportsGetSampleRate()).isFalse();
- assertThat(features.supportsGetBitDepth()).isFalse();
- assertThat(features.performsSilenceTrimmingOnSpeak()).isFalse();
- assertThat(features.performsSilenceTrimmingOnSpeakToMemory()).isFalse();
- assertThat(features.supportsSpeakSsml()).isFalse();
- assertThat(features.supportsSpeakToMemorySsml()).isFalse();
- assertThat(features.toBits()).isEqualTo(0L);
- }
-
- @Test
- void testIndividualFeatureBits() {
- BackendFeatures features = BackendFeatures.fromBits(
- BackendFeatures.BIT_IS_SUPPORTED_AT_RUNTIME |
- BackendFeatures.BIT_SUPPORTS_SPEAK |
- BackendFeatures.BIT_SUPPORTS_SET_VOLUME |
- BackendFeatures.BIT_SUPPORTS_GET_VOLUME
- );
-
- assertThat(features.isSupportedAtRuntime()).isTrue();
- assertThat(features.supportsSpeak()).isTrue();
- assertThat(features.supportsSetVolume()).isTrue();
- assertThat(features.supportsGetVolume()).isTrue();
- assertThat(features.supportsBraille()).isFalse();
- assertThat(features.supportsStop()).isFalse();
-
- long expectedBits = BackendFeatures.BIT_IS_SUPPORTED_AT_RUNTIME |
- BackendFeatures.BIT_SUPPORTS_SPEAK |
- BackendFeatures.BIT_SUPPORTS_SET_VOLUME |
- BackendFeatures.BIT_SUPPORTS_GET_VOLUME;
-
- assertThat(features.toBits()).isEqualTo(expectedBits);
- }
-
- @Test
- void testAllFeatureBitsRoundTrip() {
- long allBits = (1L << 0) | (1L << 2) | (1L << 3) | (1L << 4) | (1L << 5) |
- (1L << 6) | (1L << 7) | (1L << 8) | (1L << 9) | (1L << 10) |
- (1L << 11) | (1L << 12) | (1L << 13) | (1L << 14) | (1L << 15) |
- (1L << 16) | (1L << 17) | (1L << 18) | (1L << 19) | (1L << 20) |
- (1L << 21) | (1L << 22) | (1L << 23) | (1L << 24) | (1L << 25) |
- (1L << 26) | (1L << 27) | (1L << 28);
-
- BackendFeatures features = BackendFeatures.fromBits(allBits);
- assertThat(features.isSupportedAtRuntime()).isTrue();
- assertThat(features.supportsSpeak()).isTrue();
- assertThat(features.supportsSpeakToMemory()).isTrue();
- assertThat(features.supportsBraille()).isTrue();
- assertThat(features.supportsOutput()).isTrue();
- assertThat(features.supportsIsSpeaking()).isTrue();
- assertThat(features.supportsStop()).isTrue();
- assertThat(features.supportsPause()).isTrue();
- assertThat(features.supportsResume()).isTrue();
- assertThat(features.supportsSetVolume()).isTrue();
- assertThat(features.supportsGetVolume()).isTrue();
- assertThat(features.supportsSetRate()).isTrue();
- assertThat(features.supportsGetRate()).isTrue();
- assertThat(features.supportsSetPitch()).isTrue();
- assertThat(features.supportsGetPitch()).isTrue();
- assertThat(features.supportsRefreshVoices()).isTrue();
- assertThat(features.supportsCountVoices()).isTrue();
- assertThat(features.supportsGetVoiceName()).isTrue();
- assertThat(features.supportsGetVoiceLanguage()).isTrue();
- assertThat(features.supportsGetVoice()).isTrue();
- assertThat(features.supportsSetVoice()).isTrue();
- assertThat(features.supportsGetChannels()).isTrue();
- assertThat(features.supportsGetSampleRate()).isTrue();
- assertThat(features.supportsGetBitDepth()).isTrue();
- assertThat(features.performsSilenceTrimmingOnSpeak()).isTrue();
- assertThat(features.performsSilenceTrimmingOnSpeakToMemory()).isTrue();
- assertThat(features.supportsSpeakSsml()).isTrue();
- assertThat(features.supportsSpeakToMemorySsml()).isTrue();
-
- assertThat(features.toBits()).isEqualTo(allBits);
- }
-}
diff --git a/src/test/java/org/mcaccess/prism/BackendIdTest.java b/src/test/java/org/mcaccess/prism/BackendIdTest.java
index 0aaa928..290e391 100644
--- a/src/test/java/org/mcaccess/prism/BackendIdTest.java
+++ b/src/test/java/org/mcaccess/prism/BackendIdTest.java
@@ -2,42 +2,58 @@
import org.junit.jupiter.api.Test;
-import java.util.Optional;
-
import static org.assertj.core.api.Assertions.assertThat;
class BackendIdTest {
@Test
- void testAllKnownBackendIds() {
- assertThat(BackendId.INVALID.getId()).isEqualTo(0L);
- assertThat(BackendId.SAPI.getId()).isEqualTo(0x1D6DF72422CEEE66L);
- assertThat(BackendId.AV_SPEECH.getId()).isEqualTo(0x28E3429577805C24L);
- assertThat(BackendId.VOICE_OVER.getId()).isEqualTo(0xCB4897961A754BCBL);
- assertThat(BackendId.SPEECH_DISPATCHER.getId()).isEqualTo(0xE3D6F895D949EBFEL);
- assertThat(BackendId.NVDA.getId()).isEqualTo(0x89CC19C5C4AC1A56L);
- assertThat(BackendId.JAWS.getId()).isEqualTo(0xAC3D60E9BD84B53EL);
- assertThat(BackendId.ONE_CORE.getId()).isEqualTo(0x6797D32F0D994CB4L);
- assertThat(BackendId.ORCA.getId()).isEqualTo(0x10AA1FC05A17F96CL);
- assertThat(BackendId.ANDROID_SCREEN_READER.getId()).isEqualTo(0xD199C175AEEC494BL);
- assertThat(BackendId.ANDROID_TTS.getId()).isEqualTo(0xBC175831BFE4E5CCL);
- assertThat(BackendId.WEB_SPEECH.getId()).isEqualTo(0x3572538D44D44A8FL);
- assertThat(BackendId.UIA.getId()).isEqualTo(0x6238F019DB678F8EL);
- assertThat(BackendId.ZDSR.getId()).isEqualTo(0x3D93C56C9E7F2A2EL);
- assertThat(BackendId.ZOOM_TEXT.getId()).isEqualTo(0xAE439D62DC7B1479L);
- assertThat(BackendId.BOY_PC_READER.getId()).isEqualTo(0x285ABA1C16F3300FL);
- assertThat(BackendId.PC_TALKER.getId()).isEqualTo(0x344B951962E3B835L);
- assertThat(BackendId.SENSE_READER.getId()).isEqualTo(0xED4760890B55C2F2L);
- assertThat(BackendId.SYSTEM_ACCESS.getId()).isEqualTo(0x8380F2A37B2C3EB6L);
- assertThat(BackendId.WINDOW_EYES.getId()).isEqualTo(0x9120D89908785C13L);
- assertThat(BackendId.SPIEL.getId()).isEqualTo(0x478B44F14AD3D89CL);
+ void knownIdentifiersMatchPrism() {
+ assertThat(BackendId.INVALID.id()).isEqualTo(0L);
+ assertThat(BackendId.SAPI.id()).isEqualTo(0x1D6DF72422CEEE66L);
+ assertThat(BackendId.AV_SPEECH.id()).isEqualTo(0x28E3429577805C24L);
+ assertThat(BackendId.VOICE_OVER.id()).isEqualTo(0xCB4897961A754BCBL);
+ assertThat(BackendId.SPEECH_DISPATCHER.id()).isEqualTo(0xE3D6F895D949EBFEL);
+ assertThat(BackendId.NVDA.id()).isEqualTo(0x89CC19C5C4AC1A56L);
+ assertThat(BackendId.JAWS.id()).isEqualTo(0xAC3D60E9BD84B53EL);
+ assertThat(BackendId.ONE_CORE.id()).isEqualTo(0x6797D32F0D994CB4L);
+ assertThat(BackendId.ORCA.id()).isEqualTo(0x10AA1FC05A17F96CL);
+ assertThat(BackendId.ANDROID_SCREEN_READER.id()).isEqualTo(0xD199C175AEEC494BL);
+ assertThat(BackendId.ANDROID_TTS.id()).isEqualTo(0xBC175831BFE4E5CCL);
+ assertThat(BackendId.WEB_SPEECH.id()).isEqualTo(0x3572538D44D44A8FL);
+ assertThat(BackendId.UIA.id()).isEqualTo(0x6238F019DB678F8EL);
+ assertThat(BackendId.ZDSR.id()).isEqualTo(0x3D93C56C9E7F2A2EL);
+ assertThat(BackendId.ZOOM_TEXT.id()).isEqualTo(0xAE439D62DC7B1479L);
+ assertThat(BackendId.BOY_PC_READER.id()).isEqualTo(0x285ABA1C16F3300FL);
+ assertThat(BackendId.PC_TALKER.id()).isEqualTo(0x344B951962E3B835L);
+ assertThat(BackendId.SENSE_READER.id()).isEqualTo(0xED4760890B55C2F2L);
+ assertThat(BackendId.SYSTEM_ACCESS.id()).isEqualTo(0x8380F2A37B2C3EB6L);
+ assertThat(BackendId.WINDOW_EYES.id()).isEqualTo(0x9120D89908785C13L);
+ assertThat(BackendId.SPIEL.id()).isEqualTo(0x478B44F14AD3D89CL);
+ }
+
+ @Test
+ void unknownIdentifiersSurviveIntact() {
+ long custom = 0x1122334455667788L;
+ assertThat(new BackendId(custom).id()).isEqualTo(custom);
+ assertThat(new BackendId(custom)).isNotEqualTo(BackendId.INVALID);
+ }
+
+ @Test
+ void equalityIsByValue() {
+ assertThat(new BackendId(0x89CC19C5C4AC1A56L)).isEqualTo(BackendId.NVDA);
+ assertThat(new BackendId(0x89CC19C5C4AC1A56L)).hasSameHashCodeAs(BackendId.NVDA);
+ }
+
+ @Test
+ void invalidIsTheZeroIdentifier() {
+ assertThat(BackendId.INVALID.isInvalid()).isTrue();
+ assertThat(new BackendId(0L).isInvalid()).isTrue();
+ assertThat(BackendId.NVDA.isInvalid()).isFalse();
}
@Test
- void testFromId() {
- assertThat(BackendId.fromId(0x1D6DF72422CEEE66L)).contains(BackendId.SAPI);
- assertThat(BackendId.fromId(0x89CC19C5C4AC1A56L)).contains(BackendId.NVDA);
- assertThat(BackendId.fromId(0x6797D32F0D994CB4L)).contains(BackendId.ONE_CORE);
- assertThat(BackendId.fromId(0x123456789L)).isEmpty();
+ void toStringIsFixedWidthHex() {
+ assertThat(BackendId.NVDA).hasToString("0x89CC19C5C4AC1A56");
+ assertThat(BackendId.INVALID).hasToString("0x0000000000000000");
}
}
diff --git a/src/test/java/org/mcaccess/prism/ContextAvailabilityTest.java b/src/test/java/org/mcaccess/prism/ContextAvailabilityTest.java
new file mode 100644
index 0000000..067c1cb
--- /dev/null
+++ b/src/test/java/org/mcaccess/prism/ContextAvailabilityTest.java
@@ -0,0 +1,131 @@
+package org.mcaccess.prism;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Exercises availability polling against a single shared context.
+ *
+ * Deliberately not one context per test: PRISM crashes when a context configured with an availability callback is
+ * shut down and another is then created and shut down in the same process, so every polling test here shares one
+ * context that is closed once at the end. Contexts without a callback are unaffected and are created freely.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ContextAvailabilityTest {
+
+ private static final List EVENTS = new CopyOnWriteArrayList<>();
+ private static final CountDownLatch SCANNED = new CountDownLatch(1);
+
+ private ExecutorService executor;
+ private Context polling;
+
+ @BeforeAll
+ void startPolling() {
+ PrismLog.setListener((level, source, message) -> {
+ if (message.contains("Scanning instance slot")) {
+ SCANNED.countDown();
+ }
+ });
+ PrismLog.setLevel(PrismLog.Level.TRACE);
+
+ executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "availability-dispatch"));
+ polling = Context.builder()
+ .availabilityListener((id, name, available) -> EVENTS.add(name + "=" + available), executor)
+ .pollIntervalMs(0)
+ .debounceSamples(1)
+ .backoffMaxMs(0)
+ .autoPowerManage(true)
+ .build();
+ }
+
+ @AfterAll
+ void stopPolling() throws Exception {
+ polling.close();
+ assertThat(polling.isClosed()).isTrue();
+ executor.shutdownNow();
+ assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
+ PrismLog.clearListener();
+ PrismLog.setLevel(PrismLog.Level.NONE);
+ }
+
+ @AfterEach
+ void resumeIfPaused() {
+ polling.resumeAvailabilityPolling();
+ }
+
+ @Test
+ void configuringAListenerStartsThePollThread() throws Exception {
+ assertThat(SCANNED.await(6, TimeUnit.SECONDS))
+ .as("PRISM must run its poll thread once an availability callback is configured")
+ .isTrue();
+ }
+
+ @Test
+ void pausingAndResumingIsIdempotent() {
+ polling.pauseAvailabilityPolling();
+ polling.pauseAvailabilityPolling();
+ polling.resumeAvailabilityPolling();
+ polling.resumeAvailabilityPolling();
+ assertThat(polling.isClosed()).isFalse();
+ }
+
+ @Test
+ void backendsRemainUsableWhilePolling() {
+ try (Backend backend = polling.acquireBest()) {
+ assertThat(backend.getName()).isNotBlank();
+ assertThat(BackendFeature.IS_SUPPORTED_AT_RUNTIME.isSupportedBy(backend.getFeatures())).isTrue();
+ }
+ }
+
+ @Test
+ void registryQueriesWorkWhilePolling() {
+ assertThat(polling.getBackendsCount()).isPositive();
+ BackendId first = polling.getIdOf(0);
+ assertThat(first.isInvalid()).isFalse();
+ assertThat(polling.getNameOf(first)).isNotBlank();
+ }
+
+ @Test
+ void aContextWithoutAListenerRunsNoPollThreadAndTakesPollingCallsSafely() {
+ try (Context plain = new Context()) {
+ plain.pauseAvailabilityPolling();
+ plain.resumeAvailabilityPolling();
+ assertThat(plain.getBackendsCount()).isPositive();
+ }
+ }
+
+ @Test
+ void pollingControlsRejectAClosedContext() {
+ Context closed = new Context();
+ closed.close();
+ assertThatThrownBy(closed::pauseAvailabilityPolling).isInstanceOf(IllegalStateException.class);
+ assertThatThrownBy(closed::resumeAvailabilityPolling).isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ void autoPowerManagementSupportIsQueryable() {
+ assertThat(Context.isAutoPowerManagementSupported()).isIn(true, false);
+ }
+
+ @Test
+ void noSpuriousEventsWithoutAnActualTransition() throws Exception {
+ int before = EVENTS.size();
+ Thread.sleep(2500);
+ assertThat(EVENTS.subList(Math.min(before, EVENTS.size()), EVENTS.size()))
+ .as("a baseline scan must not fabricate transitions")
+ .isEmpty();
+ }
+}
diff --git a/src/test/java/org/mcaccess/prism/PrismErrorTest.java b/src/test/java/org/mcaccess/prism/PrismErrorTest.java
new file mode 100644
index 0000000..a606b18
--- /dev/null
+++ b/src/test/java/org/mcaccess/prism/PrismErrorTest.java
@@ -0,0 +1,53 @@
+package org.mcaccess.prism;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PrismErrorTest {
+
+ @Test
+ void codeMatchesOrdinal() {
+ for (PrismError error : PrismError.values()) {
+ assertThat(error.getCode())
+ .as("%s must keep code == ordinal, since fromCode indexes by code", error)
+ .isEqualTo(error.ordinal());
+ }
+ }
+
+ @Test
+ void fromCodeRoundTripsEveryConstant() {
+ for (PrismError error : PrismError.values()) {
+ assertThat(PrismError.fromCode(error.getCode())).isSameAs(error);
+ }
+ }
+
+ @Test
+ void fromCodeClampsUnrecognisedCodes() {
+ assertThat(PrismError.fromCode(-1)).isSameAs(PrismError.UNKNOWN);
+ assertThat(PrismError.fromCode(PrismError.values().length)).isSameAs(PrismError.UNKNOWN);
+ assertThat(PrismError.fromCode(9999)).isSameAs(PrismError.UNKNOWN);
+ }
+
+ @Test
+ void everyErrorProducesADistinctExceptionType() {
+ for (PrismError error : PrismError.values()) {
+ if (error.isSuccess()) {
+ continue;
+ }
+ PrismException thrown = error.newException("boom");
+ assertThat(thrown.getMessage()).isEqualTo("boom");
+ assertThat(thrown.getClass())
+ .as("%s must map to its own exception subtype", error)
+ .isNotSameAs(PrismException.class);
+ }
+ }
+
+ @Test
+ void okIsNotAnError() {
+ assertThat(PrismError.OK.isSuccess()).isTrue();
+ assertThatThrownBy(() -> PrismError.OK.newException("boom"))
+ .isInstanceOf(IllegalStateException.class);
+ }
+}
diff --git a/src/test/java/org/mcaccess/prism/PrismLogTest.java b/src/test/java/org/mcaccess/prism/PrismLogTest.java
new file mode 100644
index 0000000..484a823
--- /dev/null
+++ b/src/test/java/org/mcaccess/prism/PrismLogTest.java
@@ -0,0 +1,115 @@
+package org.mcaccess.prism;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PrismLogTest {
+
+ @AfterEach
+ void detach() {
+ PrismLog.clearListener();
+ PrismLog.setLevel(PrismLog.Level.NONE);
+ }
+
+ @Test
+ void levelCodesMatchOrdinals() {
+ for (PrismLog.Level level : PrismLog.Level.values()) {
+ assertThat(level.getCode()).isEqualTo(level.ordinal());
+ }
+ }
+
+ @Test
+ void unrecognisedLevelDegradesToNone() {
+ assertThat(PrismLog.Level.fromCode(-1)).isSameAs(PrismLog.Level.NONE);
+ assertThat(PrismLog.Level.fromCode(99)).isSameAs(PrismLog.Level.NONE);
+ for (PrismLog.Level level : PrismLog.Level.values()) {
+ assertThat(PrismLog.Level.fromCode(level.getCode())).isSameAs(level);
+ }
+ }
+
+ @Test
+ void setLevelReturnsThePreviousLevel() {
+ PrismLog.setLevel(PrismLog.Level.WARN);
+ assertThat(PrismLog.setLevel(PrismLog.Level.DEBUG)).isSameAs(PrismLog.Level.WARN);
+ assertThat(PrismLog.setLevel(PrismLog.Level.NONE)).isSameAs(PrismLog.Level.DEBUG);
+ }
+
+ @Test
+ void messagesRoundTripThroughTheListener() throws Exception {
+ CountDownLatch seen = new CountDownLatch(1);
+ List captured = new CopyOnWriteArrayList<>();
+ PrismLog.setListener((level, source, message) -> {
+ if ("junit".equals(source)) {
+ captured.add(level + "|" + source + "|" + message);
+ seen.countDown();
+ }
+ });
+ PrismLog.setLevel(PrismLog.Level.TRACE);
+
+ PrismLog.log(PrismLog.Level.WARN, "junit", "hello from the test");
+ PrismLog.flush();
+
+ assertThat(seen.await(5, TimeUnit.SECONDS)).as("listener must receive the message").isTrue();
+ assertThat(captured).contains("WARN|junit|hello from the test");
+ }
+
+ @Test
+ void clearListenerStopsDelivery() throws Exception {
+ CountDownLatch first = new CountDownLatch(1);
+ PrismLog.setListener((level, source, message) -> {
+ if ("junit".equals(source)) {
+ first.countDown();
+ }
+ });
+ PrismLog.setLevel(PrismLog.Level.TRACE);
+ PrismLog.log(PrismLog.Level.INFO, "junit", "before");
+ PrismLog.flush();
+ assertThat(first.await(5, TimeUnit.SECONDS)).isTrue();
+
+ List afterClear = new CopyOnWriteArrayList<>();
+ PrismLog.clearListener();
+ PrismLog.log(PrismLog.Level.INFO, "junit", "after");
+ PrismLog.flush();
+ Thread.sleep(200);
+ assertThat(afterClear).isEmpty();
+ }
+
+ @Test
+ void aThrowingListenerDoesNotEscapeIntoPrism() throws Exception {
+ CountDownLatch called = new CountDownLatch(1);
+ Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler((t, e) -> { });
+ try {
+ PrismLog.setListener((level, source, message) -> {
+ called.countDown();
+ throw new IllegalStateException("listener blew up");
+ });
+ PrismLog.setLevel(PrismLog.Level.TRACE);
+ PrismLog.log(PrismLog.Level.ERROR, "junit", "boom");
+ PrismLog.flush();
+ assertThat(called.await(5, TimeUnit.SECONDS)).isTrue();
+
+ // PRISM's logging thread must still be alive and delivering.
+ PrismLog.log(PrismLog.Level.INFO, "junit", "still here");
+ PrismLog.flush();
+ } finally {
+ Thread.setDefaultUncaughtExceptionHandler(previous);
+ }
+ }
+
+ @Test
+ void nullArgumentsAreRejected() {
+ assertThatThrownBy(() -> PrismLog.setLevel(null)).isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> PrismLog.log(null, "s", "m")).isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> PrismLog.log(PrismLog.Level.INFO, null, "m")).isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> PrismLog.log(PrismLog.Level.INFO, "s", null)).isInstanceOf(NullPointerException.class);
+ }
+}
diff --git a/src/test/java/org/mcaccess/prism/PrismTest.java b/src/test/java/org/mcaccess/prism/PrismTest.java
index 3744f84..67548c6 100644
--- a/src/test/java/org/mcaccess/prism/PrismTest.java
+++ b/src/test/java/org/mcaccess/prism/PrismTest.java
@@ -52,22 +52,21 @@ void testBackendAcquireAndCreate() {
BackendId id = ctx.getIdOf(i);
try (Backend backend = ctx.create(id)) {
assertThat(backend.getName()).isNotBlank();
- BackendFeatures features = backend.getFeatures();
- assertThat(features).isNotNull();
-
- if (features.supportsGetVolume()) {
+ long features = backend.getFeatures();
+
+ if (BackendFeature.SUPPORTS_GET_VOLUME.isSupportedBy(features)) {
float volume = backend.getVolume();
assertThat(volume).isBetween(0.0f, 1.0f);
}
- if (features.supportsCountVoices()) {
+ if (BackendFeature.SUPPORTS_COUNT_VOICES.isSupportedBy(features)) {
int voicesCount = backend.getVoicesCount();
assertThat(voicesCount).isGreaterThanOrEqualTo(0);
- if (voicesCount > 0 && features.supportsGetVoiceName()) {
+ if (voicesCount > 0 && BackendFeature.SUPPORTS_GET_VOICE_NAME.isSupportedBy(features)) {
String voiceName = backend.getVoiceName(0);
assertThat(voiceName).isNotNull();
}
- if (voicesCount > 0 && features.supportsGetVoiceLanguage()) {
+ if (voicesCount > 0 && BackendFeature.SUPPORTS_GET_VOICE_LANGUAGE.isSupportedBy(features)) {
String voiceLang = backend.getVoiceLanguage(0);
assertThat(voiceLang).isNotNull();
}
@@ -94,8 +93,8 @@ void testSpeakToMemoryIfSupported() {
for (int i = 0; i < ctx.getBackendsCount(); i++) {
BackendId id = ctx.getIdOf(i);
try (Backend backend = ctx.create(id)) {
- BackendFeatures features = backend.getFeatures();
- if (features.supportsSpeakToMemory()) {
+ long features = backend.getFeatures();
+ if (BackendFeature.SUPPORTS_SPEAK_TO_MEMORY.isSupportedBy(features)) {
AtomicBoolean received = new AtomicBoolean(false);
AtomicInteger totalSamples = new AtomicInteger(0);
diff --git a/src/test/java/org/mcaccess/prism/RegistryInfoTest.java b/src/test/java/org/mcaccess/prism/RegistryInfoTest.java
new file mode 100644
index 0000000..870de2d
--- /dev/null
+++ b/src/test/java/org/mcaccess/prism/RegistryInfoTest.java
@@ -0,0 +1,59 @@
+package org.mcaccess.prism;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RegistryInfoTest {
+
+ @Test
+ void listsEveryRegisteredBackendWithNameAndPriority() {
+ try (Context ctx = new Context()) {
+ List backends = ctx.getRegisteredBackends();
+ assertThat(backends).hasSize(ctx.getBackendsCount());
+ assertThat(backends).allSatisfy(info -> {
+ assertThat(info.name()).isNotBlank();
+ assertThat(info.id().isInvalid()).isFalse();
+ });
+ assertThat(backends).extracting(BackendInfo::name).doesNotHaveDuplicates();
+ assertThat(backends).extracting(BackendInfo::id).doesNotHaveDuplicates();
+ }
+ }
+
+ @Test
+ void entriesAgreeWithTheIndividualLookups() {
+ try (Context ctx = new Context()) {
+ for (BackendInfo info : ctx.getRegisteredBackends()) {
+ assertThat(ctx.getNameOf(info.id())).isEqualTo(info.name());
+ assertThat(ctx.getPriorityOf(info.id())).isEqualTo(info.priority());
+ assertThat(ctx.getIdOf(info.name())).isEqualTo(info.id());
+ assertThat(ctx.exists(info.id())).isTrue();
+ }
+ }
+ }
+
+ @Test
+ void theListIsImmutable() {
+ try (Context ctx = new Context()) {
+ List backends = ctx.getRegisteredBackends();
+ assertThatThrownBy(() -> backends.add(new BackendInfo(BackendId.NVDA, "x", 1)))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+ }
+
+ @Test
+ void aClosedContextRejectsEnumeration() {
+ Context ctx = new Context();
+ ctx.close();
+ assertThatThrownBy(ctx::getRegisteredBackends).isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ void loadFailureIsNullWhenTheLibraryLoaded() {
+ assertThat(Prism.isAvailable()).isTrue();
+ assertThat(Prism.getLoadFailure()).isNull();
+ }
+}