

//_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
//_/_/
//_/_/  This file is part of PSYCLONE version 2.1.1.260726 (26 July 2026)
//_/_/  
//_/_/  developed by
//_/_/  
//_/_/  Communicative Machines Ltd. 
//_/_/    http://cmlabs.com/
//_/_/  Copyright (c) 2026
//_/_/  
//_/_/  in collaboration with 
//_/_/  
//_/_/  Icelandic Institute for Intelligent Machines ses
//_/_/    Menntavegur 1
//_/_/    101 Reykjavik
//_/_/    ICELAND
//_/_/    http://www.iiim.is/
//_/_/
//_/_/  PSYCLONE 2.1 is released under the GNU Lesser General Public License 
//_/_/  (LGPL) version 3.0, and is subject to the following
//_/_/  
//_/_/  - CADIA Clause: The license granted in and to the software under this 
//_/_/    agreement is a limited-use license. The software may not be used in 
//_/_/    furtherance of: (i) intentionally causing bodily injury or severe 
//_/_/    emotional harm to any person; (ii) invading the personal privacy or 
//_/_/    violating the human rights of any person; or (iii) committing or 
//_/_/    preparing for any act of war. 
//_/_/    
//_/_/  A commercial license for PSYCLONE 2.1 is also available to 
//_/_/  any interested party from the copyright holder.
//_/_/  
//_/_/  You can redistribute this copy of PSYCLONE 2.1 and/or modify it under 
//_/_/  the terms of the GNU General Public License as published by the Free 
//_/_/  Software Foundation, either version 3 of the License, or (at your 
//_/_/  option) any later version.
//_/_/  
//_/_/  You should have received a copy of the GNU General Public License
//_/_/  and the GNU Lesser General Public License along with PSYCLONE 2.1.  
//_/_/  If not, see <http://www.gnu.org/licenses/>.
//_/_/  
//_/_/  PSYCLONE 2.1 is distributed in the hope that it will be useful,
//_/_/  but WITHOUT ANY WARRANTY; without even the implied warranty of
//_/_/  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//_/_/  GNU General Public License for more details.
//_/_/
//_/_/  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
//_/_/  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
//_/_/  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 
//_/_/  PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER 
//_/_/  OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
//_/_/  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 
//_/_/  PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 
//_/_/  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 
//_/_/  LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 
//_/_/  NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 
//_/_/  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
//_/_/
//_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
// ============================================================================
//  Psyclone unit tests (registered into the unified CMSDK unit test framework)
//
//  Run with:
//      Psyclone test=psy_configvars
//      Psyclone test=psy_modulelogging
//  or as part of the full suite: Psyclone test=cmsdk
// ============================================================================

#include "Node.h"
#include "Logging.h"
#include "UnitTestFramework.h"
#include "ExternalExec.h"
#include <set>

