OverviewFilesCommitsIssuesDocsReleasesPeers

opencog-atomspace-storage / tests / persist / json / JSCommandsUTest.cxxtest

570 lines · cxxtest ref:
/*
 * JSCommandsUTest.cxxtest
 * Test JSON command processor
 *
 * Copyright (c) 2025 Linas Vepstas
 * SPDX-License-Identifier: AGPL-3.0-or-later
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License v3 as
 * published by the Free Software Foundation and including the exceptions
 * at http://opencog.org/wiki/Licenses
 *
 * This program 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.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program; if not, write to:
 * Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

#include <opencog/util/Logger.h>
#include <opencog/atomspace/AtomSpace.h>
#include <opencog/atoms/value/FloatValue.h>
#include <opencog/atoms/value/StringValue.h>
#include <opencog/atoms/base/Node.h>
#include <opencog/atoms/base/Link.h>

#include "opencog/persist/json/JSCommands.h"

using namespace opencog;

class JSCommandsUTest : public CxxTest::TestSuite
{
private:
	AtomSpacePtr as;

public:
	JSCommandsUTest()
	{
		logger().set_print_to_stdout_flag(true);
		as = createAtomSpace();
	}

	void setUp() { as->clear(); }
	void tearDown() {}

	// Helper to extract and unescape text from MCP content format
	std::string extractText(const std::string& mcpResponse) {
		// Extract text from {"content": [{"type":"text", "text": "..."}]}
		size_t textPos = mcpResponse.find("\"text\": \"");
		if (textPos == std::string::npos) return "";
		textPos += 9; // strlen("\"text\": \"")

		// Find closing quote+brace (may have trailing space)
		size_t endPos = mcpResponse.find("\"}", textPos);
		if (endPos == std::string::npos) {
			// Try with space before closing brace
			endPos = mcpResponse.find(" \"}", textPos);
		}
		if (endPos == std::string::npos) return "";

		std::string escaped = mcpResponse.substr(textPos, endPos - textPos);

		// Unescape JSON string: \" -> ", \\ -> \, etc.
		std::string unescaped;
		for (size_t i = 0; i < escaped.length(); i++) {
			if (escaped[i] == '\\' && i + 1 < escaped.length()) {
				char next = escaped[i + 1];
				if (next == '"' || next == '\\' || next == '/') {
					unescaped += next;
					i++; // Skip the next char
				} else if (next == 'n') {
					unescaped += '\n';
					i++;
				} else if (next == 't') {
					unescaped += '\t';
					i++;
				} else {
					unescaped += escaped[i];
				}
			} else {
				unescaped += escaped[i];
			}
		}

		return unescaped;
	}

	// Test type query functions
	void test_getSubTypes();
	void test_getSuperTypes();
	void test_getAtoms();

	// Test atom query functions
	void test_haveNode();
	void test_haveLink();
	void test_haveAtom();

	// Test atom creation functions
	void test_makeAtom();

	// Test atom property functions
	void test_getIncoming();
	void test_getValuesSetValue();

	// Test other functions
	void test_execute();
	void test_extract();
	void test_version();
};

// Test getSubTypes with both formats
void JSCommandsUTest::test_getSubTypes()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Test original format
	std::string cmd = "AtomSpace.getSubTypes(\"Link\")";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should return array of Link subtypes
	std::string text = extractText(result);
	TS_ASSERT(text.find("[") != std::string::npos);
	TS_ASSERT(text.find("\"OrderedLink\"") != std::string::npos);

	// Test JSON object format
	cmd = "AtomSpace.getSubTypes({\"type\": \"Link\"})";
	std::string result2 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result2.c_str());

	// Results should be identical
	TS_ASSERT_EQUALS(result, result2);

	// Test with recursive flag - original format
	cmd = "AtomSpace.getSubTypes(\"Link\", true)";
	std::string result3 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result3.c_str());

	// Recursive should return more types
	TS_ASSERT(result3.length() >= result.length());

	// Test JSON object format with recursive
	// NOTE: The "recursive" field in JSON object format is not yet implemented
	// This test documents the expected behavior once implemented
	cmd = "AtomSpace.getSubTypes({\"type\": \"Link\", \"recursive\": true})";
	std::string result4 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result4.c_str());

	// TODO: Fix JSON parser to handle recursive field
	// For now, this returns non-recursive results
	// TS_ASSERT_EQUALS(result3, result4);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test getSuperTypes with both formats
void JSCommandsUTest::test_getSuperTypes()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Test original format
	std::string cmd = "AtomSpace.getSuperTypes(\"ListLink\")";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should return array containing parent types
	std::string text = extractText(result);
	TS_ASSERT(text.find("[") != std::string::npos);
	// ListLink's direct parent is OrderedLink, not UnorderedLink
	TS_ASSERT(text.find("\"OrderedLink\"") != std::string::npos);

	// Test JSON object format
	cmd = "AtomSpace.getSuperTypes({\"type\": \"ListLink\"})";
	std::string result2 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result2.c_str());

	// Results should be identical
	TS_ASSERT_EQUALS(result, result2);

	// Test with recursive flag - original format
	cmd = "AtomSpace.getSuperTypes(\"ListLink\", true)";
	std::string result3 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result3.c_str());

	// Recursive should return more parent types
	TS_ASSERT(result3.length() >= result.length());

	// Test JSON object format with recursive
	// NOTE: The "recursive" field in JSON object format is not yet implemented
	// This test documents the expected behavior once implemented
	cmd = "AtomSpace.getSuperTypes({\"type\": \"ListLink\", \"recursive\": true})";
	std::string result4 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result4.c_str());

	// TODO: Fix JSON parser to handle recursive field
	// For now, this returns non-recursive results
	// TS_ASSERT_EQUALS(result3, result4);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test getAtoms with both formats
void JSCommandsUTest::test_getAtoms()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Add some test atoms
	as->add_node(CONCEPT_NODE, "test1");
	as->add_node(CONCEPT_NODE, "test2");
	as->add_node(PREDICATE_NODE, "pred1");

	// Test original format with quoted type
	std::string cmd = "AtomSpace.getAtoms(\"ConceptNode\")";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should return array with 2 concept nodes
	std::string text = extractText(result);
	TS_ASSERT(text.find("[") != std::string::npos);
	TS_ASSERT(text.find("\"test1\"") != std::string::npos);
	TS_ASSERT(text.find("\"test2\"") != std::string::npos);
	TS_ASSERT(text.find("\"pred1\"") == std::string::npos);

	// Test JSON object format
	cmd = "AtomSpace.getAtoms({\"type\": \"ConceptNode\"})";
	std::string result2 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result2.c_str());

	// Results should be identical
	TS_ASSERT_EQUALS(result, result2);

	// Test with recursive flag to get all Nodes - original format
	cmd = "AtomSpace.getAtoms(\"Node\", true)";
	std::string result3 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result3.c_str());

	// Should include all nodes
	std::string text3 = extractText(result3);
	TS_ASSERT(text3.find("\"test1\"") != std::string::npos);
	TS_ASSERT(text3.find("\"test2\"") != std::string::npos);
	TS_ASSERT(text3.find("\"pred1\"") != std::string::npos);

	// Test JSON object format with subclass
	// NOTE: The "subclass" field in JSON object format is not yet implemented
	// This test documents the expected behavior once implemented
	cmd = "AtomSpace.getAtoms({\"type\": \"Node\", \"subclass\": true})";
	std::string result4 = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result4.c_str());

	// TODO: Fix JSON parser to handle subclass field
	// For now, this returns only exact type matches
	// TS_ASSERT_EQUALS(result3, result4);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test haveNode with both formats
void JSCommandsUTest::test_haveNode()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Test when node doesn't exist - original format
	std::string cmd = "AtomSpace.haveNode(\"ConceptNode\", \"foo\")";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"false\"}]}\n");

	// Test when node doesn't exist - JSON object format
	cmd = "AtomSpace.haveNode({\"type\": \"ConceptNode\", \"name\": \"foo\"})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"false\"}]}\n");

	// Add the node
	as->add_node(CONCEPT_NODE, "foo");

	// Test when node exists - original format
	cmd = "AtomSpace.haveNode(\"ConceptNode\", \"foo\")";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	// Test when node exists - JSON object format
	cmd = "AtomSpace.haveNode({\"type\": \"ConceptNode\", \"name\": \"foo\"})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test haveLink with both formats
void JSCommandsUTest::test_haveLink()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Add test nodes
	Handle h1 = as->add_node(CONCEPT_NODE, "foo");
	Handle h2 = as->add_node(CONCEPT_NODE, "bar");

	// Test when link doesn't exist - original format
	std::string cmd = "AtomSpace.haveLink(\"ListLink\", [{\"type\": \"ConceptNode\", \"name\": \"foo\"}, {\"type\": \"ConceptNode\", \"name\": \"bar\"}])";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"false\"}]}\n");

	// Test when link doesn't exist - JSON object format
	cmd = "AtomSpace.haveLink({\"type\": \"ListLink\", \"outgoing\": [{\"type\": \"ConceptNode\", \"name\": \"foo\"}, {\"type\": \"ConceptNode\", \"name\": \"bar\"}]})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"false\"}]}\n");

	// Add the link
	as->add_link(LIST_LINK, h1, h2);

	// Test when link exists - original format
	cmd = "AtomSpace.haveLink(\"ListLink\", [{\"type\": \"ConceptNode\", \"name\": \"foo\"}, {\"type\": \"ConceptNode\", \"name\": \"bar\"}])";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	// Test when link exists - JSON object format
	cmd = "AtomSpace.haveLink({\"type\": \"ListLink\", \"outgoing\": [{\"type\": \"ConceptNode\", \"name\": \"foo\"}, {\"type\": \"ConceptNode\", \"name\": \"bar\"}]})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test haveAtom
void JSCommandsUTest::test_haveAtom()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Test when atom doesn't exist
	std::string cmd = "AtomSpace.haveAtom({\"type\": \"ConceptNode\", \"name\": \"test\"})";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"false\"}]}\n");

	// Add the atom
	as->add_node(CONCEPT_NODE, "test");

	// Test when atom exists
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test makeAtom
void JSCommandsUTest::test_makeAtom()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Test creating a node
	std::string cmd = "AtomSpace.makeAtom({\"type\": \"ConceptNode\", \"name\": \"created\"})";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	// Verify node was created
	Handle h = as->get_node(CONCEPT_NODE, "created");
	TS_ASSERT(h != nullptr);

	// Test creating a link
	cmd = "AtomSpace.makeAtom({\"type\": \"ListLink\", \"outgoing\": [{\"type\": \"ConceptNode\", \"name\": \"created\"}, {\"type\": \"ConceptNode\", \"name\": \"another\"}]})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	// Verify link was created
	HandleSeq outgoing;
	outgoing.push_back(as->get_node(CONCEPT_NODE, "created"));
	outgoing.push_back(as->get_node(CONCEPT_NODE, "another"));
	Handle link = as->get_link(LIST_LINK, std::move(outgoing));
	TS_ASSERT(link != nullptr);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test getIncoming
void JSCommandsUTest::test_getIncoming()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Create a node and some links pointing to it
	Handle target = as->add_node(CONCEPT_NODE, "target");
	Handle other = as->add_node(CONCEPT_NODE, "other");
	Handle list1 = as->add_link(LIST_LINK, target, other);
	Handle list2 = as->add_link(LIST_LINK, other, target);
	Handle eval1 = as->add_link(EVALUATION_LINK, target, other);

	// Test getting all incoming
	std::string cmd = "AtomSpace.getIncoming({\"type\": \"ConceptNode\", \"name\": \"target\"})";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should return array with 3 links
	TS_ASSERT(result.find("[") != std::string::npos);
	TS_ASSERT(result.find("ListLink") != std::string::npos);
	TS_ASSERT(result.find("EvaluationLink") != std::string::npos);

	// Test with type filter
	cmd = "AtomSpace.getIncoming({\"type\": \"ConceptNode\", \"name\": \"target\"}, \"ListLink\")";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should only have ListLinks
	TS_ASSERT(result.find("ListLink") != std::string::npos);
	TS_ASSERT(result.find("EvaluationLink") == std::string::npos);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test getValues and setValue
void JSCommandsUTest::test_getValuesSetValue()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Create atoms
	Handle h = as->add_node(CONCEPT_NODE, "valuetest");
	Handle key = as->add_node(PREDICATE_NODE, "mykey");

	// Set a value
	std::string cmd = "AtomSpace.setValue({\"type\": \"ConceptNode\", \"name\": \"valuetest\", \"key\": {\"type\": \"PredicateNode\", \"name\": \"mykey\"}, \"value\": {\"type\": \"FloatValue\", \"value\": [1.1, 2.2, 3.3]}})";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	// Get values
	cmd = "AtomSpace.getValues({\"type\": \"ConceptNode\", \"name\": \"valuetest\"})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should contain the key and float value
	TS_ASSERT(result.find("mykey") != std::string::npos);
	TS_ASSERT(result.find("FloatValue") != std::string::npos);
	TS_ASSERT(result.find("1.1") != std::string::npos);
	TS_ASSERT(result.find("2.2") != std::string::npos);
	TS_ASSERT(result.find("3.3") != std::string::npos);

	// Test setValue with mixed field order and StringValue
	logger().info("Testing setValue with mixed field order and StringValue...");

	// Test the exact command format requested
	cmd = "AtomSpace.setValue({\"key\":{\"type\":\"Predicate\",\"name\":\"sovs\"},\"name\":\"fimble\",\"type\":\"Concept\",\"value\":{\"type\":\"StringValue\",\"value\":[\"a\", \"b\", \"c\", \"wait there's more\", \"e\", \"oofdah\"]}})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for setValue with mixed fields: %s", result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	// Verify the atom was created with the correct name
	Handle fimble = as->get_node(CONCEPT_NODE, "fimble");
	TS_ASSERT(fimble != nullptr);
	TS_ASSERT_EQUALS(fimble->get_name(), "fimble");

	// Verify the key was created
	Handle sovs = as->get_node(PREDICATE_NODE, "sovs");
	TS_ASSERT(sovs != nullptr);
	TS_ASSERT_EQUALS(sovs->get_name(), "sovs");

	// Verify the value was set correctly
	ValuePtr v = fimble->getValue(sovs);
	TS_ASSERT(v != nullptr);

	// Check that it's a StringValue
	StringValuePtr sv = StringValueCast(v);
	TS_ASSERT(sv != nullptr);

	// Verify the string values
	std::vector<std::string> expected = {"a", "b", "c", "wait there's more", "e", "oofdah"};
	TS_ASSERT_EQUALS(sv->size(), expected.size());

	for (size_t i = 0; i < expected.size(); i++) {
		TS_ASSERT_EQUALS(sv->value()[i], expected[i]);
	}

	// Also verify using getValues command
	cmd = "AtomSpace.getValues({\"type\": \"Concept\", \"name\": \"fimble\"})";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for getValues on fimble: %s", result.c_str());

	// Should contain the key and string values (now escaped in JSON string)
	TS_ASSERT(result.find("sovs") != std::string::npos);
	TS_ASSERT(result.find("StringValue") != std::string::npos);
	TS_ASSERT(result.find("\\\"a\\\"") != std::string::npos);
	TS_ASSERT(result.find("\\\"b\\\"") != std::string::npos);
	TS_ASSERT(result.find("\\\"c\\\"") != std::string::npos);
	TS_ASSERT(result.find("\\\"wait there's more\\\"") != std::string::npos);
	TS_ASSERT(result.find("\\\"e\\\"") != std::string::npos);
	TS_ASSERT(result.find("\\\"oofdah\\\"") != std::string::npos);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test execute
void JSCommandsUTest::test_execute()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Create executable atoms (using NumberNodes for PlusLink)
	Handle n1 = as->add_node(NUMBER_NODE, "2");
	Handle n2 = as->add_node(NUMBER_NODE, "3");
	Handle plus = as->add_link(PLUS_LINK, n1, n2);

	// Execute via command
	std::string cmd = "AtomSpace.execute({\"type\": \"PlusLink\", \"outgoing\": [{\"type\": \"NumberNode\", \"name\": \"2\"}, {\"type\": \"NumberNode\", \"name\": \"3\"}]})";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should return result of execution (now escaped in JSON string)
	TS_ASSERT(result.find("NumberNode") != std::string::npos);
	TS_ASSERT(result.find("\\\"5\\\"") != std::string::npos);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test extract
void JSCommandsUTest::test_extract()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	// Create some atoms
	Handle h1 = as->add_node(CONCEPT_NODE, "extract1");
	Handle h2 = as->add_node(CONCEPT_NODE, "extract2");
	Handle link = as->add_link(LIST_LINK, h1, h2);

	size_t initial_size = as->get_size();

	// Extract non-recursively (should fail because of incoming link)
	std::string cmd = "AtomSpace.extract({\"type\": \"ConceptNode\", \"name\": \"extract1\"}, false)";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"false\"}]}\n");
	TS_ASSERT_EQUALS(as->get_size(), initial_size);

	// Extract recursively
	cmd = "AtomSpace.extract({\"type\": \"ConceptNode\", \"name\": \"extract1\"}, true)";
	result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());
	TS_ASSERT_EQUALS(result, "{\"content\": [{\"type\":\"text\", \"text\": \"true\"}]}\n");

	// Verify extraction
	TS_ASSERT(as->get_node(CONCEPT_NODE, "extract1") == nullptr);
	TS_ASSERT(as->get_link(LIST_LINK, HandleSeq{h1, h2}) == nullptr);

	logger().info("END TEST: %s", __FUNCTION__);
}

// Test version
void JSCommandsUTest::test_version()
{
	logger().info("BEGIN TEST: %s", __FUNCTION__);

	std::string cmd = "AtomSpace.version()";
	std::string result = JSCommands::interpret_command(as.get(), cmd);
	logger().info("Result for '%s': %s", cmd.c_str(), result.c_str());

	// Should return version string
	TS_ASSERT(result.length() > 0);

	logger().info("END TEST: %s", __FUNCTION__);
}