Basic testing framework (#594)

This commit is contained in:
TheMode 2022-01-19 21:41:25 +01:00 committed by GitHub
parent 37bebff883
commit 91c06da68a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 513 additions and 23 deletions

View File

@ -21,5 +21,8 @@ tasks {
}
withType<Test> {
useJUnitPlatform()
// Present until tests all succeed without
maxParallelForks = Runtime.getRuntime().availableProcessors()
setForkEvery(1)
}
}

View File

@ -1,15 +1,12 @@
package net.minestom.server.instance;
import net.minestom.server.MinecraftServer;
import net.minestom.server.exception.ExceptionManager;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.block.BlockHandler;
import net.minestom.server.instance.block.BlockManager;
import net.minestom.server.tag.Tag;
import net.minestom.server.utils.NamespaceID;
import net.minestom.server.utils.async.AsyncUtils;
import net.minestom.server.world.biomes.Biome;
import net.minestom.server.world.biomes.BiomeManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jglrxavpok.hephaistos.mca.*;
@ -30,9 +27,6 @@ import java.util.concurrent.ConcurrentHashMap;
public class AnvilLoader implements IChunkLoader {
private final static Logger LOGGER = LoggerFactory.getLogger(AnvilLoader.class);
private static final BlockManager BLOCK_MANAGER = MinecraftServer.getBlockManager();
private static final BiomeManager BIOME_MANAGER = MinecraftServer.getBiomeManager();
private static final ExceptionManager EXCEPTION_MANAGER = MinecraftServer.getExceptionManager();
private static final Biome BIOME = Biome.PLAINS;
private final Map<String, RegionFile> alreadyLoaded = new ConcurrentHashMap<>();
@ -74,7 +68,7 @@ public class AnvilLoader implements IChunkLoader {
try {
return loadMCA(instance, chunkX, chunkZ);
} catch (Exception e) {
EXCEPTION_MANAGER.handleException(e);
MinecraftServer.getExceptionManager().handleException(e);
}
return CompletableFuture.completedFuture(null);
}
@ -119,7 +113,8 @@ public class AnvilLoader implements IChunkLoader {
int finalZ = fileChunk.getZ() * Chunk.CHUNK_SIZE_Z + z;
int finalY = section.getY() * Chunk.CHUNK_SECTION_SIZE + y;
String biomeName = section.getBiome(x, y, z);
Biome biome = biomeCache.computeIfAbsent(biomeName, n -> Objects.requireNonNullElse(BIOME_MANAGER.getByName(NamespaceID.from(n)), BIOME));
Biome biome = biomeCache.computeIfAbsent(biomeName, n ->
Objects.requireNonNullElse(MinecraftServer.getBiomeManager().getByName(NamespaceID.from(n)), BIOME));
chunk.setBiome(finalX, finalY, finalZ, biome);
}
}
@ -151,7 +146,7 @@ public class AnvilLoader implements IChunkLoader {
}
return new RegionFile(new RandomAccessFile(regionPath.toFile(), "rw"), regionX, regionZ, instance.getDimensionType().getMinY(), instance.getDimensionType().getMaxY()-1);
} catch (IOException | AnvilException e) {
EXCEPTION_MANAGER.handleException(e);
MinecraftServer.getExceptionManager().handleException(e);
return null;
}
});
@ -178,7 +173,7 @@ public class AnvilLoader implements IChunkLoader {
chunk.setBlock(x, y + yOffset, z, block);
} catch (Exception e) {
EXCEPTION_MANAGER.handleException(e);
MinecraftServer.getExceptionManager().handleException(e);
}
}
}
@ -199,7 +194,7 @@ public class AnvilLoader implements IChunkLoader {
final String tileEntityID = te.getString("id");
if (tileEntityID != null) {
final BlockHandler handler = BLOCK_MANAGER.getHandlerOrDummy(tileEntityID);
final BlockHandler handler = MinecraftServer.getBlockManager().getHandlerOrDummy(tileEntityID);
block = block.withHandler(handler);
}
// Remove anvil tags
@ -254,7 +249,7 @@ public class AnvilLoader implements IChunkLoader {
alreadyLoaded.put(n, mcaFile);
} catch (AnvilException | IOException e) {
LOGGER.error("Failed to save chunk " + chunkX + ", " + chunkZ, e);
EXCEPTION_MANAGER.handleException(e);
MinecraftServer.getExceptionManager().handleException(e);
return AsyncUtils.VOID_FUTURE;
}
}
@ -264,7 +259,7 @@ public class AnvilLoader implements IChunkLoader {
column = mcaFile.getOrCreateChunk(chunkX, chunkZ);
} catch (AnvilException | IOException e) {
LOGGER.error("Failed to save chunk " + chunkX + ", " + chunkZ, e);
EXCEPTION_MANAGER.handleException(e);
MinecraftServer.getExceptionManager().handleException(e);
return AsyncUtils.VOID_FUTURE;
}
save(chunk, column);
@ -274,7 +269,7 @@ public class AnvilLoader implements IChunkLoader {
mcaFile.forget(column);
} catch (IOException e) {
LOGGER.error("Failed to save chunk " + chunkX + ", " + chunkZ, e);
EXCEPTION_MANAGER.handleException(e);
MinecraftServer.getExceptionManager().handleException(e);
return AsyncUtils.VOID_FUTURE;
}
return AsyncUtils.VOID_FUTURE;