namespace cmlabs {

// ----------------------------------------------------------------------------
//  psy_configvars
//  Proves that %variable% substitution in the PsySpec uses the spec's
//  <variable> defaults, and that a 'name=value' argument on the Psyclone
//  command line overrides the spec default (command line wins).
// ----------------------------------------------------------------------------
static bool UnitTest_ConfigVars() {

	const char* spec =
		"<psyspec name=\"VarTest\" port=\"10000\">\n"
		"	<variable name=\"cfgvartest_over\" value=\"specvalue\"/>\n"
		"	<variable name=\"cfgvartest_keep\" value=\"keepvalue\"/>\n"
		"	<module name=\"Mod_%cfgvartest_over%_%cfgvartest_keep%\"/>\n"
		"</psyspec>";

	unittest::progress(10, "injecting command line override");
	// Simulate 'psyclone ... cfgvartest_over=cmdvalue' on the command line
	char a0[] = "psyclone";
	char a1[] = "cfgvartest_over=cmdvalue";
	char* argv[] = { a0, a1 };
	utils::SetCommandLine(2, argv);

	unittest::progress(30, "parsing spec");
	XMLResults xmlResults;
	XMLNode mainNode = XMLNode::parseString(spec, "psyspec", &xmlResults);
	if ((xmlResults.error != eXMLErrorNone) || mainNode.isEmpty()) {
		unittest::fail("could not parse test PsySpec");
		return false;
	}

	unittest::progress(50, "expanding variables");
	std::string xml;
	if (!Node::ExpandConfigVariables(mainNode, spec, xml)) {
		unittest::fail("ExpandConfigVariables returned false");
		return false;
	}
	unittest::detail("expanded spec:\n%s", xml.c_str());

	unittest::progress(80, "checking substitutions");
	if (xml.find("Mod_cmdvalue_keepvalue") == std::string::npos) {
		unittest::fail("command line override 'cfgvartest_over=cmdvalue' was not applied: %s", xml.c_str());
		return false;
	}
	if (xml.find("Mod_specvalue") != std::string::npos) {
		unittest::fail("spec default was used despite command line override");
		return false;
	}
	if (xml.find("%cfgvartest_over%") != std::string::npos || xml.find("%cfgvartest_keep%") != std::string::npos) {
		unittest::fail("unsubstituted %%variable%% left in expanded spec");
		return false;
	}
	unittest::progress(100, "done");
	return true;
}

// ----------------------------------------------------------------------------
//  psy_modulelogging
//  Proves that a per-module verbose level and a per-module logfile take
//  effect for that module only: entries above the module's level are dropped,
//  the module's entries go to its own logfile, and other modules' entries are
//  unaffected (and stay out of the module's file).
// ----------------------------------------------------------------------------
static LogEntry* makeTestLogEntry(uint32 source, uint8 level, uint8 type, const char* text) {
	uint32 len = (uint32)strlen(text);
	LogEntry* entry = (LogEntry*)malloc(sizeof(LogEntry) + len + 1);
	entry->size = sizeof(LogEntry) + len + 1;
	entry->cid = LOGENTRYID;
	entry->time = GetTimeNow();
	entry->source = source;
	entry->subject = LOG_COMPONENT;
	entry->level = level;
	entry->type = type;
	entry->setText((char*)text, len);
	return entry;
}

static bool UnitTest_ModuleLogging() {

	const char* modLogPath = "/tmp/psytest_modlog_42.log";
	unlink(modLogPath);

	unittest::progress(10, "setting up logging");
	Logging* logging = new Logging();
	if (!logging->init(NULL)) {
		unittest::fail("could not init Logging");
		return false;
	}

	// Module 42: verbose level 1 + its own logfile. Module 43: untouched.
	if (!logging->setModuleLogging(42, "ModA", 1, -1, modLogPath)) {
		unittest::fail("setModuleLogging failed");
		return false;
	}

	unittest::progress(35, "sending log entries");
	logging->logEntry(makeTestLogEntry(42, 0, LOGPRINT, "m42-keep-lvl0"));
	logging->logEntry(makeTestLogEntry(42, 1, LOGPRINT, "m42-keep-lvl1"));
	logging->logEntry(makeTestLogEntry(42, 2, LOGPRINT, "m42-DROP-lvl2"));   // above module verbose level
	logging->logEntry(makeTestLogEntry(43, 2, LOGPRINT, "m43-other-lvl2"));  // other module, unaffected

	unittest::progress(60, "checking per-module logfile");
	uint32 len = 0;
	char* fileData = utils::ReadAFile(modLogPath, len);
	if (!fileData || !len) {
		unittest::fail("module logfile '%s' was not written", modLogPath);
		delete(logging);
		return false;
	}
	std::string fileStr(fileData, len);
	delete [] fileData;
	unittest::detail("module logfile contents:\n%s", fileStr.c_str());

	bool ok = true;
	if (fileStr.find("m42-keep-lvl0") == std::string::npos) { unittest::fail("level 0 entry missing from module logfile"); ok = false; }
	else if (fileStr.find("m42-keep-lvl1") == std::string::npos) { unittest::fail("level 1 entry missing from module logfile"); ok = false; }
	else if (fileStr.find("m42-DROP-lvl2") != std::string::npos) { unittest::fail("entry above module verbose level was not dropped"); ok = false; }
	else if (fileStr.find("m43-other") != std::string::npos) { unittest::fail("other module's entry leaked into module logfile"); ok = false; }

	unittest::progress(80, "checking other module unaffected");
	if (ok) {
		// Module 43's high-level entry must still be in the central log storage
		std::list<LogEntry*> entries = logging->getLogEntries(43, 0, 10, 0, 0);
		if (entries.empty()) {
			unittest::fail("other module's entry was wrongly dropped from central logging");
			ok = false;
		}
		std::list<LogEntry*>::iterator it, itEnd;
		for (it = entries.begin(), itEnd = entries.end(); it != itEnd; ++it)
			free(*it);
		// And module 42's dropped entry must NOT be in central storage
		entries = logging->getLogEntries(42, 0, 10, 0, 0);
		uint32 dummy;
		for (it = entries.begin(), itEnd = entries.end(); it != itEnd; ++it) {
			if (ok && (*it)->getText(dummy) && strstr((*it)->getText(dummy), "m42-DROP-lvl2")) {
				unittest::fail("dropped entry found in central log storage");
				ok = false;
			}
			free(*it);
		}
	}

	delete(logging);
	unlink(modLogPath);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  Registration (called from Psyclone.cpp before test routing)
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
//  psy_extexec_*  (T1.4 external-executable auto-start / lifecycle)
//  In-process, cross-platform ports of the former test/t14 bash suite. Each
//  test boots a real Node from an inline spec, lets it launch a real external
//  child (Examples/bin/<arch>/PsyTestChild), and asserts via the
//  ExternalExecManager state accessors and (for output routing) logfile readback.
// ----------------------------------------------------------------------------

// Locate the PsyTestChild helper executable built by Examples/. Tries an env
// override then a set of candidate paths relative to common run dirs.
static std::string FindTestChild() {
	const char* ov = getenv("PSYTESTCHILD");
	if (ov && utils::GetFileDetails(ov).doesExist)
		return ov;
#if defined(WINDOWS)
	const char* archs[] = { "win64", "win32", NULL };
	const char* exe = "PsyTestChild.exe";
#elif defined(__APPLE__)
	const char* archs[] = { "macosx64", "macosx32", NULL };
	const char* exe = "PsyTestChild";
#else
	const char* archs[] = { "linux64", "linux32", NULL };
	const char* exe = "PsyTestChild";
#endif
	const char* bases[] = {
		"Examples/bin", "../Examples/bin", "../../Examples/bin",
		"./bin", "../bin", NULL
	};
	for (int b = 0; bases[b]; b++) {
		for (int a = 0; archs[a]; a++) {
			std::string p = utils::StringFormat("%s/%s/%s", bases[b], archs[a], exe);
			if (utils::GetFileDetails(p.c_str()).doesExist) {
				// Return an ABSOLUTE path: the child is launched with a workdir set, so a
				// relative exec path would resolve against the wrong directory.
				if (p.size() && p[0] != '/' && !(p.size() > 1 && p[1] == ':')) {
					std::string cwd = utils::GetCurrentDir();
					if (cwd.size())
						p = cwd + "/" + p;
				}
				return p;
			}
		}
	}
	return "";
}

// Boot a node from an inline spec on the given port; caller polls then calls
// EndTestNode(). Returns NULL on failure.
static cmlabs::Node* StartTestNode(const char* spec, uint16 port) {
	using namespace cmlabs;
	Node* node = new Node("/tmp", "PsyTestChild-host");
	node->setConfigFile("/tmp/psytest_extexec.xml");	// gives %specdir%=/tmp
	if (!node->init(port, spec, NULL)) {
		delete(node);
		return NULL;
	}
	return node;
}

static void EndTestNode(cmlabs::Node* node) {
	if (!node) return;
	node->terminate();
	delete(node);
	// Give the OS a moment to reap any just-killed children before the next test.
	utils::Sleep(300);
}

// Poll externalExec->getState(space) until it reaches one of the wanted states
// (bitmask by (1<<state)) or timeout. Returns the final state (-1 if unknown).
static int WaitForState(cmlabs::Node* node, const char* space, uint32 wantedMask, uint32 timeoutMS) {
	uint64 start = GetTimeNow();
	int state = -1;
	while (GetTimeAgeMS(start) < (int64)timeoutMS) {
		state = node->getExternalExec() ? node->getExternalExec()->getState(space) : -1;
		if (state >= 0 && (wantedMask & (1u << state)))
			return state;
		utils::Sleep(50);
	}
	return state;
}

// --- Spec builders ------------------------------------------------------------
// A basic external-space + module with an <executable> pointing at the child.
static std::string BuildSpec(uint16 port, const char* child, const char* extraModuleAttrs,
							 const char* execBody, const char* execAttrs) {
	return utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\" %s>\n"
		"    <executable %s>%s port=%%port%% space=Ext1</executable>\n"
		"  </module>\n"
		"</psyspec>",
		port, extraModuleAttrs ? extraModuleAttrs : "", execAttrs ? execAttrs : "",
		execBody ? execBody : child);
}

// ----------------------------------------------------------------------------
//  psy_extexec_basic : auto-launch, space becomes ACTIVE, workdir, env, logfile
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_Basic() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found (build Examples/)"); return false; }

