From 38e85d8a35bbc6ccc98cbd460ac73241df51cbc5 Mon Sep 17 00:00:00 2001 From: Johannes <32422182+Firionus@users.noreply.github.com> Date: Sat, 8 May 2021 15:59:38 +0200 Subject: [PATCH] Subscription Handling (#38) * Make Example GdtfWrapper for Tests a Singleton Now, the example GDTF file only needs to be parsed once and the resulting GdtfWrapper can be shared between all tets. * Implement and Test Out Event for AddFixtureTypes * Make Queue Tests for AddFixtureTypes More Complex * Refactor Queue Notification for Patch into Generic Function * Implement Out Events for RemoveFixtureTypes * Implement Out Events for Fixture Operations in Patch * RemoveFixture OutEvent when Removing FixtureType * Make Putting Messages into Queue Blocking * Scaffolding for OutEventHandler Logging outgoing events is already working * First Unit-Tested Implementation of JsonSubscriptionHandler * Serialize JSON Message Only Once for All Subscribers * Move Example Fixture & Awaitility Config to Utilities * Move Integration Test Harness to Utilities * Move GDTF Integration Tests to Specific Class * Move Add Fixture Integration Tests to Specific Class * Move Update Fixture Integration Tests to Specific Class * Move Remove Fixture Integration Tests to Specific Class * Clean Up Test Names in Specific Classes Also clean up imports in whole project * Delete integration Test Package by moving specific classes to the specific packages * Delete Documentation of integration Test Package * Rename RemoveFixtureTest * Move JsonSubscriptionHandler to Generic SubscriptionHandler * Handle Subscribe in IncomingGlowRequestHandler required some changes for dependency injection * Implement Sync in SubscriptionHandler * Rename JsonTopic to GlowTopic * Integration Test for Subscription * Test Random Sync Messages for SubscriptionHandler * New Subscription API It is now generic over the topic, as it was before the rework. E.g. "patchSubscribe" -> "subscribe" with "data": "patch". I realized while implementing the subscription mechanism that this would be less work once we add a new topic. * Log Unexpected Message Events from Client * Remove Trivial TODOs * Unsubscribe Upon WebSocket Close * Add Some Docstrings * Test and Implement Sync After Unsubscribe * Add Docstrings and Refactors for Readability --- .../org/cueglow/server/CueGlowServer.kt | 9 +- .../org/cueglow/server/OutEventHandler.kt | 23 ++ .../org/cueglow/server/OutEventReceiver.kt | 7 + .../org/cueglow/server/StateProvider.kt | 6 +- .../org/cueglow/server/json/AsyncClient.kt | 7 + .../cueglow/server/json/JsonGlowMessage.kt | 21 +- .../org/cueglow/server/json/JsonHandler.kt | 3 +- .../server/json/JsonSubscriptionHandler.kt | 8 + .../server/objects/messages/GlowEvent.kt | 9 +- .../server/objects/messages/GlowMessage.kt | 10 +- .../server/objects/messages/GlowRequest.kt | 2 +- .../server/objects/messages/GlowTopic.kt | 13 ++ .../messages/IncomingGlowRequestHandler.kt | 11 +- .../objects/messages/SubscriptionHandler.kt | 97 ++++++++ .../kotlin/org/cueglow/server/patch/Patch.kt | 58 +++-- .../server/websocket/WebSocketConnection.kt | 9 +- .../server/websocket/WebSocketSetup.kt | 9 +- cueglow-server/src/test/README.md | 10 - .../cueglow/server/gdtf/ChannelLayoutTest.kt | 10 +- .../server/gdtf/GdtIntegrationfTest.kt | 183 ++++++++++++++++ .../server/integration/AddFixtureTest.kt | 116 ---------- .../server/integration/ApiIntegrationTest.kt | 207 ------------------ .../cueglow/server/integration/GdtfTest.kt | 156 ------------- .../server/integration/RemoveFixtureTest.kt | 55 ----- .../server/integration/UpdateFixtureTest.kt | 136 ------------ .../server/json/AddFixtureIntegrationTest.kt | 130 +++++++++++ .../json/JsonSubscriptionHandlerTest.kt | 146 ++++++++++++ .../json/RemoveFixtureIntegrationTest.kt | 62 ++++++ .../json/UpdateFixtureIntegrationTest.kt | 149 +++++++++++++ .../objects/messages/GlowMessageTest.kt | 30 +-- .../server/patch/PatchOutEventQueueTest.kt | 132 +++++++++++ .../server/patch/PatchSubscriptionTest.kt | 51 +++++ .../org/cueglow/server/patch/PatchTest.kt | 16 +- .../test_utilities/ClientAndServerTest.kt | 41 ++++ .../test_utilities/ExampleFixtureType.kt | 41 ++++ .../WsClient.kt | 4 +- dev-docs/Client-Server API.md | 20 +- 37 files changed, 1215 insertions(+), 782 deletions(-) create mode 100644 cueglow-server/src/main/kotlin/org/cueglow/server/OutEventHandler.kt create mode 100644 cueglow-server/src/main/kotlin/org/cueglow/server/OutEventReceiver.kt create mode 100644 cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonSubscriptionHandler.kt create mode 100644 cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowTopic.kt create mode 100644 cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/SubscriptionHandler.kt delete mode 100644 cueglow-server/src/test/README.md create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/GdtIntegrationfTest.kt delete mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/integration/AddFixtureTest.kt delete mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/integration/ApiIntegrationTest.kt delete mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/integration/GdtfTest.kt delete mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/integration/RemoveFixtureTest.kt delete mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/integration/UpdateFixtureTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/json/AddFixtureIntegrationTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/json/JsonSubscriptionHandlerTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/json/RemoveFixtureIntegrationTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/json/UpdateFixtureIntegrationTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchOutEventQueueTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchSubscriptionTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ClientAndServerTest.kt create mode 100644 cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ExampleFixtureType.kt rename cueglow-server/src/test/kotlin/org/cueglow/server/{integration => test_utilities}/WsClient.kt (91%) diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/CueGlowServer.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/CueGlowServer.kt index c689998..1abc135 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/CueGlowServer.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/CueGlowServer.kt @@ -3,6 +3,7 @@ package org.cueglow.server import io.javalin.Javalin import org.apache.logging.log4j.kotlin.Logging import org.cueglow.server.gdtf.GdtfHandler +import org.cueglow.server.json.JsonSubscriptionHandler import org.cueglow.server.rest.handleGdtfUpload import org.cueglow.server.websocket.GlowWebSocketHandler import org.eclipse.jetty.server.Server @@ -22,7 +23,11 @@ class CueGlowServer(port: Int = 7000) : Logging { logger.info("Starting CueGlow Server") } - val state = StateProvider() + val jsonSubscriptionHandler = JsonSubscriptionHandler() + + val outEventHandler = OutEventHandler(listOf(jsonSubscriptionHandler)) + + val state = StateProvider(outEventHandler.queue) private val gdtfHandler = GdtfHandler(state.patch) @@ -30,7 +35,7 @@ class CueGlowServer(port: Int = 7000) : Logging { // add our own WebSocket Handler config.server { val server = Server() - server.handler = GlowWebSocketHandler(state) + server.handler = GlowWebSocketHandler(state, jsonSubscriptionHandler) return@server server } config.requestLogger { ctx, executionTimeMs -> diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/OutEventHandler.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/OutEventHandler.kt new file mode 100644 index 0000000..59a627b --- /dev/null +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/OutEventHandler.kt @@ -0,0 +1,23 @@ +package org.cueglow.server + +import org.apache.logging.log4j.kotlin.Logging +import org.cueglow.server.objects.messages.GlowMessage +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue + +/** + * Starts a thread that takes GlowMessages from the [queue] of OutEvents and passes them to the registered receivers. + */ +class OutEventHandler(receivers: Iterable): Logging { + val queue = LinkedBlockingQueue() + + init { + Executors.newSingleThreadExecutor().submit { + while (true) { + val glowMessage = queue.take() + logger.info("Handling OutEvent: $glowMessage") + receivers.forEach { it.receive(glowMessage) } + } + } + } +} diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/OutEventReceiver.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/OutEventReceiver.kt new file mode 100644 index 0000000..fa086ff --- /dev/null +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/OutEventReceiver.kt @@ -0,0 +1,7 @@ +package org.cueglow.server + +import org.cueglow.server.objects.messages.GlowMessage + +interface OutEventReceiver { + fun receive(glowMessage: GlowMessage) +} \ No newline at end of file diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/StateProvider.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/StateProvider.kt index 788ec57..f20d8ed 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/StateProvider.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/StateProvider.kt @@ -1,13 +1,15 @@ package org.cueglow.server import org.cueglow.server.json.JsonHandler +import org.cueglow.server.objects.messages.GlowMessage import org.cueglow.server.patch.Patch +import java.util.concurrent.BlockingQueue /** * Provides a collection of state objects * * The StateProvider is initialized by the main process and passed to e.g. a [JsonHandler] for mutation. */ -class StateProvider { - val patch = Patch() +class StateProvider(val outEventQueue: BlockingQueue) { + val patch = Patch(outEventQueue) } \ No newline at end of file diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/json/AsyncClient.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/json/AsyncClient.kt index 8430c3d..cf03a3d 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/json/AsyncClient.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/json/AsyncClient.kt @@ -6,5 +6,12 @@ import org.cueglow.server.objects.messages.GlowMessage * Represents a Client of the Server that can be sent a [GlowMessage] asynchronously (i.e. at any time) */ interface AsyncClient { + /** + * Send a [message]. + * + * If the message cannot be sent (e.g. because the client is disconnected), nothing should happen. + */ fun send(message: GlowMessage) + + fun send(message: String) } \ No newline at end of file diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonGlowMessage.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonGlowMessage.kt index 03c5a7d..c9caa81 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonGlowMessage.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonGlowMessage.kt @@ -1,14 +1,12 @@ package org.cueglow.server.json -import com.beust.klaxon.Converter -import com.beust.klaxon.JsonValue -import com.beust.klaxon.Klaxon -import com.beust.klaxon.TypeAdapter +import com.beust.klaxon.* import com.github.michaelbull.result.* import org.cueglow.server.objects.ArtNetAddress import org.cueglow.server.objects.DmxAddress import org.cueglow.server.objects.messages.GlowEvent import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.objects.messages.GlowTopic import java.io.StringReader import java.util.* import kotlin.reflect.KClass @@ -17,21 +15,19 @@ import kotlin.reflect.KClass // Serialization and Parsing //-------------------------- -// TODO check if this works with the new GlowMessage design - /** Convert GlowMessage to JSON String by Extension Function */ fun GlowMessage.toJsonString(): String { return Klaxon() .fieldConverter(KlaxonArtNetAddressUpdate::class, ArtNetAddressResultConverter) .fieldConverter(KlaxonDmxAddressUpdate::class, DmxAddressResultConverter) .converter(KlaxonGlowEventConverter) + .converter(KlaxonGlowTopicConverter) .converter(UUIDConverter) .converter(DmxAddressConverter) .converter(ArtNetAddressConverter) .toJsonString(this) } -// TODO check if this works with the new GlowMessage design /** * Parse JSON to the internal representation [GlowMessage] */ @@ -39,11 +35,12 @@ fun GlowMessage.Companion.fromJsonString(input: String): GlowMessage = Klaxon() .fieldConverter(KlaxonArtNetAddressUpdate::class, ArtNetAddressResultConverter) .fieldConverter(KlaxonDmxAddressUpdate::class, DmxAddressResultConverter) .converter(KlaxonGlowEventConverter) + .converter(KlaxonGlowTopicConverter) .converter(UUIDConverter) .converter(DmxAddressConverter) .converter(ArtNetAddressConverter) .parse(StringReader(input)) - ?: TODO("Error Handling is WIP") + ?: throw KlaxonException("Klaxon Parser returned null after parsing '$input'") //------------------------------- // Klaxon Adapters and Converters @@ -62,6 +59,14 @@ object KlaxonGlowEventConverter: Converter { override fun fromJson(jv: JsonValue): GlowEvent? = GlowEvent.fromString(jv.inside.toString()) } +object KlaxonGlowTopicConverter: Converter { + override fun canConvert(cls: Class<*>): Boolean = cls == GlowTopic::class.java + + override fun toJson(value: Any): String = "\"$value\"" + + override fun fromJson(jv: JsonValue): GlowTopic? = GlowTopic.fromString(jv.inside.toString()) +} + object UUIDConverter: Converter { override fun canConvert(cls: Class<*>) = cls == UUID::class.java diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonHandler.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonHandler.kt index eeaeca6..1d33db3 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonHandler.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonHandler.kt @@ -4,6 +4,7 @@ import org.cueglow.server.StateProvider import org.cueglow.server.objects.messages.GlowMessage import org.cueglow.server.objects.messages.GlowRequest import org.cueglow.server.objects.messages.IncomingGlowRequestHandler +import org.cueglow.server.objects.messages.SubscriptionHandler /** Represents a Receiver that takes a message string and can answer asynchronously with the provided GlowClient */ interface StringReceiver { @@ -14,7 +15,7 @@ interface StringReceiver { * A stateful handler, created for each JSON Connection. * It receives JSON messages, parses them and passes them to the handle implementation from [IncomingGlowRequestHandler]. */ -class JsonHandler(private val client: AsyncClient, state: StateProvider): StringReceiver, IncomingGlowRequestHandler(state) { +class JsonHandler(private val client: AsyncClient, state: StateProvider, subscriptionHandler: SubscriptionHandler): StringReceiver, IncomingGlowRequestHandler(state, subscriptionHandler) { override fun receive(message: String) { val glowMessage = GlowMessage.fromJsonString(message) val request = GlowRequest(glowMessage, client) diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonSubscriptionHandler.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonSubscriptionHandler.kt new file mode 100644 index 0000000..1457174 --- /dev/null +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/json/JsonSubscriptionHandler.kt @@ -0,0 +1,8 @@ +package org.cueglow.server.json + +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.objects.messages.SubscriptionHandler + +class JsonSubscriptionHandler: SubscriptionHandler() { + override fun serializeMessage(glowMessage: GlowMessage): String = glowMessage.toJsonString() +} diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowEvent.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowEvent.kt index bcd03b2..f7cc35f 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowEvent.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowEvent.kt @@ -2,7 +2,6 @@ package org.cueglow.server.objects.messages import kotlin.reflect.KClass -// TODO change class associations to GlowMessage classes /** * The different events in the [GlowMessage] */ @@ -10,12 +9,16 @@ enum class GlowEvent(val string: String, val messageClass: KClass, messageId: Int? = null): GlowMessage(GlowEvent.ADD_FIXTURES, messageId) class UpdateFixtures(@Json(index=1) val data: List, messageId: Int? = null): GlowMessage(GlowEvent.UPDATE_FIXTURES, messageId) diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowRequest.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowRequest.kt index dd4b327..185aada 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowRequest.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowRequest.kt @@ -5,7 +5,7 @@ import org.cueglow.server.json.AsyncClient /** * Wrapper around [GlowMessage] that provides a convenient way to answer. */ -class GlowRequest(val originalMessage: GlowMessage, private val client: AsyncClient) { +class GlowRequest(val originalMessage: GlowMessage, val client: AsyncClient) { fun answer(data: GlowMessage) = client.send(data) fun answer(error: GlowError) = client.send(error.toGlowMessage(originalMessage.messageId)) diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowTopic.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowTopic.kt new file mode 100644 index 0000000..28fe52b --- /dev/null +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/GlowTopic.kt @@ -0,0 +1,13 @@ +package org.cueglow.server.objects.messages + +enum class GlowTopic(val string: String) { + PATCH("patch"),; + + override fun toString() = string + + companion object { + // lookup topic by topic string + private val map = values().associateBy(GlowTopic::string) + fun fromString(string: String) = map[string] + } +} \ No newline at end of file diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/IncomingGlowRequestHandler.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/IncomingGlowRequestHandler.kt index 3e5b73a..4c85b3c 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/IncomingGlowRequestHandler.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/IncomingGlowRequestHandler.kt @@ -4,19 +4,16 @@ import com.github.michaelbull.result.getOrElse import org.apache.logging.log4j.kotlin.Logging import org.cueglow.server.StateProvider -abstract class IncomingGlowRequestHandler(private val state: StateProvider): Logging { +abstract class IncomingGlowRequestHandler(private val state: StateProvider, private val subscriptionHandler: SubscriptionHandler): Logging { fun handle(request: GlowRequest) { when (request.originalMessage.event) { - // TODO remove events that shouldn't come from outside and handle them with Error in else clause - GlowEvent.PATCH_SUBSCRIBE -> TODO() - GlowEvent.PATCH_INITIAL_STATE -> TODO() - GlowEvent.PATCH_UNSUBSCRIBE -> TODO() - GlowEvent.ERROR -> TODO() + GlowEvent.SUBSCRIBE -> subscriptionHandler.subscribe(request.client, (request.originalMessage as GlowMessage.Subscribe).data, state) + GlowEvent.UNSUBSCRIBE -> subscriptionHandler.unsubscribe(request.client, (request.originalMessage as GlowMessage.Unsubscribe).data) GlowEvent.ADD_FIXTURES -> handleAddFixtures(request) GlowEvent.UPDATE_FIXTURES -> handleUpdateFixture(request) GlowEvent.REMOVE_FIXTURES -> handleRemoveFixtures(request) - GlowEvent.FIXTURE_TYPE_ADDED -> TODO() GlowEvent.REMOVE_FIXTURE_TYPES -> handleRemoveFixtureTypes(request) + else -> logger.warn("Received a message with event ${request.originalMessage.event} which should not be sent by client. Discarding message. ") } } diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/SubscriptionHandler.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/SubscriptionHandler.kt new file mode 100644 index 0000000..441d6f8 --- /dev/null +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/objects/messages/SubscriptionHandler.kt @@ -0,0 +1,97 @@ +package org.cueglow.server.objects.messages + +import org.apache.logging.log4j.kotlin.Logging +import org.cueglow.server.OutEventReceiver +import org.cueglow.server.StateProvider +import org.cueglow.server.json.AsyncClient +import java.util.* + +/** + * Handles Subscribe/Unsubscribe Events. Receives OutEvents from the OutEventHandler and sends them to the subscribers. + **/ +abstract class SubscriptionHandler: OutEventReceiver, Logging { + private val activeSubscriptions = EnumMap>(GlowTopic::class.java) // TODO synchronize (see JavaDoc for EnumMap) + + /** Keeps subscriptions that were sent the initial state but do not get updates yet because older updates + * in the OutEventQueue first need to be handled. Subscriptions move from pending to active once the sync message + * (identified by UUID) is received by the SubscriptionHandler. + **/ + private val pendingSubscriptions = mutableMapOf>() + + init { + // populate subscriptions with empty sets + GlowTopic.values().forEach { activeSubscriptions[it] = mutableSetOf() } + } + + abstract fun serializeMessage(glowMessage: GlowMessage): String + + /** Receive and handle messages from the OutEventQueue **/ + override fun receive(glowMessage: GlowMessage) { + logger.info("Receiving $glowMessage") + + when (glowMessage.event) { + GlowEvent.ADD_FIXTURES, GlowEvent.UPDATE_FIXTURES, + GlowEvent.REMOVE_FIXTURES, GlowEvent.ADD_FIXTURE_TYPES, + GlowEvent.REMOVE_FIXTURE_TYPES -> publish(GlowTopic.PATCH, glowMessage) + + GlowEvent.SYNC -> activateSubscription((glowMessage as GlowMessage.Sync).data) + + else -> return + } + } + + private fun publish(topic: GlowTopic, glowMessage: GlowMessage) { + val topicSubscribers = activeSubscriptions[topic] + if (topicSubscribers!!.isNotEmpty()) { // null asserted because all possible keys are initialized in init block + val messageString = serializeMessage(glowMessage) + topicSubscribers.forEach {it.send(messageString)} + } + } + + /** Check if [syncUuid] is known. If yes, move subscription from pending to active **/ + private fun activateSubscription(syncUuid: UUID) { + val (topic, subscriber) = pendingSubscriptions.remove(syncUuid) ?: return + activeSubscriptions[topic]!!.add(subscriber) // null asserted because all possible keys are initialized in init block + } + + fun subscribe(subscriber: AsyncClient, topic: GlowTopic, state: StateProvider) { + // unsubscribe before subscribing + if (internalUnsubscribe(subscriber, topic)) {logger.warn("Client $subscriber subscribed to $topic but was already subscribed. Subscription was reset. ")} + when (topic) { + GlowTopic.PATCH -> { + val syncUuid = UUID.randomUUID() + val syncMessage = GlowMessage.Sync(syncUuid) + pendingSubscriptions[syncUuid] = Pair(GlowTopic.PATCH, subscriber) + // TODO acquire state lock here + val initialPatchState = state.patch.getGlowPatch() + state.outEventQueue.put(syncMessage) // TODO possible deadlock because SubscriptionHandler is locked and cannot work to reduce message count in queue + // no deadlock problem if we don't have the SubscriptionHandler Lock here? + // TODO release state lock here + val initialMessage = GlowMessage.PatchInitialState(initialPatchState) + subscriber.send(initialMessage) + } + } + } + + fun unsubscribe(subscriber: AsyncClient, topic: GlowTopic) { + if (!internalUnsubscribe(subscriber, topic)) {logger.warn("Client $subscriber unsubscribed from $topic but was not subscribed")} + } + + /** Returns true if the subscriber was successfully unsubscribed and false if the subscriber wasn't subscribed */ + private fun internalUnsubscribe(subscriber: AsyncClient, topic: GlowTopic): Boolean { + val numberOfSubscriptionsRemovedFromPending = pendingSubscriptions + .filter {it.value.first == topic && it.value.second == subscriber} + .keys + .map {pendingSubscriptions.remove(it)} + .size + val removedFromActive = activeSubscriptions[topic]!!.remove(subscriber) // null asserted because all possible keys are initialized in init block + return removedFromActive || numberOfSubscriptionsRemovedFromPending > 0 + } + + fun unsubscribeFromAllTopics(subscriber: AsyncClient) { + pendingSubscriptions.filter {it.value.second == subscriber}.keys.map { pendingSubscriptions.remove(it) } + activeSubscriptions.values.forEach { + it.remove(subscriber) + } + } +} \ No newline at end of file diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/patch/Patch.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/patch/Patch.kt index e7a47ee..2bcd334 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/patch/Patch.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/patch/Patch.kt @@ -2,17 +2,19 @@ package org.cueglow.server.patch import com.github.michaelbull.result.* import org.cueglow.server.gdtf.GdtfWrapper -import org.cueglow.server.objects.* +import org.cueglow.server.objects.ImmutableMap import org.cueglow.server.objects.messages.* import java.util.* -import kotlin.collections.HashMap +import java.util.concurrent.BlockingQueue +import kotlin.reflect.KClass +import kotlin.reflect.full.primaryConstructor /** * Holds Patch Data * * The data is isolated such that it can only be modified by methods that notify the StreamHandler on change. */ -class Patch { +class Patch(private val outEventQueue: BlockingQueue) { private val fixtures: HashMap = HashMap() private val fixtureTypes: HashMap = HashMap() @@ -23,10 +25,6 @@ class Patch { /** Returns an immutable copy of the Patch */ fun getGlowPatch(): GlowPatch = GlowPatch(fixtures.values.toList(), fixtureTypes.values.toList()) - // ------------------- - // Modify Fixture List - // ------------------- - /** * Execute [lambda] for every element in [collection]. If the result of [lambda] is an Error, add it to the * error list which is returned once all elements are dealt with. @@ -40,8 +38,31 @@ class Patch { return Ok(Unit) } + /** + * Calls [executeWithErrorList] but also keeps a list of successful operations. When the operations are done, the + * successful operations are wrapped in the specified [messageType] and added to the [outEventQueue]. + */ + private fun executeWithErrorListAndSendOutEvent(messageType: KClass, collection: Iterable, lambda: (T) -> Result): Result> { + val successList = mutableListOf() + val mainResult = executeWithErrorList(collection) { collectionElement -> + lambda(collectionElement).map{ + successList.add(it) + Unit + } + } + if (successList.isNotEmpty()) { + val glowMessage = messageType.primaryConstructor?.call(successList, null) ?: throw IllegalArgumentException("messageType does not have a primary constructor") + outEventQueue.put(glowMessage) // TODO possibly blocks rendering/etc. but the changes were already made so the message needs to be put into the queue + } + return mainResult + } + + // ------------------- + // Modify Fixture List + // ------------------- + fun addFixtures(fixturesToAdd: Iterable): Result> { - return executeWithErrorList(fixturesToAdd) eachFixture@{ patchFixtureToAdd -> + return executeWithErrorListAndSendOutEvent(GlowMessage.AddFixtures::class, fixturesToAdd) eachFixture@{ patchFixtureToAdd -> // validate uuid does not exist yet if (fixtures.contains(patchFixtureToAdd.uuid)) { return@eachFixture Err(FixtureUuidAlreadyExistsError(patchFixtureToAdd.uuid)) @@ -55,12 +76,12 @@ class Patch { return@eachFixture Err(UnknownDmxModeError(patchFixtureToAdd.dmxMode, patchFixtureToAdd.fixtureTypeId)) } fixtures[patchFixtureToAdd.uuid] = patchFixtureToAdd - return@eachFixture Ok(Unit) + return@eachFixture Ok(patchFixtureToAdd) } } fun updateFixtures(fixtureUpdates: Iterable): Result> { - return executeWithErrorList(fixtureUpdates) eachUpdate@{fixtureUpdate -> + return executeWithErrorListAndSendOutEvent(GlowMessage.UpdateFixtures::class, fixtureUpdates) eachUpdate@{ fixtureUpdate -> // validate fixture uuid exists already val oldFixture = fixtures[fixtureUpdate.uuid] ?: run { return@eachUpdate Err(UnknownFixtureUuidError(fixtureUpdate.uuid)) @@ -72,38 +93,39 @@ class Patch { address = fixtureUpdate.address.getOr(oldFixture.address), ) fixtures[newFixture.uuid] = newFixture - return@eachUpdate Ok(Unit) + return@eachUpdate Ok(fixtureUpdate) } } fun removeFixtures(uuids: Iterable): Result> { - return executeWithErrorList(uuids) eachFixture@{uuidToRemove -> + return executeWithErrorListAndSendOutEvent(GlowMessage.RemoveFixtures::class, uuids) eachFixture@{ uuidToRemove -> fixtures.remove(uuidToRemove) ?: return@eachFixture Err(UnknownFixtureUuidError(uuidToRemove)) - return@eachFixture Ok(Unit) + return@eachFixture Ok(uuidToRemove) } } // ------------------------ // Modify Fixture Type List // ------------------------ + fun addFixtureTypes(fixtureTypesToAdd: Iterable): Result> { - return executeWithErrorList(fixtureTypesToAdd) eachFixtureType@{ fixtureTypeToAdd -> + return executeWithErrorListAndSendOutEvent(GlowMessage.AddFixtureTypes::class, fixtureTypesToAdd) eachFixtureType@{ fixtureTypeToAdd -> // validate fixture type is not patched already if (fixtureTypes.containsKey(fixtureTypeToAdd.fixtureTypeId)) { return@eachFixtureType Err(FixtureTypeAlreadyExistsError(fixtureTypeToAdd.fixtureTypeId)) } fixtureTypes[fixtureTypeToAdd.fixtureTypeId] = fixtureTypeToAdd - return@eachFixtureType Ok(Unit) + return@eachFixtureType Ok(fixtureTypeToAdd) } } fun removeFixtureTypes(fixtureTypeIdsToRemove: List): Result> { - return executeWithErrorList(fixtureTypeIdsToRemove) eachFixtureType@{ fixtureTypeIdToRemove -> + return executeWithErrorListAndSendOutEvent(GlowMessage.RemoveFixtureTypes::class, fixtureTypeIdsToRemove) eachFixtureType@{ fixtureTypeIdToRemove -> fixtureTypes.remove(fixtureTypeIdToRemove) ?: return@eachFixtureType Err(UnpatchedFixtureTypeIdError(fixtureTypeIdToRemove)) // remove associated fixtures - fixtures.filter { it.value.fixtureTypeId == fixtureTypeIdToRemove }.keys.forEach {fixtures.remove(it)} - return@eachFixtureType Ok(Unit) + fixtures.filter { it.value.fixtureTypeId == fixtureTypeIdToRemove }.keys.let {this.removeFixtures(it)} + return@eachFixtureType Ok(fixtureTypeIdToRemove) } } } diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketConnection.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketConnection.kt index fbc1722..6659238 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketConnection.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketConnection.kt @@ -6,10 +6,11 @@ import org.cueglow.server.json.AsyncClient import org.cueglow.server.json.JsonHandler import org.cueglow.server.json.toJsonString import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.objects.messages.SubscriptionHandler import org.eclipse.jetty.websocket.api.Session import org.eclipse.jetty.websocket.api.WebSocketListener -class WebSocketConnection(val state: StateProvider): WebSocketListener, AsyncClient, Logging { +class WebSocketConnection(val state: StateProvider, val subscriptionHandler: SubscriptionHandler): WebSocketListener, AsyncClient, Logging { @Volatile var session: Session? = null @@ -24,7 +25,7 @@ class WebSocketConnection(val state: StateProvider): WebSocketListener, AsyncCli * Sends a message to the WebSocket client. * If the client is disconnected, nothing will be done. */ - fun send(message: String) { + override fun send(message: String) { session?.remote?.sendStringByFuture(message) } @@ -39,7 +40,7 @@ class WebSocketConnection(val state: StateProvider): WebSocketListener, AsyncCli override fun onWebSocketConnect(newSession: Session) { session = newSession logger.info("WebSocket connection with ${newSession.remoteAddress} established") - jsonHandler = JsonHandler(this, state) + jsonHandler = JsonHandler(this, state, subscriptionHandler) } override fun onWebSocketText(message: String?) { @@ -57,7 +58,7 @@ class WebSocketConnection(val state: StateProvider): WebSocketListener, AsyncCli override fun onWebSocketClose(statusCode: Int, reason: String?) { logger.info("WebSocket connection to ${session?.remoteAddress} closed. Status: $statusCode. Reason: $reason. ") + subscriptionHandler.unsubscribeFromAllTopics(this) session = null - // TODO pass close event to jsonHandler (unsubscribe, etc.) (must still be added in StringReceiver interface) } } diff --git a/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketSetup.kt b/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketSetup.kt index 3305d8a..684f32b 100644 --- a/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketSetup.kt +++ b/cueglow-server/src/main/kotlin/org/cueglow/server/websocket/WebSocketSetup.kt @@ -2,6 +2,7 @@ package org.cueglow.server.websocket import org.apache.logging.log4j.kotlin.Logging import org.cueglow.server.StateProvider +import org.cueglow.server.objects.messages.SubscriptionHandler import org.eclipse.jetty.websocket.server.WebSocketHandler import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse @@ -9,16 +10,16 @@ import org.eclipse.jetty.websocket.servlet.WebSocketCreator import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory /** Register the [WebSocketCreator] with the [WebSocketServletFactory]. */ -class GlowWebSocketHandler(val state: StateProvider): WebSocketHandler(), Logging { +class GlowWebSocketHandler(val state: StateProvider, val subscriptionHandler: SubscriptionHandler): WebSocketHandler(), Logging { override fun configure(factory: WebSocketServletFactory?) { - factory?.creator = GlowWebSocketCreator(state) + factory?.creator = GlowWebSocketCreator(state, subscriptionHandler) } } /** For every new WebSocket connection, create a [WebSocketConnection] and inject access to the state */ -class GlowWebSocketCreator(val state: StateProvider): WebSocketCreator { +class GlowWebSocketCreator(val state: StateProvider, val subscriptionHandler: SubscriptionHandler): WebSocketCreator { override fun createWebSocket(req: ServletUpgradeRequest?, resp: ServletUpgradeResponse?): Any { - return WebSocketConnection(state) + return WebSocketConnection(state, subscriptionHandler) } } diff --git a/cueglow-server/src/test/README.md b/cueglow-server/src/test/README.md deleted file mode 100644 index 02f944f..0000000 --- a/cueglow-server/src/test/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Module test - -# Package org.cueglow.server.integration - -This package contains tests that will each start a server instance and interact with its network API to test it. - -Other so called "integration tests" (i.e. testing multiple pieces of code interacting with each other) that don't -start up a complete server instance are placed in other packages. - -This structure may need to be refactored once more code is added. \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/ChannelLayoutTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/ChannelLayoutTest.kt index d091648..9e91083 100644 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/ChannelLayoutTest.kt +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/ChannelLayoutTest.kt @@ -1,16 +1,8 @@ package org.cueglow.server.gdtf -import com.github.michaelbull.result.unwrap +import org.cueglow.server.test_utilities.fixtureTypeFromGdtfResource import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -import java.io.InputStream - -fun fixtureTypeFromGdtfResource(exampleGdtfFileName: String, cls: Class<*>): GdtfWrapper { - val exampleGdtfInputStream: InputStream = - cls.classLoader.getResourceAsStream(exampleGdtfFileName) ?: throw Error("inputStream is Null") - val parsedExampleGdtf = parseGdtf(exampleGdtfInputStream).unwrap() - return GdtfWrapper(parsedExampleGdtf) -} internal class ChannelLayoutTest { private val exampleFixtureType = diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/GdtIntegrationfTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/GdtIntegrationfTest.kt new file mode 100644 index 0000000..7c96634 --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/gdtf/GdtIntegrationfTest.kt @@ -0,0 +1,183 @@ +package org.cueglow.server.integration + +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.core.FileDataPart +import com.github.kittinunf.fuel.core.ResponseResultOf +import org.awaitility.Awaitility.await +import org.cueglow.server.json.fromJsonString +import org.cueglow.server.objects.messages.GlowEvent +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.test_utilities.ClientAndServerTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.File +import java.util.* + +class GdtfIntegrationTest: ClientAndServerTest() { + + private fun uploadGdtfFile(filename: String, partname: String = "file"): ResponseResultOf { + val exampleGdtfFile = + File(javaClass.classLoader.getResource(filename)?.file ?: throw Error("can't get resource")) + return Fuel.upload("http://localhost:7000/api/fixturetype") + .add( + FileDataPart( + exampleGdtfFile, name = partname, filename = filename + ) + ) + .responseString() + } + + @Test + fun gdtfUpload() { + // HTTP Request + val (_, response, result) = uploadGdtfFile("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf") + + // evaluate response + assertEquals(200, response.statusCode) + + val responseJSON = result.component1() ?: "" + val jsonMessage = GlowMessage.fromJsonString(responseJSON) + assertEquals(GlowEvent.FIXTURE_TYPE_ADDED, jsonMessage.event) + + val expectedUUID = UUID.fromString("7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE") + val returnedUUID = (jsonMessage as GlowMessage.FixtureTypeAdded).data + assertEquals(expectedUUID, returnedUUID) + + // check that fixture is added to Patch + assertEquals(1, patch.getFixtureTypes().size) + assertEquals("Robin Esprite", patch.getFixtureTypes()[expectedUUID]?.name) + } + + @Test + fun removeInvalidFixtureTypes() { + setupExampleFixture() + + val uuid1 = UUID.fromString("049bbf91-25f4-495f-ae30-9289adb8c2cf") + val uuid2 = UUID.fromString("37cd9136-58bf-423c-b373-07651940807d") + + val deleteJSONMsg = + """ + { + "event": "removeFixtureTypes", + "data": ["$uuid1", "$uuid2"], + "messageId": 42 + } + """ + + wsClient.send(deleteJSONMsg) + + val msg1 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) + val msg2 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) + + // still one fixture type in patch + assertEquals(1, patch.getFixtureTypes().size) + + assertEquals(GlowEvent.ERROR, msg1.event) + assertEquals("UnpatchedFixtureTypeIdError", (msg1 as GlowMessage.Error).data.name) + assertTrue(msg1.data.description.contains(uuid1.toString())) + + assertEquals(GlowEvent.ERROR, msg2.event) + assertEquals("UnpatchedFixtureTypeIdError", (msg2 as GlowMessage.Error).data.name) + assertTrue(msg2.data.description.contains(uuid2.toString())) + } + + @Test + fun removeFixtureType() { + setupExampleFixture() + + val deleteJSONMsg = + """ + { + "event": "removeFixtureTypes", + "data": ["7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE"], + "messageId": 42 + } + """ + + wsClient.send(deleteJSONMsg) + + await().untilAsserted { + assertEquals(0, patch.getFixtureTypes().size) + } + assertEquals(0, patch.getFixtures().size) + } + + @Test + fun invalidGdtfUpload() { + val (_, response, _) = uploadGdtfFile("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf.broken") + + assertEquals(400, response.statusCode) + assertTrue( + response.body().asString("text/plain").contains( + "Duplicate unique value [PanTilt] declared for identity constraint" + ) + ) + + // no fixtureType should be added + assertEquals(0, patch.getFixtureTypes().size) + } + + @Test + fun noFilePartInGdtfUploadErrors() { + val (_, response, _) = uploadGdtfFile( + "Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf", + "strangePartName" + ) + + assertEquals(400, response.statusCode) + + val responseJSON = response.body().asString("text/plain") + val jsonMessage = GlowMessage.fromJsonString(responseJSON) + val data = (jsonMessage as GlowMessage.Error).data + assertEquals("MissingFilePartError", data.name) + assertNotEquals("", data.description) + } + + @Test + fun noDescriptionXmlUploadError() { + val (_, response, _) = uploadGdtfFile("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf.noDescriptionXml") + + assertEquals(400, response.statusCode) + + val responseJSON = response.body().asString("text/plain") + val jsonMessage = GlowMessage.fromJsonString(responseJSON) + val data = (jsonMessage as GlowMessage.Error).data + assertEquals("MissingDescriptionXmlInGdtfError", data.name) + assertNotEquals("", data.description) + } + + @Test + fun gdtfWithChannelClash() { + val (_, response, _) = uploadGdtfFile("ChannelLayoutTest/Test@Channel_Layout_Test@v1_first_try.channel_clash.gdtf") + + assertEquals(400, response.statusCode) + + val responseJSON = response.body().asString("text/plain") + println("Error returned by server: ") + println(responseJSON) + val jsonMessage = GlowMessage.fromJsonString(responseJSON) + val data = (jsonMessage as GlowMessage.Error).data + assertEquals("InvalidGdtfError", data.name) + // Error Message should contain the names of the colliding channels + assertTrue(data.description.contains("Element 1 -> AbstractElement_Pan")) + assertTrue(data.description.contains("Main_Dimmer")) + // Error Message should contain the Mode Name + assertTrue(data.description.contains("Mode 1")) + } + + @Test + fun gdtfWithMissingBreakInGeometryReference() { + val (_, response, _) = uploadGdtfFile("ChannelLayoutTest/Test@Channel_Layout_Test@v1_first_try.missing_break_in_geometry_reference.gdtf") + + assertEquals(400, response.statusCode) + + val responseJSON = response.body().asString("text/plain") + println("Error returned by server: ") + println(responseJSON) + val jsonMessage = GlowMessage.fromJsonString(responseJSON) + val data = (jsonMessage as GlowMessage.Error).data + assertEquals("InvalidGdtfError", data.name) + // Error Message should contain the name of the faulty geometry reference + assertTrue(data.description.contains("Element 2")) + } +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/AddFixtureTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/integration/AddFixtureTest.kt deleted file mode 100644 index 3ded8ff..0000000 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/AddFixtureTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.cueglow.server.integration - -import org.awaitility.Awaitility.await -import org.cueglow.server.json.fromJsonString -import org.cueglow.server.objects.messages.GlowEvent -import org.cueglow.server.objects.messages.GlowMessage -import org.cueglow.server.patch.Patch -import org.cueglow.server.patch.PatchFixture -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue - -val addFixtureJsonMessage = - """{ - "event": "addFixtures", - "data": [ - { - "uuid": "91faaa61-624b-477a-a6c2-de00c717b3e6", - "fid": 1, - "name": "exampleFixture", - "fixtureTypeId": "7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE", - "dmxMode": "mode1", - "universe": 1, - "address": 1 - } - ], - "messageId": 42 - }""".trimIndent() - -fun addFixtureTest(wsClient: WsClient, patch: Patch, examplePatchFixture: PatchFixture) { - wsClient.send(addFixtureJsonMessage) - - await().untilAsserted{ assertEquals(1, patch.getFixtures().size) } - - assertTrue(examplePatchFixture.isSimilar(patch.getFixtures().asSequence().first().value)) -} - -fun addFixtureDuplicateUuidTest(wsClient: WsClient, patch: Patch) { - wsClient.send(addFixtureJsonMessage) - - val receivedString = wsClient.receiveOneMessageBlocking() - val message = GlowMessage.fromJsonString(receivedString) - - assertEquals(GlowEvent.ERROR, message.event) - assertEquals("FixtureUuidAlreadyExistsError", (message as GlowMessage.Error).data.name) - assertEquals(42, message.messageId) - - assertEquals(1, patch.getFixtures().size) -} - -fun addFixtureInvalidFixtureTypeIdTest(wsClient: WsClient, patch: Patch) { - val initialFixtureCount = patch.getFixtures().size - - // fixtureTypeId has "3be" at end instead of "4be" - val jsonToSend = - """{ - "event": "addFixtures", - "data": [ - { - "uuid": "d81644b3-43e0-4b6d-8df4-e1e8dffaddeb", - "fid": 2, - "name": "exampleFixture2", - "fixtureTypeId": "7FB33577-09C9-4BF0-BE3B-EF0DC3BEF3BE", - "dmxMode": "mode1", - "universe": 1, - "address": 100 - } - ], - "messageId": 42 - }""".trimIndent() - - wsClient.send(jsonToSend) - - val received = wsClient.receiveOneMessageBlocking() - val message = GlowMessage.fromJsonString(received) - - assertEquals(GlowEvent.ERROR, message.event) - assertEquals("UnpatchedFixtureTypeIdError", (message as GlowMessage.Error).data.name) - assertEquals(42, message.messageId) - - assertEquals(initialFixtureCount, patch.getFixtures().size) -} - -fun addFixtureInvalidDmxModeTest(wsClient: WsClient, patch: Patch) { - val initialFixtureCount = patch.getFixtures().size - - // changed dmx mode - val jsonToSend = - """{ - "event": "addFixtures", - "data": [ - { - "uuid": "d81644b3-43e0-4b6d-8df4-e1e8dffaddeb", - "fid": 2, - "name": "exampleFixture2", - "fixtureTypeId": "7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE", - "dmxMode": "not_a_mode", - "universe": 1, - "address": 100 - } - ], - "messageId": 42 - }""".trimIndent() - - wsClient.send(jsonToSend) - - val received = wsClient.receiveOneMessageBlocking() - val message = GlowMessage.fromJsonString(received) - - assertEquals(GlowEvent.ERROR, message.event) - assertEquals("UnknownDmxModeError", (message as GlowMessage.Error).data.name) - assertEquals(42, message.messageId) - - assertEquals(initialFixtureCount, patch.getFixtures().size) -} - - diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/ApiIntegrationTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/integration/ApiIntegrationTest.kt deleted file mode 100644 index fb45a09..0000000 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/ApiIntegrationTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -package org.cueglow.server.integration - -import com.github.kittinunf.fuel.Fuel -import com.github.kittinunf.fuel.core.FileDataPart -import com.github.kittinunf.fuel.core.ResponseResultOf -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.unwrap -import org.awaitility.Awaitility -import org.awaitility.pollinterval.FibonacciPollInterval.fibonacci -import org.cueglow.server.CueGlowServer -import org.cueglow.server.gdtf.fixtureTypeFromGdtfResource -import org.cueglow.server.objects.ArtNetAddress -import org.cueglow.server.objects.DmxAddress -import org.cueglow.server.patch.Patch -import org.cueglow.server.patch.PatchFixture -import org.junit.jupiter.api.* -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.MethodOrderer.OrderAnnotation -import java.io.File -import java.net.URI -import java.time.Duration -import java.util.* - -/** - * Tests the WebSocket and REST API - * - * Provides setup/teardown of the server for each test and utility functions. - * - * Individual tests are written as free functions in other files and called in @Test-methods. - * - * To wait for responses/state-changes we use Awaitility. - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -@TestMethodOrder(OrderAnnotation::class) -internal class ApiIntegrationTest { - //----------------------------------------------------- - // Initialization - //----------------------------------------------------- - private lateinit var server: CueGlowServer - - private lateinit var patch: Patch - - private lateinit var wsClient: WsClient - - init { - Awaitility.setDefaultPollInterval(fibonacci()) - Awaitility.setDefaultTimeout(Duration.ofSeconds(2)) - } - - //----------------------------------------------------- - // Helpers - //----------------------------------------------------- - - private val exampleFixtureType = fixtureTypeFromGdtfResource("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf", this.javaClass) - - private val examplePatchFixture = PatchFixture( - UUID.fromString("91faaa61-624b-477a-a6c2-de00c717b3e6"), - 1, - "exampleFixture", - exampleFixtureType.fixtureTypeId, - "mode1", - ArtNetAddress.tryFrom(1).unwrap(), - DmxAddress.tryFrom(1).unwrap(), - ) - - private fun uploadGdtfFile(filename: String, partname: String = "file"): ResponseResultOf { - val exampleGdtfFile = - File(javaClass.classLoader.getResource(filename)?.file ?: throw Error("can't get resource")) - return Fuel.upload("http://localhost:7000/api/fixturetype") - .add( - FileDataPart( - exampleGdtfFile, name = partname, filename = filename - ) - ) - .responseString() - } - - private fun setupExampleFixtureType() { - assertTrue(patch.addFixtureTypes(listOf(exampleFixtureType)) is Ok) - } - - private fun setupExampleFixture() { - setupExampleFixtureType() - assert(patch.addFixtures(listOf(examplePatchFixture)) is Ok) - } - - //----------------------------------------------------- - // Setup/Teardown for Each Test - //----------------------------------------------------- - - @BeforeEach - fun setup() { - server = CueGlowServer() - patch = server.state.patch - wsClient = WsClient(URI("ws://localhost:7000/ws")) - wsClient.connectBlocking() - } - - @AfterEach - fun teardown() { - wsClient.closeBlocking() - server.stop() - } - - //----------------------------------------------------- - // Individual Tests - //----------------------------------------------------- - - - // GDTF / Fixture Type Tests - - @Test - fun uploadGdtfFixtureType() = gdtfUploadTest(::uploadGdtfFile, patch) - - @Test - fun invalidGdtfFileUpload() = invalidGdtfUploadTest(::uploadGdtfFile, patch) - - @Test - fun noFilePartInGdtfUpload() = noFilePartInGdtfUploadErrors(::uploadGdtfFile) - - @Test - fun gdtfWithoutDescriptionXml() = noDescriptionXmlUploadError(::uploadGdtfFile) - - @Test - fun gdtfWithChannelClash() = gdtfWithChannelClash(::uploadGdtfFile) - - @Test - fun gdtfWithMissingBreakInGeometryReference() = gdtfWithMissingBreakInGeometryReference(::uploadGdtfFile) - - @Test - fun removeInvalidFixtureTypes() { - setupExampleFixture() - removeInvalidFixtureTypesTest(wsClient, patch) - } - - @Test - fun removeFixtureType() { - setupExampleFixture() - removeFixtureTypeTest(wsClient, patch) - } - - // Add Fixture Tests - - @Test - fun addFixture() { - setupExampleFixtureType() - addFixtureTest(wsClient, patch, examplePatchFixture) - } - - @Test - fun addFixtureDuplicateUuid() { - setupExampleFixture() - addFixtureDuplicateUuidTest(wsClient, patch) - } - - @Test - fun addFixtureInvalidFixtureTypeId() { - setupExampleFixtureType() - addFixtureInvalidFixtureTypeIdTest(wsClient, patch) - } - - @Test - fun addFixtureInvalidDmxMode() { - setupExampleFixtureType() - addFixtureInvalidDmxModeTest(wsClient, patch) - } - - // Update Fixture Tests - - @Test - fun updateUnknownFixture() { - setupExampleFixture() - updateUnknownFixtureTest(wsClient) - } - - @Test - fun updateAddress() { - setupExampleFixture() - updateAddressTest(wsClient, patch) - } - - @Test - fun updateUniverse() { - setupExampleFixture() - updateUniverseTest(wsClient, patch) - } - - @Test - fun updateNameAndFid() { - setupExampleFixture() - updateNameAndFidTest(wsClient, patch) - } - - // Remove Fixture Tests - - @Test - fun removeInvalidFixture() { - setupExampleFixture() - removeInvalidFixtureTest(wsClient, patch) - } - - @Test - fun removeFixture() { - setupExampleFixture() - removeFixtureTest(wsClient, patch) - } -} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/GdtfTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/integration/GdtfTest.kt deleted file mode 100644 index 038e937..0000000 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/GdtfTest.kt +++ /dev/null @@ -1,156 +0,0 @@ -package org.cueglow.server.integration - -import com.github.kittinunf.fuel.core.ResponseResultOf -import org.awaitility.Awaitility.await -import org.cueglow.server.json.fromJsonString -import org.cueglow.server.objects.messages.GlowEvent -import org.cueglow.server.objects.messages.GlowMessage -import org.cueglow.server.patch.Patch -import org.junit.jupiter.api.Assertions.* -import java.util.* - -// These test functions are executed in ApiIntegrationTest.kt - -fun gdtfUploadTest(uploadGdtfFile: (String) -> ResponseResultOf, patch: Patch) { - // HTTP Request - val (_, response, result) = uploadGdtfFile("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf") - - // evaluate response - assertEquals(200, response.statusCode) - - val responseJSON = result.component1() ?: "" - val jsonMessage = GlowMessage.fromJsonString(responseJSON) - assertEquals(GlowEvent.FIXTURE_TYPE_ADDED, jsonMessage.event) - - val expectedUUID = UUID.fromString("7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE") - val returnedUUID = (jsonMessage as GlowMessage.FixtureTypeAdded).data - assertEquals(expectedUUID, returnedUUID) - - // check that fixture is added to Patch - assertEquals(1, patch.getFixtureTypes().size) - assertEquals("Robin Esprite", patch.getFixtureTypes()[expectedUUID]?.name) - - // TODO check that streamUpdate is delivered (once streams are working) -} - -fun removeInvalidFixtureTypesTest(wsClient: WsClient, patch: Patch) { - val uuid1 = UUID.fromString("049bbf91-25f4-495f-ae30-9289adb8c2cf") - val uuid2 = UUID.fromString("37cd9136-58bf-423c-b373-07651940807d") - - val deleteJSONMsg = - """ - { - "event": "removeFixtureTypes", - "data": ["$uuid1", "$uuid2"], - "messageId": 42 - } - """ - - wsClient.send(deleteJSONMsg) - - val msg1 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) - val msg2 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) - - // still one fixture type in patch - assertEquals(1, patch.getFixtureTypes().size) - - assertEquals(GlowEvent.ERROR, msg1.event) - assertEquals("UnpatchedFixtureTypeIdError", (msg1 as GlowMessage.Error).data.name) - assertTrue(msg1.data.description.contains(uuid1.toString())) - - assertEquals(GlowEvent.ERROR, msg2.event) - assertEquals("UnpatchedFixtureTypeIdError", (msg2 as GlowMessage.Error).data.name) - assertTrue(msg2.data.description.contains(uuid2.toString())) -} - -fun removeFixtureTypeTest(wsClient: WsClient, patch: Patch) { - val deleteJSONMsg = - """ - { - "event": "removeFixtureTypes", - "data": ["7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE"], - "messageId": 42 - } - """ - - wsClient.send(deleteJSONMsg) - - await().untilAsserted{ - assertEquals(0, patch.getFixtureTypes().size) - } - assertEquals(0, patch.getFixtures().size) -} - -fun invalidGdtfUploadTest(uploadGdtfFile: (String) -> ResponseResultOf, patch: Patch) { - val (_, response, _) = uploadGdtfFile("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf.broken") - - assertEquals(400, response.statusCode) - assertTrue(response.body().asString("text/plain").contains( - "Duplicate unique value [PanTilt] declared for identity constraint" - )) - - // no fixtureType should be added - assertEquals(0, patch.getFixtureTypes().size) - - // TODO check error response once error handling is more mature -} - -fun noFilePartInGdtfUploadErrors(uploadGdtfFile: (String, String) -> ResponseResultOf) { - val (_, response, _) = uploadGdtfFile( - "Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf", - "strangePartName" - ) - - assertEquals(400, response.statusCode) - - val responseJSON = response.body().asString("text/plain") - val jsonMessage = GlowMessage.fromJsonString(responseJSON) - val data = (jsonMessage as GlowMessage.Error).data - assertEquals("MissingFilePartError", data.name) - assertNotEquals("", data.description) -} - -fun noDescriptionXmlUploadError(uploadGdtfFile: (String) -> ResponseResultOf) { - val (_, response, _) = uploadGdtfFile("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf.noDescriptionXml") - - assertEquals(400, response.statusCode) - - val responseJSON = response.body().asString("text/plain") - val jsonMessage = GlowMessage.fromJsonString(responseJSON) - val data =(jsonMessage as GlowMessage.Error).data - assertEquals("MissingDescriptionXmlInGdtfError", data.name) - assertNotEquals("", data.description) -} - -fun gdtfWithChannelClash(uploadGdtfFile: (String) -> ResponseResultOf) { - val (_, response, _) = uploadGdtfFile("ChannelLayoutTest/Test@Channel_Layout_Test@v1_first_try.channel_clash.gdtf") - - assertEquals(400, response.statusCode) - - val responseJSON = response.body().asString("text/plain") - println("Error returned by server: ") - println(responseJSON) - val jsonMessage = GlowMessage.fromJsonString(responseJSON) - val data =(jsonMessage as GlowMessage.Error).data - assertEquals("InvalidGdtfError", data.name) - // Error Message should contain the names of the colliding channels - assertTrue(data.description.contains("Element 1 -> AbstractElement_Pan")) - assertTrue(data.description.contains("Main_Dimmer")) - // Error Message should contain the Mode Name - assertTrue(data.description.contains("Mode 1")) -} - -fun gdtfWithMissingBreakInGeometryReference(uploadGdtfFile: (String) -> ResponseResultOf) { - val (_, response, _) = uploadGdtfFile("ChannelLayoutTest/Test@Channel_Layout_Test@v1_first_try.missing_break_in_geometry_reference.gdtf") - - assertEquals(400, response.statusCode) - - val responseJSON = response.body().asString("text/plain") - println("Error returned by server: ") - println(responseJSON) - val jsonMessage = GlowMessage.fromJsonString(responseJSON) - val data =(jsonMessage as GlowMessage.Error).data - assertEquals("InvalidGdtfError", data.name) - // Error Message should contain the name of the faulty geometry reference - assertTrue(data.description.contains("Element 2")) -} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/RemoveFixtureTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/integration/RemoveFixtureTest.kt deleted file mode 100644 index 5cbfcb4..0000000 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/RemoveFixtureTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.cueglow.server.integration - -import org.awaitility.Awaitility -import org.cueglow.server.json.fromJsonString -import org.cueglow.server.objects.messages.GlowEvent -import org.cueglow.server.objects.messages.GlowMessage -import org.cueglow.server.patch.Patch -import org.junit.jupiter.api.Assertions -import java.util.* - - -fun removeInvalidFixtureTest(wsClient: WsClient, patch: Patch) { - val uuid1 = UUID.fromString("753eca6f-f5a8-4e0d-8907-16074534e08f") - val uuid2 = UUID.fromString("9cf0450d-cef1-47e6-b467-631c4cfe45e7") - - val jsonToSend = - """{ - "event": "removeFixtures", - "data": ["$uuid1", "$uuid2"], - "messageId": 729 - }""".trimIndent() - - wsClient.send(jsonToSend) - - val msg1 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) - val msg2 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) - - // still one fixture in patch - Assertions.assertEquals(1, patch.getFixtures().size) - - Assertions.assertEquals(GlowEvent.ERROR, msg1.event) - Assertions.assertEquals("UnknownFixtureUuidError", (msg1 as GlowMessage.Error).data.name) - Assertions.assertTrue(msg1.data.description.contains(uuid1.toString())) - - Assertions.assertEquals(GlowEvent.ERROR, msg2.event) - Assertions.assertEquals("UnknownFixtureUuidError", (msg2 as GlowMessage.Error).data.name) - Assertions.assertTrue(msg2.data.description.contains(uuid2.toString())) -} - -fun removeFixtureTest(wsClient: WsClient, patch: Patch) { - val uuidToModify = patch.getFixtures().keys.first() - - val jsonToSend = - """{ - "event": "removeFixtures", - "data": ["$uuidToModify"], - "messageId": 730 - }""".trimIndent() - - wsClient.send(jsonToSend) - - Awaitility.await().untilAsserted { - Assertions.assertEquals(0, patch.getFixtures().size) - } -} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/UpdateFixtureTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/integration/UpdateFixtureTest.kt deleted file mode 100644 index 3016998..0000000 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/UpdateFixtureTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.cueglow.server.integration - -import org.awaitility.Awaitility -import org.cueglow.server.json.fromJsonString -import org.cueglow.server.objects.messages.GlowEvent -import org.cueglow.server.objects.messages.GlowMessage -import org.cueglow.server.patch.Patch -import org.junit.jupiter.api.Assertions -import java.util.* - - -fun updateUnknownFixtureTest(wsClient: WsClient) { - // random UUID - val uuidToModify = UUID.fromString("cb9bd39b-303c-4105-8e14-1c5919c05750") - - val jsonToSend = - """{ - "event": "updateFixtures", - "data": [ - { - "uuid": "$uuidToModify", - "address": 432 - } - ], - "messageId": 90 - }""".trimIndent() - - wsClient.send(jsonToSend) - - val received = wsClient.receiveOneMessageBlocking() - val message = GlowMessage.fromJsonString(received) - - Assertions.assertEquals(GlowEvent.ERROR, message.event) - Assertions.assertEquals("UnknownFixtureUuidError", (message as GlowMessage.Error).data.name) - Assertions.assertEquals(90, message.messageId) -} - -fun updateAddressTest(wsClient: WsClient, patch: Patch) { - val uuidToModify = patch.getFixtures().keys.first() - - val prev = patch.getFixtures()[uuidToModify]!! - - val jsonToSend = - """{ - "event": "updateFixtures", - "data": [ - { - "uuid": "$uuidToModify", - "address": 432 - } - ], - "messageId": 89 - }""".trimIndent() - - wsClient.send(jsonToSend) - - Awaitility.await().untilAsserted { - Assertions.assertEquals(432, patch.getFixtures()[uuidToModify]?.address?.value) - } - - val post = patch.getFixtures()[uuidToModify]!! - - Assertions.assertEquals(prev.fid, post.fid) - Assertions.assertEquals(prev.name, post.name) - Assertions.assertEquals(prev.fixtureTypeId, post.fixtureTypeId) - Assertions.assertEquals(prev.dmxMode, post.dmxMode) - Assertions.assertEquals(prev.universe, post.universe) -} - -fun updateUniverseTest(wsClient: WsClient, patch: Patch) { - val uuidToModify = patch.getFixtures().keys.first() - - val prev = patch.getFixtures()[uuidToModify]!! - - val jsonToSend = - """{ - "event": "updateFixtures", - "data": [ - { - "uuid": "$uuidToModify", - "universe": -1 - } - ], - "messageId": 89 - }""".trimIndent() - - wsClient.send(jsonToSend) - - Awaitility.await().untilAsserted { - Assertions.assertEquals(null, patch.getFixtures()[uuidToModify]?.universe?.value) - } - - val post = patch.getFixtures()[uuidToModify]!! - - Assertions.assertEquals(prev.fid, post.fid) - Assertions.assertEquals(prev.name, post.name) - Assertions.assertEquals(prev.fixtureTypeId, post.fixtureTypeId) - Assertions.assertEquals(prev.dmxMode, post.dmxMode) - Assertions.assertEquals(prev.address?.value, post.address?.value) -} - -fun updateNameAndFidTest(wsClient: WsClient, patch: Patch) { - val uuidToModify = patch.getFixtures().keys.first() - - val prev = patch.getFixtures()[uuidToModify]!! - - val jsonToSend = - """{ - "event": "updateFixtures", - "data":[ - { - "uuid": "$uuidToModify", - "name": "newName", - "fid": 523 - } - ], - "messageId": 89 - }""".trimIndent() - - wsClient.send(jsonToSend) - - Awaitility.await().untilAsserted { - Assertions.assertEquals(523, patch.getFixtures()[uuidToModify]?.fid) - } - - Awaitility.await().untilAsserted { - Assertions.assertEquals("newName", patch.getFixtures()[uuidToModify]?.name) - } - - val post = patch.getFixtures()[uuidToModify]!! - - Assertions.assertEquals(prev.fixtureTypeId, post.fixtureTypeId) - Assertions.assertEquals(prev.dmxMode, post.dmxMode) - Assertions.assertEquals(prev.address?.value, post.address?.value) - Assertions.assertEquals(prev.universe, post.universe) -} diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/json/AddFixtureIntegrationTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/json/AddFixtureIntegrationTest.kt new file mode 100644 index 0000000..96c9bc6 --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/json/AddFixtureIntegrationTest.kt @@ -0,0 +1,130 @@ +package org.cueglow.server.json + +import org.awaitility.Awaitility.await +import org.cueglow.server.objects.messages.GlowEvent +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.test_utilities.ClientAndServerTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AddFixtureIntegrationTest: ClientAndServerTest() { + + val addFixtureJsonMessage = + """{ + "event": "addFixtures", + "data": [ + { + "uuid": "91faaa61-624b-477a-a6c2-de00c717b3e6", + "fid": 1, + "name": "exampleFixture", + "fixtureTypeId": "7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE", + "dmxMode": "mode1", + "universe": 1, + "address": 1 + } + ], + "messageId": 42 + }""".trimIndent() + + @Test + fun addFixture() { + setupExampleFixtureType() + + wsClient.send(addFixtureJsonMessage) + + await().untilAsserted { assertEquals(1, patch.getFixtures().size) } + + assertTrue(examplePatchFixture.isSimilar(patch.getFixtures().asSequence().first().value)) + } + + @Test + fun addFixtureDuplicateUuid() { + setupExampleFixture() + + wsClient.send(addFixtureJsonMessage) + + val receivedString = wsClient.receiveOneMessageBlocking() + val message = GlowMessage.fromJsonString(receivedString) + + assertEquals(GlowEvent.ERROR, message.event) + assertEquals("FixtureUuidAlreadyExistsError", (message as GlowMessage.Error).data.name) + assertEquals(42, message.messageId) + + assertEquals(1, patch.getFixtures().size) + } + + @Test + fun addFixtureInvalidFixtureTypeId() { + setupExampleFixtureType() + + val initialFixtureCount = patch.getFixtures().size + + // fixtureTypeId has "3be" at end instead of "4be" + val jsonToSend = + """{ + "event": "addFixtures", + "data": [ + { + "uuid": "d81644b3-43e0-4b6d-8df4-e1e8dffaddeb", + "fid": 2, + "name": "exampleFixture2", + "fixtureTypeId": "7FB33577-09C9-4BF0-BE3B-EF0DC3BEF3BE", + "dmxMode": "mode1", + "universe": 1, + "address": 100 + } + ], + "messageId": 42 + }""".trimIndent() + + wsClient.send(jsonToSend) + + val received = wsClient.receiveOneMessageBlocking() + val message = GlowMessage.fromJsonString(received) + + assertEquals(GlowEvent.ERROR, message.event) + assertEquals("UnpatchedFixtureTypeIdError", (message as GlowMessage.Error).data.name) + assertEquals(42, message.messageId) + + assertEquals(initialFixtureCount, patch.getFixtures().size) + } + + @Test + fun addFixtureInvalidDmxMode() { + setupExampleFixtureType() + + val initialFixtureCount = patch.getFixtures().size + + // changed dmx mode + val jsonToSend = + """{ + "event": "addFixtures", + "data": [ + { + "uuid": "d81644b3-43e0-4b6d-8df4-e1e8dffaddeb", + "fid": 2, + "name": "exampleFixture2", + "fixtureTypeId": "7FB33577-09C9-4BF0-BE3B-EF0DC3BEF4BE", + "dmxMode": "not_a_mode", + "universe": 1, + "address": 100 + } + ], + "messageId": 42 + }""".trimIndent() + + wsClient.send(jsonToSend) + + val received = wsClient.receiveOneMessageBlocking() + val message = GlowMessage.fromJsonString(received) + + assertEquals(GlowEvent.ERROR, message.event) + assertEquals("UnknownDmxModeError", (message as GlowMessage.Error).data.name) + assertEquals(42, message.messageId) + + assertEquals(initialFixtureCount, patch.getFixtures().size) + } +} + + diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/json/JsonSubscriptionHandlerTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/json/JsonSubscriptionHandlerTest.kt new file mode 100644 index 0000000..b312029 --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/json/JsonSubscriptionHandlerTest.kt @@ -0,0 +1,146 @@ +package org.cueglow.server.json + +import org.apache.logging.log4j.kotlin.Logging +import org.cueglow.server.StateProvider +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.objects.messages.GlowPatch +import org.cueglow.server.objects.messages.GlowTopic +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.* +import java.util.concurrent.LinkedBlockingQueue + +class JsonSubscriptionHandlerTest { + private val outEventQueue = LinkedBlockingQueue() // will fill up and it'll be ignored + private val state = StateProvider(outEventQueue) + + private val client = TestClient() + private val subscriptionHandler = JsonSubscriptionHandler() + + private val testMessage = GlowMessage.RemoveFixtures(listOf()) + + private val expectedInitialState = GlowMessage.PatchInitialState(GlowPatch(listOf(), listOf())) + + @Test + fun singleSubscriberLifecycle() { + subscriptionHandler.receive(testMessage) + // without subscription, the message should not arrive + assertEquals(0, client.messages.size) + + subscriptionHandler.subscribe(client, GlowTopic.PATCH, state) + + // client should get initial state + assertEquals(1, client.messages.size) + assertEquals(expectedInitialState.toJsonString(), client.messages.remove()) + + // SubscriptionHandler should have put a sync message into the outEventQueue + assertEquals(1, outEventQueue.size) + val syncMessage = outEventQueue.remove() as GlowMessage.Sync + + // updates should not get delivered until sync is delivered to the SubscriptionHandler + subscriptionHandler.receive(testMessage) + assertEquals(0, client.messages.size) + + // when a foreign sync message arrives, updates are still not delivered + subscriptionHandler.receive(GlowMessage.Sync(UUID.randomUUID())) + subscriptionHandler.receive(testMessage) + assertEquals(0, client.messages.size) + + // now feed the sync message to SubscriptionHandler to "activate" subscription + subscriptionHandler.receive(syncMessage) + + // updates should now get delivered + subscriptionHandler.receive(testMessage) + // now the client should have gotten the message + assertEquals(1, client.messages.size) + assertEquals(testMessage.toJsonString(), client.messages.remove()) + + // unsubscribe + subscriptionHandler.unsubscribe(client, GlowTopic.PATCH) + + // now the client should not get another message because he unsubscribed + subscriptionHandler.receive(testMessage) + assertEquals(0, client.messages.size) + } + + @Test + fun unsubscribeWithoutTopic() { + subscriptionHandler.subscribe(client, GlowTopic.PATCH, state) + + assertEquals(1, client.messages.size) + assertEquals(expectedInitialState.toJsonString(), client.messages.remove()) + + subscriptionHandler.receive(outEventQueue.remove()) + + // function under test - unsubscribe without specifying topic + subscriptionHandler.unsubscribeFromAllTopics(client) + + subscriptionHandler.receive(testMessage) + assertEquals(0, client.messages.size) + } + + @Test + fun resubscribeBlocksMessagesUntilSync() { + subscriptionHandler.subscribe(client, GlowTopic.PATCH, state) + subscriptionHandler.receive(outEventQueue.remove()) + + // resubscribe - now messages must be blocked until second sync delivery + subscriptionHandler.subscribe(client, GlowTopic.PATCH, state) + + assertEquals(2, client.messages.size) + assertEquals(expectedInitialState.toJsonString(), client.messages.remove()) + assertEquals(expectedInitialState.toJsonString(), client.messages.remove()) + + // client should not get messages yet + subscriptionHandler.receive(testMessage) + assertEquals(0, client.messages.size) + + // feed sync message + subscriptionHandler.receive(outEventQueue.remove()) + + // now client should get message + subscriptionHandler.receive(testMessage) + assertEquals(1, client.messages.size) + assertEquals(testMessage.toJsonString(), client.messages.remove()) + } + + @Test + fun unsubscribeAllBeforeSync() { + subscriptionHandler.subscribe(client, GlowTopic.PATCH, state) + assertEquals(expectedInitialState.toJsonString(), client.messages.remove()) + subscriptionHandler.unsubscribeFromAllTopics(client) + // sync + subscriptionHandler.receive(outEventQueue.remove()) + + // client should not get updates + subscriptionHandler.receive(testMessage) + assertEquals(0, client.messages.size) + } + + @Test + fun unsubscribeBeforeSync() { + subscriptionHandler.subscribe(client, GlowTopic.PATCH, state) + assertEquals(expectedInitialState.toJsonString(), client.messages.remove()) + subscriptionHandler.unsubscribe(client, GlowTopic.PATCH) + // sync + subscriptionHandler.receive(outEventQueue.remove()) + + // client should not get updates + subscriptionHandler.receive(testMessage) + assertEquals(0, client.messages.size) + } +} + + +class TestClient: AsyncClient, Logging { + val messages = LinkedBlockingQueue() + + override fun send(message: GlowMessage) { + send(message.toJsonString()) + } + + override fun send(message: String) { + logger.info("Client is instructed to send: $message") + messages.add(message) + } +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/json/RemoveFixtureIntegrationTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/json/RemoveFixtureIntegrationTest.kt new file mode 100644 index 0000000..adcad9c --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/json/RemoveFixtureIntegrationTest.kt @@ -0,0 +1,62 @@ +package org.cueglow.server.json + +import org.awaitility.Awaitility +import org.cueglow.server.objects.messages.GlowEvent +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.test_utilities.ClientAndServerTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import java.util.* + +class RemoveFixtureIntegrationTest: ClientAndServerTest() { + @Test + fun removeInvalidFixture() { + setupExampleFixture() + + val uuid1 = UUID.fromString("753eca6f-f5a8-4e0d-8907-16074534e08f") + val uuid2 = UUID.fromString("9cf0450d-cef1-47e6-b467-631c4cfe45e7") + + val jsonToSend = + """{ + "event": "removeFixtures", + "data": ["$uuid1", "$uuid2"], + "messageId": 729 + }""".trimIndent() + + wsClient.send(jsonToSend) + + val msg1 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) + val msg2 = GlowMessage.fromJsonString(wsClient.receiveOneMessageBlocking()) + + // still one fixture in patch + Assertions.assertEquals(1, patch.getFixtures().size) + + Assertions.assertEquals(GlowEvent.ERROR, msg1.event) + Assertions.assertEquals("UnknownFixtureUuidError", (msg1 as GlowMessage.Error).data.name) + Assertions.assertTrue(msg1.data.description.contains(uuid1.toString())) + + Assertions.assertEquals(GlowEvent.ERROR, msg2.event) + Assertions.assertEquals("UnknownFixtureUuidError", (msg2 as GlowMessage.Error).data.name) + Assertions.assertTrue(msg2.data.description.contains(uuid2.toString())) + } + + @Test + fun removeFixture() { + setupExampleFixture() + + val uuidToModify = patch.getFixtures().keys.first() + + val jsonToSend = + """{ + "event": "removeFixtures", + "data": ["$uuidToModify"], + "messageId": 730 + }""".trimIndent() + + wsClient.send(jsonToSend) + + Awaitility.await().untilAsserted { + Assertions.assertEquals(0, patch.getFixtures().size) + } + } +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/json/UpdateFixtureIntegrationTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/json/UpdateFixtureIntegrationTest.kt new file mode 100644 index 0000000..c350309 --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/json/UpdateFixtureIntegrationTest.kt @@ -0,0 +1,149 @@ +package org.cueglow.server.json + +import org.awaitility.Awaitility +import org.cueglow.server.objects.messages.GlowEvent +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.test_utilities.ClientAndServerTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import java.util.* + +class UpdateFixtureIntegrationTest: ClientAndServerTest() { + @Test + fun updateUnknownFixture() { + setupExampleFixture() + + // random UUID + val uuidToModify = UUID.fromString("cb9bd39b-303c-4105-8e14-1c5919c05750") + + val jsonToSend = + """{ + "event": "updateFixtures", + "data": [ + { + "uuid": "$uuidToModify", + "address": 432 + } + ], + "messageId": 90 + }""".trimIndent() + + wsClient.send(jsonToSend) + + val received = wsClient.receiveOneMessageBlocking() + val message = GlowMessage.fromJsonString(received) + + Assertions.assertEquals(GlowEvent.ERROR, message.event) + Assertions.assertEquals("UnknownFixtureUuidError", (message as GlowMessage.Error).data.name) + Assertions.assertEquals(90, message.messageId) + } + + @Test + fun updateAddress() { + setupExampleFixture() + + val uuidToModify = patch.getFixtures().keys.first() + + val prev = patch.getFixtures()[uuidToModify]!! + + val jsonToSend = + """{ + "event": "updateFixtures", + "data": [ + { + "uuid": "$uuidToModify", + "address": 432 + } + ], + "messageId": 89 + }""".trimIndent() + + wsClient.send(jsonToSend) + + Awaitility.await().untilAsserted { + Assertions.assertEquals(432, patch.getFixtures()[uuidToModify]?.address?.value) + } + + val post = patch.getFixtures()[uuidToModify]!! + + Assertions.assertEquals(prev.fid, post.fid) + Assertions.assertEquals(prev.name, post.name) + Assertions.assertEquals(prev.fixtureTypeId, post.fixtureTypeId) + Assertions.assertEquals(prev.dmxMode, post.dmxMode) + Assertions.assertEquals(prev.universe, post.universe) + } + + @Test + fun updateUniverse() { + setupExampleFixture() + + val uuidToModify = patch.getFixtures().keys.first() + + val prev = patch.getFixtures()[uuidToModify]!! + + val jsonToSend = + """{ + "event": "updateFixtures", + "data": [ + { + "uuid": "$uuidToModify", + "universe": -1 + } + ], + "messageId": 89 + }""".trimIndent() + + wsClient.send(jsonToSend) + + Awaitility.await().untilAsserted { + Assertions.assertEquals(null, patch.getFixtures()[uuidToModify]?.universe?.value) + } + + val post = patch.getFixtures()[uuidToModify]!! + + Assertions.assertEquals(prev.fid, post.fid) + Assertions.assertEquals(prev.name, post.name) + Assertions.assertEquals(prev.fixtureTypeId, post.fixtureTypeId) + Assertions.assertEquals(prev.dmxMode, post.dmxMode) + Assertions.assertEquals(prev.address?.value, post.address?.value) + } + + @Test + fun updateNameAndFid() { + setupExampleFixture() + + val uuidToModify = patch.getFixtures().keys.first() + + val prev = patch.getFixtures()[uuidToModify]!! + + val jsonToSend = + """{ + "event": "updateFixtures", + "data":[ + { + "uuid": "$uuidToModify", + "name": "newName", + "fid": 523 + } + ], + "messageId": 89 + }""".trimIndent() + + wsClient.send(jsonToSend) + + Awaitility.await().untilAsserted { + Assertions.assertEquals(523, patch.getFixtures()[uuidToModify]?.fid) + } + + Awaitility.await().untilAsserted { + Assertions.assertEquals("newName", patch.getFixtures()[uuidToModify]?.name) + } + + val post = patch.getFixtures()[uuidToModify]!! + + Assertions.assertEquals(prev.fixtureTypeId, post.fixtureTypeId) + Assertions.assertEquals(prev.dmxMode, post.dmxMode) + Assertions.assertEquals(prev.address?.value, post.address?.value) + Assertions.assertEquals(prev.universe, post.universe) + } +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/objects/messages/GlowMessageTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/objects/messages/GlowMessageTest.kt index 999435c..1452183 100644 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/objects/messages/GlowMessageTest.kt +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/objects/messages/GlowMessageTest.kt @@ -3,13 +3,12 @@ package org.cueglow.server.objects.messages import com.github.michaelbull.result.Ok import com.github.michaelbull.result.unwrap import com.karumi.kotlinsnapshot.matchWithSnapshot -import org.cueglow.server.gdtf.fixtureTypeFromGdtfResource import org.cueglow.server.json.fromJsonString import org.cueglow.server.json.toJsonString -import org.cueglow.server.objects.ArtNetAddress import org.cueglow.server.objects.DmxAddress import org.cueglow.server.patch.PatchFixture import org.cueglow.server.patch.PatchFixtureUpdate +import org.cueglow.server.test_utilities.ExampleFixtureType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test @@ -38,11 +37,11 @@ class GlowMessageTest { } @Test - fun testPatchSubscribe() { - val glowMessage = GlowMessage.PatchSubscribe() + fun testSubscribe() { + val glowMessage = GlowMessage.Subscribe(GlowTopic.PATCH) val expectedJson = - """{"event" : "patchSubscribe"}""".trimIndent() + """{"event" : "subscribe", "data" : "patch"}""".trimIndent() assertEquals(expectedJson, glowMessage.toJsonString()) @@ -53,11 +52,11 @@ class GlowMessageTest { } @Test - fun testPatchUnsubscribe() { - val glowMessage = GlowMessage.PatchUnsubscribe() + fun testUnsubscribe() { + val glowMessage = GlowMessage.Unsubscribe(GlowTopic.PATCH) val expectedJson = - """{"event" : "patchUnsubscribe"}""".trimIndent() + """{"event" : "unsubscribe", "data" : "patch"}""".trimIndent() assertEquals(expectedJson, glowMessage.toJsonString()) @@ -108,18 +107,9 @@ class GlowMessageTest { assertFalse(serialized.contains("universe")) } - private val exampleFixtureType = - fixtureTypeFromGdtfResource("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf", this.javaClass) - - private val examplePatchFixture = PatchFixture( - UUID.fromString("91faaa61-624b-477a-a6c2-de00c717b3e6"), - 1, - "exampleFixture", - exampleFixtureType.fixtureTypeId, - "mode1", - ArtNetAddress.tryFrom(1).unwrap(), - DmxAddress.tryFrom(1).unwrap(), - ) + private val exampleFixtureType = ExampleFixtureType.esprite + + private val examplePatchFixture = ExampleFixtureType.esprite_fixture @Test fun addFixtureTypesSnapshotTest() { diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchOutEventQueueTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchOutEventQueueTest.kt new file mode 100644 index 0000000..5d40c34 --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchOutEventQueueTest.kt @@ -0,0 +1,132 @@ +package org.cueglow.server.patch + +import com.github.michaelbull.result.Err +import org.cueglow.server.StateProvider +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.test_utilities.ExampleFixtureType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +class PatchOutEventQueueTest { + private val queue = LinkedBlockingQueue() + private val state = StateProvider(queue) + private val patch = state.patch + private val exampleFixtureType = ExampleFixtureType.esprite + private val examplePatchFixture = ExampleFixtureType.esprite_fixture + + private fun BlockingQueue.pollTimeout(): T = this.poll(1, TimeUnit.SECONDS) ?: throw Error("No Message in Queue until Timeout") + + @Test + fun addingFixtureTypeCreatesOutEvent() { + // add the exampleFixtureType twice + // first time should work and create an OutEvent + patch.addFixtureTypes(listOf(exampleFixtureType)) + // the second time should error and not create an OutEvent + assertTrue(patch.addFixtureTypes(listOf(exampleFixtureType)) is Err) + + val expected = GlowMessage.AddFixtureTypes(listOf(exampleFixtureType)) + + val actual = queue.pollTimeout() as GlowMessage.AddFixtureTypes + + assertEquals(expected.event, actual.event) + assertEquals(1, actual.data.size) + assertEquals(expected.data[0], actual.data[0]) + assertEquals(expected.messageId, actual.messageId) + + // when we added the fixture type the second time, it should not have created an OutEvent and so now the + // Queue should be empty + assertEquals(null, queue.peek()) + } + + @Test + fun addingFixtureTypeTwiceOnlyReportsItOnce() { + // add the exampleFixtureType twice + // first time should work + // the second time should error + assertTrue(patch.addFixtureTypes(listOf(exampleFixtureType, exampleFixtureType)) is Err) + + val expected = GlowMessage.AddFixtureTypes(listOf(exampleFixtureType)) + val actual = queue.pollTimeout() as GlowMessage.AddFixtureTypes + + assertEquals(expected.event, actual.event) + assertEquals(1, actual.data.size) + assertEquals(expected.data[0], actual.data[0]) + assertEquals(expected.messageId, actual.messageId) + + // now the Queue should be empty + assertEquals(null, queue.peek()) + } + + @Test + fun removingFixtureTypeCreatesOutEvent() { + patch.addFixtureTypes(listOf(exampleFixtureType)) + + assertTrue(queue.pollTimeout() is GlowMessage.AddFixtureTypes) + + patch.removeFixtureTypes(listOf(exampleFixtureType.fixtureTypeId)) + + val received = queue.pollTimeout() as GlowMessage.RemoveFixtureTypes + + assertEquals(1, received.data.size) + assertEquals(exampleFixtureType.fixtureTypeId, received.data[0]) + assertEquals(null, received.messageId) + + assertEquals(null, queue.peek()) + } + + @Test + fun fixtureLifecycleCreatesProperOutEvents() { + // add fixture type + patch.addFixtureTypes(listOf(exampleFixtureType)) + assertTrue(queue.pollTimeout() is GlowMessage.AddFixtureTypes) + + // add fixture + patch.addFixtures(listOf(examplePatchFixture)) + val addMessage = queue.pollTimeout() as GlowMessage.AddFixtures + assertEquals(1, addMessage.data.size) + assertTrue(examplePatchFixture.isSimilar(addMessage.data[0])) + + // update fixture + val update = PatchFixtureUpdate(examplePatchFixture.uuid, name = "new name") + patch.updateFixtures(listOf(update)) + val updateMessage = queue.pollTimeout() as GlowMessage.UpdateFixtures + assertEquals(1, updateMessage.data.size) + assertEquals(update, updateMessage.data[0]) + + // remove fixture + patch.removeFixtures(listOf(examplePatchFixture.uuid)) + val removeMessage = queue.pollTimeout() as GlowMessage.RemoveFixtures + assertEquals(1, removeMessage.data.size) + assertEquals(examplePatchFixture.uuid, removeMessage.data[0]) + + // queue should be empty + assertEquals(null, queue.peek()) + } + + @Test + fun removingFixtureViaFixtureTypeSendsOutEvent() { + patch.addFixtureTypes(listOf(exampleFixtureType)) + assertTrue(queue.pollTimeout() is GlowMessage.AddFixtureTypes) + + patch.addFixtures(listOf(examplePatchFixture)) + assertTrue(queue.pollTimeout() is GlowMessage.AddFixtures) + + patch.removeFixtureTypes(listOf(exampleFixtureType.fixtureTypeId)) + val removeFixturesMessage = queue.pollTimeout() as GlowMessage.RemoveFixtures + + assertEquals(1, removeFixturesMessage.data.size) + assertEquals(examplePatchFixture.uuid, removeFixturesMessage.data[0]) + + val removeFixtureTypeMessage = queue.pollTimeout() as GlowMessage.RemoveFixtureTypes + + assertEquals(1, removeFixtureTypeMessage.data.size) + assertEquals(exampleFixtureType.fixtureTypeId, removeFixtureTypeMessage.data[0]) + + // queue should be empty now + assertEquals(null, queue.peek()) + } +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchSubscriptionTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchSubscriptionTest.kt new file mode 100644 index 0000000..e83f309 --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchSubscriptionTest.kt @@ -0,0 +1,51 @@ +package org.cueglow.server.patch + +import com.github.michaelbull.result.Ok +import org.cueglow.server.json.toJsonString +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.objects.messages.GlowPatch +import org.cueglow.server.test_utilities.ClientAndServerTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PatchSubscriptionTest: ClientAndServerTest() { + val subscriptionMessage = + """{ + "event": "subscribe", + "data": "patch" + }""".trimIndent() + + @Test + fun patchSubscriptionLifecycle() { + // subscribe + wsClient.send(subscriptionMessage) + + // client should receive empty initial state + val expectedInitialStateString = GlowMessage.PatchInitialState(GlowPatch(listOf(), listOf())).toJsonString() + val initialStateString = wsClient.receiveOneMessageBlocking() + assertEquals(expectedInitialStateString, initialStateString) + + // update should be delivered + setupExampleFixtureType() + val expectedUpdateString = GlowMessage.AddFixtureTypes(listOf(exampleFixtureType)).toJsonString() + val updateString = wsClient.receiveOneMessageBlocking() + assertEquals(expectedUpdateString, updateString) + + // unsubscribe + wsClient.send("""{"event":"unsubscribe", "data": "patch"}""") + + // update for this change should not be delivered (checked by re-subscribing and asserting on response) + wsClient.send("""{"event": "removeFixtureTypes", "data": ["${exampleFixtureType.fixtureTypeId}"]}""") + + // subscribe again + wsClient.send(subscriptionMessage) + + // should receive empty state again (and not the update from above) + val emptyStateString = wsClient.receiveOneMessageBlocking() + assertEquals(expectedInitialStateString, emptyStateString) + + // no messages should be left in queue + assertEquals(0, wsClient.receivedMessages.size) + } +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchTest.kt index d02fc5d..cd05375 100644 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchTest.kt +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/patch/PatchTest.kt @@ -2,23 +2,19 @@ package org.cueglow.server.patch import com.github.michaelbull.result.Err import com.github.michaelbull.result.unwrap -import org.cueglow.server.gdtf.GdtfWrapper -import org.cueglow.server.gdtf.parseGdtf import org.cueglow.server.objects.ArtNetAddress import org.cueglow.server.objects.DmxAddress +import org.cueglow.server.objects.messages.GlowMessage +import org.cueglow.server.test_utilities.ExampleFixtureType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import java.io.InputStream import java.util.* +import java.util.concurrent.LinkedBlockingQueue internal class PatchTest { - private val exampleGdtfFileName = "Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf" - private val inputStream: InputStream = javaClass.classLoader.getResourceAsStream(exampleGdtfFileName) ?: - throw Error("inputStream is Null") - private val parsedGdtf = parseGdtf(inputStream).unwrap() - private val exampleFixtureType = GdtfWrapper(parsedGdtf) + private val exampleFixtureType = ExampleFixtureType.esprite private val exampleFixture = PatchFixture(UUID.randomUUID(),1, "", exampleFixtureType.fixtureTypeId, "mode1", ArtNetAddress.tryFrom(1).unwrap(), DmxAddress.tryFrom(1).unwrap()) @@ -28,7 +24,7 @@ internal class PatchTest { @Test fun patchList() { // instantiate - val patch = Patch() + val patch = Patch(LinkedBlockingQueue()) assertTrue(patch.getFixtures().isEmpty()) assertTrue(patch.getFixtureTypes().isEmpty()) @@ -83,7 +79,7 @@ internal class PatchTest { @Test fun getGlowPatchIsImmutable() { - val patch = Patch() + val patch = Patch(LinkedBlockingQueue()) patch.addFixtureTypes(listOf(exampleFixtureType)).unwrap() patch.addFixtures(listOf(exampleFixture)).unwrap() val glowPatch = patch.getGlowPatch() diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ClientAndServerTest.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ClientAndServerTest.kt new file mode 100644 index 0000000..a9233ef --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ClientAndServerTest.kt @@ -0,0 +1,41 @@ +package org.cueglow.server.test_utilities + +import com.github.michaelbull.result.Ok +import org.cueglow.server.CueGlowServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import java.net.URI + +/** + * Provides Setup/Teardown of the Server and Client for each test and utilities. + * + * Classes for such "integration" tests should inherit from this class. + */ +open class ClientAndServerTest { + val server = CueGlowServer() + val patch = server.state.patch + val wsClient = WsClient(URI("ws://localhost:7000/ws")) + + init { + wsClient.connectBlocking() + } + + val exampleFixtureType = ExampleFixtureType.esprite + + val examplePatchFixture = ExampleFixtureType.esprite_fixture + + fun setupExampleFixtureType() { + Assertions.assertTrue(patch.addFixtureTypes(listOf(exampleFixtureType)) is Ok) + } + + fun setupExampleFixture() { + setupExampleFixtureType() + assert(patch.addFixtures(listOf(examplePatchFixture)) is Ok) + } + + @AfterEach + fun teardown() { + wsClient.closeBlocking() + server.stop() + } +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ExampleFixtureType.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ExampleFixtureType.kt new file mode 100644 index 0000000..ccc95a3 --- /dev/null +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/ExampleFixtureType.kt @@ -0,0 +1,41 @@ +package org.cueglow.server.test_utilities + +import com.github.michaelbull.result.unwrap +import org.awaitility.Awaitility +import org.awaitility.pollinterval.FibonacciPollInterval.fibonacci +import org.cueglow.server.gdtf.GdtfWrapper +import org.cueglow.server.gdtf.parseGdtf +import org.cueglow.server.objects.ArtNetAddress +import org.cueglow.server.objects.DmxAddress +import org.cueglow.server.patch.PatchFixture +import java.io.InputStream +import java.time.Duration +import java.util.* + +/** Provides Example [GdtfWrapper] and an example [PatchFixture] for tests. **/ +object ExampleFixtureType { + val esprite = fixtureTypeFromGdtfResource("Robe_Lighting@Robin_Esprite@20112020v1.7.gdtf", this.javaClass) + + val esprite_fixture = PatchFixture( + UUID.fromString("91faaa61-624b-477a-a6c2-de00c717b3e6"), + 1, + "exampleFixture", + esprite.fixtureTypeId, + "mode1", + ArtNetAddress.tryFrom(1).unwrap(), + DmxAddress.tryFrom(1).unwrap(), + ) + + // additional: Global settings for Awaitility + init { + Awaitility.setDefaultPollInterval(fibonacci()) + Awaitility.setDefaultTimeout(Duration.ofSeconds(2)) + } +} + +fun fixtureTypeFromGdtfResource(exampleGdtfFileName: String, cls: Class<*>): GdtfWrapper { + val exampleGdtfInputStream: InputStream = + cls.classLoader.getResourceAsStream(exampleGdtfFileName) ?: throw Error("inputStream is Null") + val parsedExampleGdtf = parseGdtf(exampleGdtfInputStream).unwrap() + return GdtfWrapper(parsedExampleGdtf) +} \ No newline at end of file diff --git a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/WsClient.kt b/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/WsClient.kt similarity index 91% rename from cueglow-server/src/test/kotlin/org/cueglow/server/integration/WsClient.kt rename to cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/WsClient.kt index 7ebf38e..f3833ba 100644 --- a/cueglow-server/src/test/kotlin/org/cueglow/server/integration/WsClient.kt +++ b/cueglow-server/src/test/kotlin/org/cueglow/server/test_utilities/WsClient.kt @@ -1,4 +1,4 @@ -package org.cueglow.server.integration +package org.cueglow.server.test_utilities import org.apache.logging.log4j.kotlin.Logging import org.awaitility.Awaitility @@ -8,7 +8,7 @@ import java.net.URI /*** WebSocket client for Testing */ class WsClient(uri: URI): WebSocketClient(uri), Logging { - private val receivedMessages = ArrayDeque() + val receivedMessages = ArrayDeque() override fun onOpen(handshakedata: ServerHandshake?) { logger.info("WsClient opened") diff --git a/dev-docs/Client-Server API.md b/dev-docs/Client-Server API.md index 06a3221..59d29bd 100644 --- a/dev-docs/Client-Server API.md +++ b/dev-docs/Client-Server API.md @@ -68,9 +68,9 @@ A DMX mode is an object with the following fields: The events that are associated with the `patch` topic are: -- `patchSubscribe` (sent by client) +- `subscribe` (sent by client) - `patchInitialState` (sent by server) -- `patchUnsubscribe` (sent by client) +- `unsubscribe` (sent by client) - `addFixtures` (sent by client/server) - `updateFixtures` (sent by client/server) - `removeFixtures` (sent by client/server) @@ -80,12 +80,14 @@ The events that are associated with the `patch` topic are: ### Subscription Lifecycle -The client sends a `patchSubscribe` to the server: +The client sends a `subscribe` to the server and specifies the topic in the +`data` field: ```json { - "event": "patchSubscribe" + "event": "subscribe", + "data": "patch" } ``` @@ -280,16 +282,18 @@ re-subscribe. ### Unsubscribe -To unsubscribe, the client sends: +To unsubscribe from a topic, the client sends an `unsubscribe` and specifies the +topic in the `data` field: ```json { - "event": "patchUnsubscribe" + "event": "unsubscribe", + "data": "patch" } ``` -If the server receives an unsubscribe from a client that is not subscribed, he -should log the incident. +If the server receives an unsubscribe from a client that is not subscribed to +the specified topic, it should log the incident. ### Client-Driven Events