View File

@ -3,7 +3,6 @@ package net.minestom.server.instance;
import it.unimi.dsi.fastutil.objects.ObjectArraySet;
import net.kyori.adventure.identity.Identity;
import net.kyori.adventure.pointer.Pointers;
import net.minestom.server.MinecraftServer;
import net.minestom.server.Tickable;
import net.minestom.server.adventure.audience.PacketGroupingAudience;
import net.minestom.server.coordinate.Point;
@ -17,7 +16,6 @@ import net.minestom.server.event.GlobalHandles;
import net.minestom.server.event.instance.InstanceTickEvent;
import net.minestom.server.instance.block.Block;
import net.minestom.server.instance.block.BlockHandler;
import net.minestom.server.instance.block.BlockManager;
import net.minestom.server.network.packet.server.play.BlockActionPacket;
import net.minestom.server.network.packet.server.play.TimeUpdatePacket;
import net.minestom.server.tag.Tag;
@ -54,8 +52,6 @@ import java.util.stream.Collectors;
*/
public abstract class Instance implements Block.Getter, Block.Setter, Tickable, Schedulable, TagHandler, PacketGroupingAudience {
protected static final BlockManager BLOCK_MANAGER = MinecraftServer.getBlockManager();
private boolean registered;
private final DimensionType dimensionType;

View File

@ -124,7 +124,7 @@ public class InstanceContainer extends Instance {
final BlockHandler previousHandler = previousBlock.handler();
// Change id based on neighbors
final BlockPlacementRule blockPlacementRule = BLOCK_MANAGER.getBlockPlacementRule(block);
final BlockPlacementRule blockPlacementRule = MinecraftServer.getBlockManager().getBlockPlacementRule(block);
if (blockPlacementRule != null) {
block = blockPlacementRule.blockUpdate(this, blockPosition, block);
}
@ -501,7 +501,7 @@ public class InstanceContainer extends Instance {
if (chunk == null) continue;
final Block neighborBlock = chunk.getBlock(neighborX, neighborY, neighborZ);
final BlockPlacementRule neighborBlockPlacementRule = BLOCK_MANAGER.getBlockPlacementRule(neighborBlock);
final BlockPlacementRule neighborBlockPlacementRule = MinecraftServer.getBlockManager().getBlockPlacementRule(neighborBlock);
if (neighborBlockPlacementRule == null) continue;
final Vec neighborPosition = new Vec(neighborX, neighborY, neighborZ);

View File

@ -0,0 +1,64 @@
package net.minestom.server.api;
import net.minestom.server.ServerProcess;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
import net.minestom.server.instance.Chunk;
import net.minestom.server.instance.ChunkGenerator;
import net.minestom.server.instance.ChunkPopulator;
import net.minestom.server.instance.Instance;
import net.minestom.server.instance.batch.ChunkBatch;
import net.minestom.server.instance.block.Block;
import org.jetbrains.annotations.NotNull;
import java.time.Duration;
import java.util.List;
import java.util.function.BooleanSupplier;
public interface Env {
@NotNull ServerProcess process();
@NotNull TestConnection createConnection();
default void tick() {
process().ticker().tick(System.nanoTime());
}
default boolean tickWhile(BooleanSupplier condition, Duration timeout) {
var ticker = process().ticker();
final long start = System.nanoTime();
while (condition.getAsBoolean()) {
final long tick = System.nanoTime();
ticker.tick(tick);
if (timeout != null && System.nanoTime() - start > timeout.toNanos()) {
return false;
}
}
return true;
}
default @NotNull Player createPlayer(@NotNull Instance instance, @NotNull Pos pos) {
return createConnection().connect(instance, pos).join();
}
default @NotNull Instance createFlatInstance() {
var instance = process().instance().createInstanceContainer();
instance.setChunkGenerator(new ChunkGenerator() {
@Override
public void generateChunkData(@NotNull ChunkBatch batch, int chunkX, int chunkZ) {
for (byte x = 0; x < Chunk.CHUNK_SIZE_X; x++)
for (byte z = 0; z < Chunk.CHUNK_SIZE_Z; z++) {
for (byte y = 0; y < 40; y++) {
batch.setBlock(x, y, z, Block.STONE);
}
}
}
@Override
public List<ChunkPopulator> getPopulators() {
return null;
}
});
return instance;
}
}

View File

@ -0,0 +1,11 @@
package net.minestom.server.api;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
final class EnvBefore implements BeforeEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
System.setProperty("minestom.viewable-packet", "false");
}
}

View File

@ -0,0 +1,22 @@
package net.minestom.server.api;
import net.minestom.server.ServerProcess;
import org.jetbrains.annotations.NotNull;
final class EnvImpl implements Env {
private final ServerProcess process;
public EnvImpl(ServerProcess process) {
this.process = process;
}
@Override
public @NotNull ServerProcess process() {
return process;
}
@Override
public @NotNull TestConnection createConnection() {
return new TestConnectionImpl(this);
}
}

View File

@ -0,0 +1,15 @@
package net.minestom.server.api;
import net.minestom.server.MinecraftServer;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.support.TypeBasedParameterResolver;
final class EnvParameterResolver extends TypeBasedParameterResolver<Env> {
@Override
public Env resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return new EnvImpl(MinecraftServer.updateProcess());
}
}