	const char* logpath = "/tmp/psytest_extexec_basic.log";
	utils::DeleteAFile(logpath, true);

	unittest::progress(10, "building spec");
	uint16 port = 10630;
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable output=\"logfile\" logfile=\"%s\" workdir=\"/tmp\">\n"
		"      <env name=\"T14_VAR\" value=\"hello-%%port%%\"/>\n"
		"      %s port=%%port%% space=Ext1\n"
		"    </executable>\n"
		"  </module>\n"
		"</psyspec>", port, logpath, child.c_str());

	unittest::progress(30, "starting node + child");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(55, "waiting for space ACTIVE");
	int st = WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_RUNNING), 15000);
	bool ok = true;
	if (st != EXTEXEC_STATE_RUNNING) { unittest::fail("space did not reach RUNNING (state=%d)", st); ok = false; }

	unittest::progress(75, "checking logfile routing/workdir/env");
	if (ok) {
		// Give the child a moment to emit its startup lines into the logfile.
		utils::Sleep(800);
		std::string log = utils::ReadAFileString(logpath);
		unittest::detail("child logfile:\n%s", log.c_str());
		if (log.find("T14CHILD started") == std::string::npos) { unittest::fail("stdout not routed to logfile"); ok = false; }
		else if (log.find("stderr-check") == std::string::npos) { unittest::fail("stderr not routed to logfile"); ok = false; }
		else if (log.find("cwd=/tmp") == std::string::npos) { unittest::fail("workdir not applied"); ok = false; }
		else if (log.find("T14_VAR=hello-10630") == std::string::npos) { unittest::fail("env var not substituted/applied"); ok = false; }
	}

	unittest::progress(90, "shutdown + orphan check");
	EndTestNode(node);
	utils::DeleteAFile(logpath, true);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  psy_extexec_restart : crash -> autorestart with backoff; psyclonelog routing
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_Restart() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found"); return false; }

	uint16 port = 10631;
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable output=\"psyclonelog\" autorestart=\"yes\" restartdelay=\"1000\">\n"
		"      %s port=%%port%% space=Ext1 mode=crashafter ms=1500\n"
		"    </executable>\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str());

	unittest::progress(20, "starting node");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(45, "waiting for first RUNNING");
	bool ok = (WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_RUNNING), 12000) == EXTEXEC_STATE_RUNNING);
	if (!ok) unittest::fail("child never reached RUNNING before crash");

	unittest::progress(65, "waiting for crash -> WAITRESTART");
	if (ok) {
		int st = WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_WAITRESTART), 8000);
		if (st != EXTEXEC_STATE_WAITRESTART) { unittest::fail("did not enter WAITRESTART after crash (state=%d)", st); ok = false; }
	}

	unittest::progress(85, "waiting for restart back to RUNNING");
	if (ok) {
		int st = WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_RUNNING), 12000);
		if (st != EXTEXEC_STATE_RUNNING) { unittest::fail("did not restart to RUNNING (state=%d)", st); ok = false; }
	}

	EndTestNode(node);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  psy_extexec_limit : restartlimit stops the restart storm (FAILED, no child)
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_Limit() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found"); return false; }

	uint16 port = 10632;
	// Child crashes almost immediately; limit 2 restarts in a big window with a
	// tiny delay + cap so the test resolves fast and must hit FAILED.
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable autorestart=\"yes\" restartdelay=\"200\" maxrestartdelay=\"400\""
		" restartlimit=\"2\" restartwindow=\"60000\" startuptimeout=\"1500\">\n"
		"      %s port=%%port%% space=Ext1 mode=crashafter ms=200\n"
		"    </executable>\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str());

	unittest::progress(25, "starting node");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(60, "waiting for FAILED (gave up at limit)");
	int st = WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_FAILED), 20000);
	bool ok = (st == EXTEXEC_STATE_FAILED);
	if (!ok) unittest::fail("did not reach FAILED after restart limit (state=%d)", st);

	EndTestNode(node);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  psy_extexec_startuptimeout : non-connecting child + autorestart=no => FAILED/STOPPED
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_StartupTimeout() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found"); return false; }

	uint16 port = 10633;
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable autorestart=\"no\" startuptimeout=\"2000\">\n"
		"      %s port=%%port%% space=Ext1 mode=noconnect\n"
		"    </executable>\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str());

	unittest::progress(25, "starting node");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(60, "waiting for STOPPED (timeout, no restart)");
	int st = WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_STOPPED)|(1u<<EXTEXEC_STATE_FAILED), 12000);
	bool ok = (st == EXTEXEC_STATE_STOPPED || st == EXTEXEC_STATE_FAILED);
	if (!ok) unittest::fail("non-connecting child not stopped after startuptimeout (state=%d)", st);

	EndTestNode(node);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  psy_extexec_duplicate : a second <executable> for the same space is ignored
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_Duplicate() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found"); return false; }

	uint16 port = 10634;
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable> %s port=%%port%% space=Ext1 </executable>\n"
		"  </module>\n"
		"  <module name=\"M2\" type=\"external\" space=\"Ext1\">\n"
		"    <executable> %s port=%%port%% space=Ext1 </executable>\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str(), child.c_str());

	unittest::progress(30, "starting node");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(60, "checking only one executable managed");
	utils::Sleep(500);
	bool ok = true;
	if (!node->getExternalExec()) { unittest::fail("no externalExec manager"); ok = false; }
	else if (node->getExternalExec()->count() != 1) {
		unittest::fail("expected 1 managed executable for shared space, got %u", node->getExternalExec()->count());
		ok = false;
	}

	EndTestNode(node);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  psy_extexec_disabled : enabled="no" registers but never launches
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_Disabled() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found"); return false; }

	uint16 port = 10635;
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable enabled=\"no\"> %s port=%%port%% space=Ext1 </executable>\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str());

	unittest::progress(30, "starting node");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(60, "checking registered-but-DISABLED");
	utils::Sleep(600);
	int st = node->getExternalExec() ? node->getExternalExec()->getState("Ext1") : -1;
	bool ok = (st == EXTEXEC_STATE_DISABLED);
	if (!ok) unittest::fail("enabled=no should stay DISABLED, got state=%d", st);

	EndTestNode(node);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  psy_extexec_hang : child drops its space while alive => hang detected+restart
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_Hang() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found"); return false; }

	uint16 port = 10636;
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable autorestart=\"yes\" restartdelay=\"500\">\n"
		"      %s port=%%port%% space=Ext1 mode=hang ms=1500\n"
		"    </executable>\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str());

	unittest::progress(20, "starting node");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(45, "waiting for RUNNING");
	bool ok = (WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_RUNNING), 12000) == EXTEXEC_STATE_RUNNING);
	if (!ok) unittest::fail("child never reached RUNNING");

	unittest::progress(70, "waiting for hang detection (WAITRESTART)");
	if (ok) {
		int st = WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_WAITRESTART)|(1u<<EXTEXEC_STATE_LAUNCHING), 15000);
		if (!(st == EXTEXEC_STATE_WAITRESTART || st == EXTEXEC_STATE_LAUNCHING)) {
			unittest::fail("hang was not detected (state=%d)", st); ok = false;
		}
	}

	EndTestNode(node);
	unittest::progress(100, "done");
	return ok;
}

// ----------------------------------------------------------------------------
//  psy_extexec_messages : output="messages" mode launches and runs (routing path
//  posts <Module>.Stdout/.Stderr; here we assert it doesn't break launch/run).
// ----------------------------------------------------------------------------
static bool UnitTest_ExtExec_Messages() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found"); return false; }

	uint16 port = 10637;
	std::string spec = utils::StringFormat(
		"<psyspec name=\"ExtExecTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"M1\" type=\"external\" space=\"Ext1\">\n"
		"    <executable output=\"messages\"> %s port=%%port%% space=Ext1 </executable>\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str());

	unittest::progress(30, "starting node (messages mode)");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }

	unittest::progress(65, "waiting for RUNNING");
	int st = WaitForState(node, "Ext1", (1u<<EXTEXEC_STATE_RUNNING), 15000);
	bool ok = (st == EXTEXEC_STATE_RUNNING);
	if (!ok) unittest::fail("messages-mode child did not reach RUNNING (state=%d)", st);
	if (ok) utils::Sleep(600);	// let some lines flow through the messages router

	EndTestNode(node);
	unittest::progress(100, "done");
	return ok;
}

// ============================================================================
//  psy_triggergroup  (T1.6)
//  Boots a node per matrix case: an internal TGDriver posts scripted member
//  trigger messages; a <triggergroup> joins them under a specific policy; an
//  internal TGSink consumes the joined sets via waitForNewMessageGroup() and
//  records them into a result file. Each case asserts the number of joins and
//  the membership/count. Case A1 doubles as the G-B1 "wakeups ~= joins" proof.
//  The join records are produced by the host-side TGSink crank (in
//  Examples/src/PsyTestChild.cpp) writing groupName|count|members lines to a
//  result file; this harness reads that file back into local TGJoinRecords.
// ----------------------------------------------------------------------------