View File

@ -0,0 +1,15 @@
package net.minestom.server.api;
import org.junit.jupiter.api.extension.ExtendWith;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@ExtendWith(EnvParameterResolver.class)
@ExtendWith(EnvBefore.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface EnvTest {
}

View File

@ -0,0 +1,20 @@
package net.minestom.server.api;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
import net.minestom.server.instance.Instance;
import net.minestom.server.network.packet.server.ServerPacket;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public interface TestConnection {
@NotNull CompletableFuture<@NotNull Player> connect(@NotNull Instance instance, @NotNull Pos pos);
<T extends ServerPacket> @NotNull PacketTracker<T> trackIncoming(@NotNull Class<T> type);
interface PacketTracker<T> {
@NotNull List<@NotNull T> collect();
}
}

View File

@ -0,0 +1,97 @@
package net.minestom.server.api;
import net.minestom.server.ServerProcess;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.Player;
import net.minestom.server.event.EventListener;
import net.minestom.server.event.player.PlayerLoginEvent;
import net.minestom.server.instance.Instance;
import net.minestom.server.network.packet.server.SendablePacket;
import net.minestom.server.network.packet.server.ServerPacket;
import net.minestom.server.network.player.PlayerConnection;
import org.jetbrains.annotations.NotNull;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
final class TestConnectionImpl implements TestConnection {
private final Env env;
private final ServerProcess process;
private final PlayerConnectionImpl playerConnection = new PlayerConnectionImpl();
private final List<TrackerImpl<ServerPacket>> incomingTrackers = new CopyOnWriteArrayList<>();
public TestConnectionImpl(Env env) {
this.env = env;
this.process = env.process();
}
@Override
public @NotNull CompletableFuture<Player> connect(@NotNull Instance instance, @NotNull Pos pos) {
AtomicReference<EventListener<PlayerLoginEvent>> listenerRef = new AtomicReference<>();
var listener = EventListener.builder(PlayerLoginEvent.class)
.handler(event -> {
if (event.getPlayer().getPlayerConnection() == playerConnection) {
event.setSpawningInstance(instance);
event.getPlayer().setRespawnPoint(pos);
process.eventHandler().removeListener(listenerRef.get());
}
}).build();
listenerRef.set(listener);
process.eventHandler().addListener(listener);
var player = new Player(UUID.randomUUID(), "RandName", playerConnection);
process.connection().startPlayState(player, true);
while (player.getInstance() != instance) { // TODO replace with proper future
env.tick();
}
return CompletableFuture.completedFuture(player);
}
@Override
public @NotNull <T extends ServerPacket> PacketTracker<T> trackIncoming(@NotNull Class<T> type) {
var tracker = new TrackerImpl<>(type);
this.incomingTrackers.add(TrackerImpl.class.cast(tracker));
return tracker;
}
final class PlayerConnectionImpl extends PlayerConnection {
@Override
public void sendPacket(@NotNull SendablePacket packet) {
for (var tracker : incomingTrackers) {
final var serverPacket = SendablePacket.extractServerPacket(packet);
if (tracker.type.isAssignableFrom(serverPacket.getClass())) tracker.packets.add(serverPacket);
}
}
@Override
public @NotNull SocketAddress getRemoteAddress() {
return new InetSocketAddress("localhost", 25565);
}
@Override
public void disconnect() {
}
}
final class TrackerImpl<T extends ServerPacket> implements PacketTracker<T> {
private final Class<T> type;
private final List<T> packets = new CopyOnWriteArrayList<>();
public TrackerImpl(Class<T> type) {
this.type = type;
}
@Override
public @NotNull List<T> collect() {
incomingTrackers.remove(this);
return List.copyOf(packets);
}
}
}

View File

@ -0,0 +1,45 @@
package net.minestom.server.entity;
import net.minestom.server.api.Env;
import net.minestom.server.api.EnvTest;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.network.packet.server.ServerPacket;
import net.minestom.server.network.packet.server.play.JoinGamePacket;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@EnvTest
public class EntityInstanceIntegrationTest {
@Test
public void entityJoin(Env env) {
var instance = env.createFlatInstance();
var entity = new Entity(EntityTypes.ZOMBIE);
entity.setInstance(instance, new Pos(0, 42, 0)).join();
assertEquals(instance, entity.getInstance());
}
@Test
public void playerJoin(Env env) {
var instance = env.createFlatInstance();
var connection = env.createConnection();
var player = connection.connect(instance, new Pos(0, 42, 0)).join();
assertEquals(instance, player.getInstance());
}
@Test
public void playerJoinPacket(Env env) {
var instance = env.createFlatInstance();
var connection = env.createConnection();
var tracker = connection.trackIncoming(JoinGamePacket.class);
var tracker2 = connection.trackIncoming(ServerPacket.class);
var player = connection.connect(instance, new Pos(0, 40, 0)).join();
assertEquals(instance, player.getInstance());
assertEquals(new Pos(0, 40, 0), player.getPosition());
assertEquals(1, tracker.collect().size());
assertTrue(tracker2.collect().size() > 1);
}
}

View File

@ -0,0 +1,90 @@
package net.minestom.server.entity;
import net.minestom.server.api.Env;
import net.minestom.server.api.EnvTest;
import net.minestom.server.coordinate.Pos;
import net.minestom.server.network.packet.server.ServerPacket;
import net.minestom.server.network.packet.server.play.EntityTeleportPacket;
import net.minestom.server.network.packet.server.play.PlayerPositionAndLookPacket;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@EnvTest
public class EntityTeleportIntegrationTest {
@Test
public void entityChunkTeleport(Env env) {
var instance = env.createFlatInstance();
var entity = new Entity(EntityTypes.ZOMBIE);
entity.setInstance(instance, new Pos(0, 42, 0)).join();
assertEquals(instance, entity.getInstance());
assertEquals(new Pos(0, 42, 0), entity.getPosition());
entity.teleport(new Pos(1, 42, 1)).join();
assertEquals(new Pos(1, 42, 1), entity.getPosition());
}
@Test
public void entityTeleport(Env env) {
var instance = env.createFlatInstance();
var entity = new Entity(EntityTypes.ZOMBIE);
entity.setInstance(instance, new Pos(0, 42, 0)).join();
assertEquals(instance, entity.getInstance());
assertEquals(new Pos(0, 42, 0), entity.getPosition());
entity.teleport(new Pos(52, 42, 52)).join();
assertEquals(new Pos(52, 42, 52), entity.getPosition());
}
@Test
public void playerChunkTeleport(Env env) {
var instance = env.createFlatInstance();
var connection = env.createConnection();
var player = connection.connect(instance, new Pos(0, 40, 0)).join();
assertEquals(instance, player.getInstance());
assertEquals(new Pos(0, 40, 0), player.getPosition());
var viewerConnection = env.createConnection();
viewerConnection.connect(instance, new Pos(0, 40, 0)).join();
var tracker = connection.trackIncoming(ServerPacket.class);
var viewerTracker = viewerConnection.trackIncoming(ServerPacket.class);
var teleportPosition = new Pos(1, 42, 1);
player.teleport(teleportPosition).join();
assertEquals(teleportPosition, player.getPosition());
// Verify received packet(s)
{
var packets = tracker.collect();
assertEquals(1, packets.size());
var packet = ((PlayerPositionAndLookPacket) packets.get(0));
assertEquals(teleportPosition, packet.position());
}
// Verify broadcast packet(s)
{
var packets = viewerTracker.collect();
assertEquals(1, packets.size());
var packet = ((EntityTeleportPacket) packets.get(0));
assertEquals(player.getEntityId(), packet.entityId());
assertEquals(teleportPosition, packet.position());
}
}
@Test
public void playerTeleport(Env env) {
var instance = env.createFlatInstance();
var connection = env.createConnection();
var player = connection.connect(instance, new Pos(0, 40, 0)).join();
assertEquals(instance, player.getInstance());
assertEquals(new Pos(0, 40, 0), player.getPosition());
var viewerConnection = env.createConnection();
viewerConnection.connect(instance, new Pos(0, 40, 0)).join();
var teleportPosition = new Pos(4999, 42, 4999);
player.teleport(teleportPosition).join();
assertEquals(teleportPosition, player.getPosition());
}
}

View File

@ -0,0 +1,114 @@
package net.minestom.server.entity;
import net.minestom.server.api.Env;
import net.minestom.server.api.EnvTest;
import net.minestom.server.coordinate.Pos;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@EnvTest
public class EntityViewIntegrationTest {
@Test
public void emptyEntity(Env env) {
var instance = env.createFlatInstance();
var entity = new Entity(EntityType.ZOMBIE);
entity.setInstance(instance, new Pos(0, 40, 42)).join();
assertEquals(0, entity.getViewers().size());
}
@Test
public void emptyPlayer(Env env) {
var instance = env.createFlatInstance();
var player = env.createPlayer(instance, new Pos(0, 42, 0));
assertEquals(0, player.getViewers().size());
}
@Test
public void multiPlayers(Env env) {
var instance = env.createFlatInstance();
var p1 = env.createPlayer(instance, new Pos(0, 42, 42));
var p2 = env.createPlayer(instance, new Pos(0, 42, 42));
assertEquals(1, p1.getViewers().size());
p1.getViewers().forEach(p -> assertEquals(p2, p));
assertEquals(1, p2.getViewers().size());
p2.getViewers().forEach(p -> assertEquals(p1, p));
p2.remove();
assertEquals(0, p1.getViewers().size());
assertEquals(0, p2.getViewers().size());
var p3 = env.createPlayer(instance, new Pos(0, 42, 42));
assertEquals(1, p1.getViewers().size());
p1.getViewers().forEach(p -> assertEquals(p3, p));
}
@Test
public void movements(Env env) {
var instance = env.createFlatInstance();
var p1 = env.createPlayer(instance, new Pos(0, 42, 0));
var p2 = env.createPlayer(instance, new Pos(0, 42, 96));
assertEquals(0, p1.getViewers().size());
assertEquals(0, p2.getViewers().size());
p2.teleport(new Pos(0, 42, 95)).join(); // Teleport in range (6 chunks)
assertEquals(1, p1.getViewers().size());
assertEquals(1, p2.getViewers().size());
}
@Test
public void autoViewable(Env env) {
var instance = env.createFlatInstance();
var p1 = env.createPlayer(instance, new Pos(0, 42, 0));
assertTrue(p1.isAutoViewable());
p1.setAutoViewable(false);
var p2 = env.createPlayer(instance, new Pos(0, 42, 0));
assertEquals(0, p1.getViewers().size());
assertEquals(1, p2.getViewers().size());
p1.setAutoViewable(true);
assertEquals(1, p1.getViewers().size());
assertEquals(1, p2.getViewers().size());
}
@Test
public void viewableRule(Env env) {
var instance = env.createFlatInstance();
var p1 = env.createPlayer(instance, new Pos(0, 42, 0));
p1.updateViewableRule(player -> player.getEntityId() == p1.getEntityId() + 1);
var p2 = env.createPlayer(instance, new Pos(0, 42, 0));
assertEquals(1, p1.getViewers().size());
assertEquals(1, p2.getViewers().size());
p1.updateViewableRule(player -> false);
assertEquals(0, p1.getViewers().size());
assertEquals(1, p2.getViewers().size());
}
@Test
public void viewerRule(Env env) {
var instance = env.createFlatInstance();
var p1 = env.createPlayer(instance, new Pos(0, 42, 0));
p1.updateViewerRule(player -> player.getEntityId() == p1.getEntityId() + 1);
var p2 = env.createPlayer(instance, new Pos(0, 42, 0));
assertEquals(1, p1.getViewers().size());
assertEquals(1, p2.getViewers().size());
p1.updateViewerRule(player -> false);
assertEquals(1, p1.getViewers().size());
assertEquals(0, p2.getViewers().size());
}
}

View File

@ -1,9 +1,7 @@
package net.minestom.server.inventory;
import net.kyori.adventure.text.Component;
import net.minestom.server.inventory.Inventory;
import net.minestom.server.inventory.InventoryType;
import net.minestom.server.inventory.TransactionOption;
import net.minestom.server.MinecraftServer;
import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import org.junit.jupiter.api.Test;
@ -12,6 +10,11 @@ import static org.junit.jupiter.api.Assertions.*;
public class InventoryTest {
static {
// Required to prevent initialization error during event call
MinecraftServer.init();
}
@Test
public void testCreation() {
Inventory inventory = new Inventory(InventoryType.CHEST_1_ROW, "title");