// One join recorded by the TGSink crank (parsed from its result file).
struct TGJoinRecord {
	std::string groupName;
	int count;
	std::vector<std::string> members;
	TGJoinRecord() : count(0) {}
};

// Build a one-module driver + one-module group+sink spec.
//   groupAttrs : extra attributes on <triggergroup> (e.g. count="2" dup="keep")
//   memberXML  : the member <trigger .../> lines (each with a distinct type)
//   steps      : TGDriver "Steps" param (see Internal_TGDriver docs)
//   stepDelay  : ms between posts within a step (for within/time/timeout cases)
static std::string TGBuildSpec(uint16 port, const char* groupAttrs,
							   const char* memberXML, const char* steps, int stepDelay,
							   const char* child, const char* resultFile) {
	return utils::StringFormat(
		"<psyspec name=\"TGTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"Ext1Host\" type=\"external\" space=\"Ext1\">\n"
		"    <executable>%s port=%%port%% space=Ext1 mode=run</executable>\n"
		"  </module>\n"
		"  <module name=\"Driver\" space=\"Ext1\">\n"
		"    <trigger name=\"Ready\" type=\"Psyclone.Ready\" />\n"
		"    <parameter name=\"Steps\" type=\"String\" value=\"%s\" />\n"
		"    <parameter name=\"StepDelayMS\" type=\"Integer\" value=\"%d\" />\n"
		"    <crank name=\"TGDriver\" function=\"TGDriver\" />\n"
		"    <post name=\"A\" type=\"tg.a\" tag=\"ev\" />\n"
		"    <post name=\"B\" type=\"tg.b\" tag=\"ev\" />\n"
		"    <post name=\"C\" type=\"tg.c\" tag=\"ev\" />\n"
		"  </module>\n"
		"  <module name=\"Sink\" space=\"Ext1\">\n"
		"    <parameter name=\"ResultFile\" type=\"String\" value=\"%s\" />\n"
		"    <triggergroup name=\"G\" %s>\n"
		"%s"
		"    </triggergroup>\n"
		"    <crank name=\"TGSink\" function=\"TGSink\" />\n"
		"  </module>\n"
		"</psyspec>",
		port, child ? child : "", steps ? steps : "", stepDelay,
		resultFile ? resultFile : "", groupAttrs ? groupAttrs : "",
		memberXML ? memberXML : "");
}

// Members A,B,C on distinct types.
#define TG_MEMBERS_ABC \
	"      <trigger name=\"A\" type=\"tg.a\" />\n" \
	"      <trigger name=\"B\" type=\"tg.b\" />\n" \
	"      <trigger name=\"C\" type=\"tg.c\" />\n"
#define TG_MEMBERS_AB \
	"      <trigger name=\"A\" type=\"tg.a\" />\n" \
	"      <trigger name=\"B\" type=\"tg.b\" />\n"

// Run one case: boot node, let it settle, tear down, return joins recorded by Sink.
static std::vector<TGJoinRecord> TGRunCase(const char* groupAttrs,
		const char* memberXML, const char* steps, int stepDelay, uint16 port,
		uint32 settleMS, const std::string& child) {
	using namespace cmlabs;
	std::vector<TGJoinRecord> out;
	std::string resultFile = utils::StringFormat("/tmp/tg_result_%u.txt", port);
	utils::DeleteAFile(resultFile.c_str(), true);

	std::string spec = TGBuildSpec(port, groupAttrs, memberXML, steps, stepDelay,
		child.c_str(), resultFile.c_str());
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) return out;
	utils::Sleep(settleMS);
	EndTestNode(node);

	std::string data = utils::ReadAFileString(resultFile.c_str());
	std::vector<std::string> lines = utils::TextListSplitLines(data.c_str(), false, true);
	for (size_t li = 0; li < lines.size(); li++) {
		std::vector<std::string> parts = utils::TextListSplit(lines[li].c_str(), "|", true, false);
		if (parts.size() < 2) continue;
		TGJoinRecord rec;
		rec.groupName = parts[0];
		rec.count = (int)utils::Ascii2Int64(parts[1].c_str());
		if (parts.size() >= 3 && parts[2].length())
			rec.members = utils::TextListSplit(parts[2].c_str(), ",", false, true);
		out.push_back(rec);
	}
	utils::DeleteAFile(resultFile.c_str(), true);
	return out;
}

// Assert helper: total join count.
static bool TGExpectJoins(const char* caseName,
						  const std::vector<TGJoinRecord>& joins, int wantJoins) {
	unittest::detail("[%s] joins=%d (want %d)", caseName, (int)joins.size(), wantJoins);
	if ((int)joins.size() != wantJoins) {
		unittest::fail("[%s] expected %d join(s), got %d", caseName, wantJoins, (int)joins.size());
		return false;
	}
	return true;
}

// Shared case row for the split trigger-group tests.
struct TGCase {
	const char* name;
	const char* groupAttrs;
	const char* members;
	const char* steps;
	int stepDelay;
	uint32 settleMS;
	int wantJoins;		// expected number of joined activations
	int wantMembers;	// expected member count in the (first) join, -1 to skip
};

// Run a table of cases against fresh external-space nodes; assert counts.
static bool RunTGCaseTable(TGCase* cases, int n, uint16 startPort, const std::string& child) {
	using namespace cmlabs;
	bool allok = true;
	uint16 port = startPort;
	for (int i = 0; i < n; i++) {
		unittest::progress((int)(((i+1)*95.0)/n), cases[i].name);
		std::vector<TGJoinRecord> joins = TGRunCase(cases[i].groupAttrs, cases[i].members,
			cases[i].steps, cases[i].stepDelay, port++, cases[i].settleMS, child);
		if (!TGExpectJoins(cases[i].name, joins, cases[i].wantJoins)) { allok = false; continue; }
		if (cases[i].wantMembers >= 0 && !joins.empty()) {
			if (joins[0].count != cases[i].wantMembers) {
				unittest::fail("[%s] expected %d members in join, got %d", cases[i].name,
					cases[i].wantMembers, joins[0].count);
				allok = false;
			}
		}
	}
	return allok;
}

// psy_triggergroup_core: completion/count/dup/order/optional/snapshot/empty-tag
// + the duplicate-group-name parse guard. Fast cases (no maxage/timeout waits).
static bool UnitTest_TriggerGroup_Core() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found (build Examples/)"); return false; }
	uint16 port = 10700;

	TGCase cases[] = {
		{ "A1_all_of_3",        "maxage=\"2000\"",                       TG_MEMBERS_ABC, "A,B,C",     0, 900, 1, 3 },
		{ "A2_count_2_of_3",    "maxage=\"2000\" count=\"2\"",           TG_MEMBERS_ABC, "A,B",       0, 900, 1, 2 },
		{ "A3_count_all_kw",    "maxage=\"2000\" count=\"all\"",         TG_MEMBERS_ABC, "A,B,C",     0, 900, 1, 3 },
		{ "C1_dup_latest",      "maxage=\"2000\"",                       TG_MEMBERS_ABC, "A=1,A=2,B,C", 0, 900, 1, 3 },
		{ "E1_optional_absent", "maxage=\"2000\"",
		  "      <trigger name=\"A\" type=\"tg.a\" />\n"
		  "      <trigger name=\"B\" type=\"tg.b\" />\n"
		  "      <trigger name=\"C\" type=\"tg.c\" optional=\"yes\" />\n",
		                                                                  "A,B",       0, 900, 1, 2 },
		{ "G1_snapshot",        "maxage=\"2000\" mode=\"snapshot\"",     TG_MEMBERS_ABC, "A,B,C",     0, 900, 2, -1 },
		{ "H1_empty_tag",       "maxage=\"2000\"",                       TG_MEMBERS_AB,  "A,B",       0, 900, 1, 2 },
	};
	bool allok = RunTGCaseTable(cases, (int)(sizeof(cases)/sizeof(cases[0])), port, child);
	port += (uint16)(sizeof(cases)/sizeof(cases[0]));

	// duplicate group-name (parse-level): the duplicate is discarded, node still boots.
	unittest::progress(97, "dup-group-name parse");
	{
		std::string spec = utils::StringFormat(
			"<psyspec name=\"TGDup\" port=\"%u\">\n"
			"  <space name=\"Ext1\" type=\"external\"/>\n"
			"  <module name=\"Ext1Host\" type=\"external\" space=\"Ext1\">\n"
			"    <executable>%s port=%%port%% space=Ext1 mode=run</executable>\n"
			"  </module>\n"
			"  <module name=\"Sink\" space=\"Ext1\">\n"
			"    <triggergroup name=\"G\" maxage=\"2000\">\n" TG_MEMBERS_AB "    </triggergroup>\n"
			"    <triggergroup name=\"G\" maxage=\"2000\">\n" TG_MEMBERS_AB "    </triggergroup>\n"
			"    <crank name=\"TGSink\" function=\"TGSink\" />\n"
			"  </module>\n"
			"</psyspec>", port++, child.c_str());
		Node* node = StartTestNode(spec.c_str(), port);
		if (!node) { unittest::fail("[dup_group] node with duplicate group name failed to boot"); allok = false; }
		else { unittest::detail("[dup_group] booted OK (duplicate discarded with L1 warn)"); utils::Sleep(700); EndTestNode(node); }
	}
	unittest::progress(100, allok ? "core cases passed" : "some core cases failed");
	return allok;
}

// psy_triggergroup_timing: window/maxage/timeout cases (need real wall-clock waits).
static bool UnitTest_TriggerGroup_Timing() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found (build Examples/)"); return false; }
	uint16 port = 10740;

	TGCase cases[] = {
		{ "A4_incomplete_discard","maxage=\"600\" timeout=\"300\" ontimeout=\"discard\"", TG_MEMBERS_ABC, "A,B", 0, 1300, 0, -1 },
		{ "A5_min_2",           "maxage=\"700\" min=\"2\"",              TG_MEMBERS_ABC, "A,B",       0, 1300, 2, 2 },
		{ "B1_within_ok",       "maxage=\"3000\" within=\"5000\"",       TG_MEMBERS_ABC, "A,B,C",     0, 900, 1, 3 },
		{ "B2_time0",           "maxage=\"2000\" time=\"0\"",            TG_MEMBERS_ABC, "A,B,C",     0, 900, 1, 3 },
		{ "C2_dup_keep",        "maxage=\"2000\" dup=\"keep\"",          TG_MEMBERS_ABC, "A=1,A=2,B,C", 0, 900, 1, 3 },
		{ "D1_order_strict",    "maxage=\"2000\" order=\"strict\"",      TG_MEMBERS_ABC, "A,B,C",     0, 900, 1, 3 },
		{ "F1_timeout_fire",    "maxage=\"3000\" timeout=\"300\" ontimeout=\"fire\"",  TG_MEMBERS_ABC, "A,B", 0, 1300, 1, 2 },
		{ "F2_timeout_discard", "maxage=\"3000\" timeout=\"300\" ontimeout=\"discard\"", TG_MEMBERS_ABC, "A,B", 0, 1300, 0, -1 },
	};
	bool allok = RunTGCaseTable(cases, (int)(sizeof(cases)/sizeof(cases[0])), port, child);
	unittest::progress(100, allok ? "timing cases passed" : "some timing cases failed");
	return allok;
}

// ============================================================================
//  psy_triggerfilter
//  Exercises all 8 <filter> trigger-filter operations end-to-end: a FiltDriver
//  crank posts "ft.v" messages carrying user entry k in {2, 3, 5, abc}; a
//  FiltSink module declares one trigger per filter op and records every firing
//  as "triggerName|k" lines. Asserts exactly the expected (trigger, k) firings
//  — in particular that equalsnumeric fires ONLY on the equal value and
//  notequalsnumeric ONLY on non-equal values (regression for the inverted
//  comparisons fixed in SubscriptionEngine.cpp, 2026-07-26). Non-numeric "abc"
//  parses as 0 for the numeric ops.
// ----------------------------------------------------------------------------

static bool UnitTest_TriggerFilter() {
	using namespace cmlabs;
	std::string child = FindTestChild();
	if (child.empty()) { unittest::fail("PsyTestChild executable not found (build Examples/)"); return false; }
	uint16 port = 10780;
#if defined(WINDOWS)
	const char* tmpdir = getenv("TEMP");
	std::string resultFile = utils::StringFormat("%s\\filt_result_%u.txt",
		tmpdir ? tmpdir : ".", port);
#else
	std::string resultFile = utils::StringFormat("/tmp/filt_result_%u.txt", port);
#endif
	utils::DeleteAFile(resultFile.c_str(), true);

	std::string spec = utils::StringFormat(
		"<psyspec name=\"FiltTest\" port=\"%u\">\n"
		"  <space name=\"Ext1\" type=\"external\"/>\n"
		"  <module name=\"Ext1Host\" type=\"external\" space=\"Ext1\">\n"
		"    <executable>%s port=%%port%% space=Ext1 mode=run</executable>\n"
		"  </module>\n"
		"  <module name=\"Driver\" space=\"Ext1\">\n"
		"    <trigger name=\"Ready\" type=\"Psyclone.Ready\" />\n"
		"    <parameter name=\"Values\" type=\"String\" value=\"2,3,5,abc\" />\n"
		"    <parameter name=\"PostNames\" type=\"String\" value=\"PEQ,PNE,PGT,PLT,PSEQ,PSNE,PHK,PAGE\" />\n"
		"    <parameter name=\"DebugFile\" type=\"String\" value=\"%s.dbg\" />\n"
		"    <crank name=\"FiltDriver\" function=\"FiltDriver\" />\n"
		"    <post name=\"PEQ\" type=\"ft.eq\" />\n"
		"    <post name=\"PNE\" type=\"ft.ne\" />\n"
		"    <post name=\"PGT\" type=\"ft.gt\" />\n"
		"    <post name=\"PLT\" type=\"ft.lt\" />\n"
		"    <post name=\"PSEQ\" type=\"ft.seq\" />\n"
		"    <post name=\"PSNE\" type=\"ft.sne\" />\n"
		"    <post name=\"PHK\" type=\"ft.hk\" />\n"
		"    <post name=\"PAGE\" type=\"ft.age\" />\n"
		"  </module>\n"
		"  <module name=\"Sink\" space=\"Ext1\">\n"
		"    <parameter name=\"ResultFile\" type=\"String\" value=\"%s\" />\n"
		"    <trigger name=\"EQ3\" type=\"ft.eq\"><filter type=\"equalsnumeric\" key=\"k\" value=\"3\"/></trigger>\n"
		"    <trigger name=\"NE3\" type=\"ft.ne\"><filter type=\"notequalsnumeric\" key=\"k\" value=\"3\"/></trigger>\n"
		"    <trigger name=\"GT3\" type=\"ft.gt\"><filter type=\"greaterthan\" key=\"k\" value=\"3\"/></trigger>\n"
		"    <trigger name=\"LT3\" type=\"ft.lt\"><filter type=\"lessthan\" key=\"k\" value=\"3\"/></trigger>\n"
		"    <trigger name=\"SEQ\" type=\"ft.seq\"><filter type=\"equals\" key=\"k\" value=\"abc\"/></trigger>\n"
		"    <trigger name=\"SNE\" type=\"ft.sne\"><filter type=\"notequals\" key=\"k\" value=\"abc\"/></trigger>\n"
		"    <trigger name=\"HK\" type=\"ft.hk\"><filter type=\"haskey\" key=\"k\"/></trigger>\n"
		"    <trigger name=\"AGE\" type=\"ft.age\" maxage=\"10000\" />\n"
		"    <crank name=\"FiltSink\" function=\"FiltSink\" />\n"
		"  </module>\n"
		"</psyspec>", port, child.c_str(), resultFile.c_str(), resultFile.c_str());

	unittest::progress(10, "booting filter-test node");
	Node* node = StartTestNode(spec.c_str(), port);
	if (!node) { unittest::fail("node init failed"); return false; }
	utils::Sleep(1500);
	EndTestNode(node);

	// Expected exact set of (trigger, k) firings. GT3/LT3 note: equal-within-
	// epsilon values pass the greaterthan/lessthan gates (engine uses >= / <=
	// at the boundary), but k=3 vs value=3 is exactly equal so with the test
	// values below only strict cases occur for GT3/LT3... except the boundary:
	// CompareFloats(3,3)==0 → GREATERTHAN does NOT erase (Compare<0 required),
	// so GT3 and LT3 DO fire on k=3 as well (documented >=/<= boundary).
	struct { const char* trig; const char* k; } expected[] = {
		{ "EQ3", "3" },
		{ "NE3", "2" }, { "NE3", "5" }, { "NE3", "abc" },
		{ "GT3", "3" }, { "GT3", "5" },
		{ "LT3", "2" }, { "LT3", "3" }, { "LT3", "abc" },
		{ "SEQ", "abc" },
		{ "SNE", "2" }, { "SNE", "3" }, { "SNE", "5" },
		{ "HK", "2" }, { "HK", "3" }, { "HK", "5" }, { "HK", "abc" },
		{ "AGE", "2" }, { "AGE", "3" }, { "AGE", "5" }, { "AGE", "abc" },
	};
	const int nExpected = (int)(sizeof(expected)/sizeof(expected[0]));

	std::string data = utils::ReadAFileString(resultFile.c_str());
	std::vector<std::string> lines = utils::TextListSplitLines(data.c_str(), false, true);
	utils::DeleteAFile(resultFile.c_str(), true);
	utils::DeleteAFile((resultFile + ".dbg").c_str(), true);

	bool ok = true;
	std::multiset<std::string> got;
	for (size_t li = 0; li < lines.size(); li++)
		if (lines[li].length()) got.insert(lines[li]);
	unittest::detail("recorded %d firings (want %d)", (int)got.size(), nExpected);
	for (int i = 0; i < nExpected; i++) {
		std::string want = utils::StringFormat("%s|%s", expected[i].trig, expected[i].k);
		std::multiset<std::string>::iterator f = got.find(want);
		if (f == got.end()) {
			unittest::fail("missing expected firing: %s", want.c_str());
			ok = false;
		} else
			got.erase(f);
	}
	for (std::multiset<std::string>::iterator it = got.begin(); it != got.end(); ++it) {
		unittest::fail("unexpected firing: %s", it->c_str());
		ok = false;
	}
	unittest::progress(100, ok ? "all 8 filter ops behave as named" : "filter mismatches");
	return ok;
}

void Register_Psyclone_Tests() {
	UnitTestRunner& r = UnitTestRunner::instance();
	r.registerTest("psy_triggerfilter", UnitTest_TriggerFilter,
		"<filter> trigger filters: all 8 ops incl. equalsnumeric/notequalsnumeric regression", "psyclone");
	r.registerTest("psy_triggergroup_core", UnitTest_TriggerGroup_Core,
		"T1.6 <triggergroup> join core: count/dup/order/optional/snapshot/empty-tag + dup-name", "psyclone");
	r.registerTest("psy_triggergroup_timing", UnitTest_TriggerGroup_Timing,
		"T1.6 <triggergroup> timing: within/time/maxage/min re-fire/timeout fire+discard", "psyclone");
	r.registerTest("psy_configvars", UnitTest_ConfigVars,
		"PsySpec %variable% expansion incl. command line overrides", "psyclone");
	r.registerTest("psy_modulelogging", UnitTest_ModuleLogging,
		"Per-module verbose level and separate module logfile", "psyclone");
	r.registerTest("psy_extexec_basic", UnitTest_ExtExec_Basic,
		"External exec: auto-launch, space active, workdir, env, logfile routing", "psyclone");
	r.registerTest("psy_extexec_restart", UnitTest_ExtExec_Restart,
		"External exec: crash -> autorestart with backoff; psyclonelog routing", "psyclone");
	r.registerTest("psy_extexec_limit", UnitTest_ExtExec_Limit,
		"External exec: restartlimit stops the restart storm (FAILED)", "psyclone");
	r.registerTest("psy_extexec_startuptimeout", UnitTest_ExtExec_StartupTimeout,
		"External exec: startuptimeout on non-connecting child, autorestart=no", "psyclone");
	r.registerTest("psy_extexec_messages", UnitTest_ExtExec_Messages,
		"External exec: output=messages mode launches and runs", "psyclone");
	r.registerTest("psy_extexec_duplicate", UnitTest_ExtExec_Duplicate,
		"External exec: duplicate <executable> on shared space ignored", "psyclone");
	r.registerTest("psy_extexec_disabled", UnitTest_ExtExec_Disabled,
		"External exec: enabled=no registers but does not launch", "psyclone");
	r.registerTest("psy_extexec_hang", UnitTest_ExtExec_Hang,
		"External exec: child drops space while alive -> hang detected + restart", "psyclone");
}

} // namespace cmlabs
