/**
* Unit tests for what `ls` finds at a place and how it writes each row back.
*
* Every read a listing makes is `packages/cli`'s, and every case that makes
* one stands its own in through the deps bag, so what is under test is the
* composing —
* which read a position takes, what a row carries, and how a row prints — with
* no socket, no server and no piece behind any of it. What those reads do once
* called is not this file's subject; that a listing hands each of them the
* connection this process holds is, and four cases pin it.
*
* The connection is a borrowed one throughout. A listing never opens or closes
* one, so which arm a case stands it up through decides nothing here, and the
* borrowed arm is the one that needs no opener behind it.
*
* The property the file exists for is the last group's: a name `ls` prints is
* a name `cd` takes back to the row it was printed for. It is driven over a
* construction rather than a hand-listed few, because the interesting names
* are the ones nobody thinks to list — and its other half matters as much,
* that a row it has no operand for prints no name at all.
*/
import { expect } from "@std/expect";
import { describe, it } from "@std/testing/bdd";
import type { MemorySpace } from "@commonfabric/memory/interface";
import { idStringForEntityAddress } from "@commonfabric/runner";
import type { PiecesController } from "@commonfabric/piece/ops";
import {
createLLMFriendlyLink,
linkPathSegmentToCellPathSegment,
} from "@commonfabric/runner/shared";
import type { SlugSummary, SpaceConfig } from "../lib/piece.ts";
import { HeldConnection } from "../lib/shuttle/connection.ts";
import { quoteToken, splitLine } from "../lib/shuttle/line.ts";
import { readsAsOption } from "../lib/shuttle/options.ts";
import {
handleFor,
type Listing,
type ListingDeps,
listingLines,
type ListingRow,
listPlace,
type RowKind,
} from "../lib/shuttle/listing.ts";
import { CurrentPlace, type Facet, type Place } from "../lib/shuttle/place.ts";
import { moved } from "./shuttle-place-helpers.ts";
const SPACE = "did:key:z6MkConnectedSpace" as MemorySpace;
const HANDLE = "of:fid1:abcdefghijklmnop";
const OTHER_HANDLE = "of:fid1:qrstuvwxyz012345";
/** The spelling a piece reports its own id as, which carries no scheme. */
const BARE_HANDLE = "fid1:abcdefghijklmnop";
const CONFIG: SpaceConfig = {
apiUrl: "https://toolshed.example/",
space: SPACE,
identity: "/keys/shuttle.pkcs8",
};
/** Helper for the cases below, which fails whichever read a case reaches. */
const READS_NOTHING: ListingDeps = {
listSpaceSlugs: () => {
throw new Error("The slug index was read.");
},
listPieces: () => {
throw new Error("The pieces were read.");
},
getCellValue: () => {
throw new Error("The cell was read.");
},
listCallableKeys: () => {
throw new Error("The cell's callables were read.");
},
};
/** Helper for the cases below, which stands at the space's root. */
function atSpaceRoot(): CurrentPlace {
return new CurrentPlace(SPACE);
}
/** Helper for the cases below, which stands inside `facet`. */
function inFacet(facet: Facet): CurrentPlace {
const place = atSpaceRoot();
moved(place, facet);
return place;
}
/** Helper for the cases below, which stands at a piece, at `path` inside it. */
function atPiece(...path: string[]): CurrentPlace {
const place = atSpaceRoot();
moved(place, `/${HANDLE}`);
for (const segment of path) moved(place, segment);
return place;
}
/**
* Helper for the cases below, which is a connection over a controller nothing
* reads. A listing hands the controller to a read and never touches it, so
* what a case can see is which read was handed it.
*/
function heldConnection(): {
connection: HeldConnection;
pieces: PiecesController;
} {
const pieces = {
dispose: () => Promise.resolve(),
} as unknown as PiecesController;
return {
connection: new HeldConnection({ kind: "borrowed", pieces }),
pieces,
};
}
/** Helper for the cases below, which stands `rows` in for the slug index. */
function slugIndex(rows: SlugSummary[]): ListingDeps {
return { ...READS_NOTHING, listSpaceSlugs: () => Promise.resolve(rows) };
}
/** Helper for the cases below, which stands `rows` in for the space's pieces. */
function spacePieces(
rows: { id: string; reference?: string; name?: string; error?: string }[],
): ListingDeps {
return {
...READS_NOTHING,
listPieces: () =>
Promise.resolve(rows.map((row) => ({
...row,
reference: row.reference ??
createLLMFriendlyLink({
space: SPACE,
id: idStringForEntityAddress(row.id) as `${string}:${string}`,
scope: "space",
path: [],
}),
}))),
};
}
/**
* Helper for the cases below, which stands a cell holding `keys` in for the
* one a listing reads, each key holding an ordinary value.
*
* A listing reads the level's value rather than a list of names, so a case
* stands in a value: `keysOf` is what names the rows off it, which is the same
* seam `listCellKeys` names its own off.
*/
function cellKeys(keys: string[]): ListingDeps {
return cellHolding(Object.fromEntries(keys.map((key) => [key, "a value"])));
}
/**
* Helper for the cases below, which stands `value` in for a cell's value and
* `callables` in for the keys whose stored links declare a stream.
*/
function cellHolding(
value: unknown,
callables: readonly string[] = [],
): ListingDeps {
return {
...READS_NOTHING,
getCellValue: () => Promise.resolve(value),
listCallableKeys: () => Promise.resolve(new Set(callables)),
};
}
/** Helper for the cases below, which is `names` with no name written twice. */
function unique(names: readonly string[]): string[] {
return [...new Set(names)];
}
/** Helper for the cases below, which lists `place` over `deps`. */
function list(place: CurrentPlace, deps: ListingDeps): Promise
{
return listPlace(CONFIG, place.place, heldConnection().connection, deps);
}
describe("listing", () => {
describe("listPlace()", () => {
describe("a space root", () => {
it("returns one row per facet the root lists", async () => {
const listing = await list(atSpaceRoot(), READS_NOTHING);
expect(listing.rows.map((row) => row.name)).toEqual([
"slugs",
"pieces",
]);
});
it("returns a facet row carrying the operand `cd` takes to it", async () => {
const listing = await list(atSpaceRoot(), READS_NOTHING);
expect(listing.rows.map((row) => row.operand)).toEqual([
"slugs",
"pieces",
]);
});
it("returns the facets without reading anything", async () => {
let reads = 0;
await list(atSpaceRoot(), {
listSpaceSlugs: () => {
reads++;
return Promise.resolve([]);
},
listPieces: () => {
reads++;
return Promise.resolve([]);
},
getCellValue: () => {
reads++;
return Promise.resolve({});
},
});
expect(reads).toBe(0);
});
it("returns no bound, a root listing its whole facet set", async () => {
const listing = await list(atSpaceRoot(), READS_NOTHING);
expect(listing.bound).toBeUndefined();
});
});
describe("`slugs/`", () => {
it("returns one row per slug the index named", async () => {
const listing = await list(
inFacet("slugs"),
slugIndex([
{ slug: "board", piece: HANDLE },
{ slug: "topics", piece: OTHER_HANDLE },
]),
);
expect(listing.rows.map((row) => row.name)).toEqual([
"board",
"topics",
]);
});
it("returns a slug row carrying the operand `cd` takes to its piece", async () => {
const listing = await list(
inFacet("slugs"),
slugIndex([{ slug: "board", piece: HANDLE }]),
);
expect(listing.rows[0].operand).toBe("board");
});
it("returns the row of a slug that resolved to nothing, carrying its error", async () => {
const listing = await list(
inFacet("slugs"),
slugIndex([{ slug: "board", error: "Slug redirects to no piece." }]),
);
expect(listing.rows).toEqual([{
name: "board",
kind: "slug",
operand: "board",
error: "Slug redirects to no piece.",
}]);
});
it("returns the rows beside a slug that resolved to nothing", async () => {
const listing = await list(
inFacet("slugs"),
slugIndex([
{ slug: "board", error: "Slug redirects to no piece." },
{ slug: "topics", piece: HANDLE },
]),
);
expect(listing.rows.map((row) => row.name)).toEqual([
"board",
"topics",
]);
});
it("returns no operand for an index name that is no slug", async () => {
const listing = await list(
inFacet("slugs"),
slugIndex([{ slug: "Board", piece: HANDLE }]),
);
expect(listing.rows).toEqual([{ name: "Board", kind: "slug" }]);
});
it("returns a bound saying the index is what was listed", async () => {
const listing = await list(inFacet("slugs"), slugIndex([]));
expect(listing.bound).toBe(
"the space's slug index names these, and a slug it never recorded " +
"still resolves",
);
});
});
describe("`pieces/`", () => {
it("retains distinct registered target references with equal document IDs", async () => {
const references = [
createLLMFriendlyLink({
space: SPACE,
id: HANDLE,
scope: "space",
path: [],
}),
createLLMFriendlyLink({
space: "did:key:z6MkForeignSpace",
id: HANDLE,
scope: "user",
path: ["embedded", "0"],
}),
createLLMFriendlyLink({
space: SPACE,
id: HANDLE,
scope: "user",
path: ["embedded", "0"],
}),
];
const listing = await list(
inFacet("pieces"),
spacePieces(
references.map((reference) => ({ id: HANDLE, reference })),
),
);
expect(listing.rows.map((row) => row.operand)).toEqual([
HANDLE,
...references.slice(1),
]);
expect(listing.rows.map((row) => row.name)).toEqual([
HANDLE,
HANDLE,
HANDLE,
]);
const local = inFacet("pieces");
expect(moved(local, listing.rows[2].operand!).kind).toBe("moved");
expect(local.place.scope).toBe("user");
expect(local.place.position).toEqual({
kind: "piece",
space: SPACE,
piece: HANDLE,
path: ["embedded", 0],
});
const foreign = inFacet("pieces");
const before = foreign.place;
expect(moved(foreign, listing.rows[1].operand!).kind).toBe("refused");
expect(foreign.place).toEqual(before);
});
it("returns one row per piece", async () => {
const listing = await list(
inFacet("pieces"),
spacePieces([{ id: HANDLE }, { id: OTHER_HANDLE }]),
);
expect(listing.rows.map((row) => row.name)).toEqual([
HANDLE,
OTHER_HANDLE,
]);
});
it("returns a piece row carrying the operand `cd` takes to it", async () => {
const listing = await list(
inFacet("pieces"),
spacePieces([{ id: HANDLE }]),
);
expect(listing.rows[0].operand).toBe(HANDLE);
});
it("returns a piece row carrying as its operand the bare hash it was listed under", async () => {
// A piece reports its own id with no entity scheme, so that is the
// spelling this facet is handed and the one a row hands back. The
// case is the row a real listing mints, where the ones around it are
// written in the `of:` spelling the rest of this file uses.
const listing = await list(
inFacet("pieces"),
spacePieces([{ id: BARE_HANDLE }]),
);
expect(listing.rows[0].operand).toBe(BARE_HANDLE);
});
it("returns a piece row carrying the name the read found for it", async () => {
// The name is what the piece calls itself and the id is what reaches
// it, so the row carries both: the operand is the id whether a name
// was found or not, which is what keeps the row one `cd` takes back.
const listing = await list(
inFacet("pieces"),
spacePieces([{ id: HANDLE, name: "Thermostat" }]),
);
expect(listing.rows).toEqual([{
name: HANDLE,
kind: "piece",
operand: HANDLE,
ownName: "Thermostat",
}]);
});
it("returns a piece row carrying no name where the read found none", async () => {
const listing = await list(
inFacet("pieces"),
spacePieces([{ id: HANDLE }]),
);
expect(listing.rows).toEqual([{
name: HANDLE,
kind: "piece",
operand: HANDLE,
}]);
});
it("returns the row of a piece that would not load, carrying its error", async () => {
const listing = await list(
inFacet("pieces"),
spacePieces([{ id: HANDLE, error: "The piece would not load." }]),
);
expect(listing.rows).toEqual([{
name: HANDLE,
kind: "piece",
operand: HANDLE,
error: "The piece would not load.",
}]);
});
it("returns no bound, a piece listing naming every piece registered", async () => {
const listing = await list(inFacet("pieces"), spacePieces([]));
expect(listing.bound).toBeUndefined();
});
});
describe("inside a piece", () => {
it("returns one row per key the cell has", async () => {
const listing = await list(atPiece(), cellKeys(["title", "body"]));
expect(listing.rows.map((row) => row.name)).toEqual(["title", "body"]);
});
it("returns a key row carrying the operand `cd` takes to it", async () => {
const listing = await list(atPiece(), cellKeys(["title"]));
expect(listing.rows[0].operand).toBe("title");
});
it("reads the piece the place stands on", async () => {
// A slug stands unresolved in the place, so what a listing hands on is
// the slug — which is what makes a name typed back off a listing reach
// the piece it names, the read resolving it the way `--cell` does.
const place = inFacet("slugs");
moved(place, "board");
let piece: string | undefined;
await list(place, {
...READS_NOTHING,
getCellValue: (config) => {
piece = config.piece;
return Promise.resolve({});
},
});
expect(piece).toBe("board");
});
it("reads at the scope the place reads through", async () => {
const place = atPiece();
moved(place, ".@session");
let scope: string | undefined;
await list(place, {
...READS_NOTHING,
getCellValue: (config) => {
scope = config.pieceScope;
return Promise.resolve({});
},
});
expect(scope).toBe("session");
});
it("reads at the path inside the piece the place stands at", async () => {
let path: (string | number)[] | undefined;
await list(atPiece("topics", "3"), {
...READS_NOTHING,
getCellValue: (_config, given) => {
path = given;
return Promise.resolve({});
},
});
expect(path).toEqual(["topics", 3]);
});
it("returns no bound, a cell's keys being all of them", async () => {
const listing = await list(atPiece(), cellKeys(["title"]));
expect(listing.bound).toBeUndefined();
});
});
describe("what a row turned out to be", () => {
// A row's kind is recorded where the row is made, which is what lets a
// handle minted off a listing say what it stands for. Each case drives
// the read that produces the kind rather than constructing a row, so
// what is pinned is the classification and not the type.
it("returns a facet row as a container", async () => {
const listing = await list(atSpaceRoot(), READS_NOTHING);
expect(listing.rows.map((row) => row.kind)).toEqual([
"container",
"container",
]);
});
it("returns an index row as a slug", async () => {
const listing = await list(
inFacet("slugs"),
slugIndex([{ slug: "board", piece: HANDLE }]),
);
expect(listing.rows[0].kind).toBe("slug");
});
it("returns a piece row as a piece", async () => {
const listing = await list(
inFacet("pieces"),
spacePieces([{
id: HANDLE,
}]),
);
expect(listing.rows[0].kind).toBe("piece");
});
it("returns a key by what the cell holds at it", async () => {
// The boundary the classification draws, from both sides at once: a
// stream is the piece's callable, an array and an object are walked
// into, and everything else is where a path ends. `null` is the pair
// that straddles the object test, being an object to `typeof` and not
// a container to anything else. A stream holds nothing at its
// position; what makes it a callable is what its links declare.
const listing = await list(
atPiece(),
cellHolding({
"add-reply": {},
topics: [1, 2],
author: { name: "a" },
title: "a",
replies: 14,
done: false,
nothing: null,
}, ["add-reply"]),
);
expect(
Object.fromEntries(listing.rows.map((row) => [row.name, row.kind])),
).toEqual({
"add-reply": "callable",
topics: "container",
author: "container",
title: "value",
replies: "value",
done: "value",
nothing: "value",
});
});
it("returns a key holding a `$stream` that is not the marker as a container", async () => {
// The sentinel is the whole marker and not the key: a stored object
// that happens to carry the name is data, and calling it a callable
// would mint a handle `call` cannot use. `isStreamValue` is what
// draws that line, and this is the value on the far side of it.
const listing = await list(
atPiece(),
cellHolding({ notes: { $stream: "later" } }),
);
expect(listing.rows[0].kind).toBe("container");
});
it("returns a key still holding the retired stream sentinel as a callable", async () => {
// A document written before streams were declared by schema holds
// `{ $stream: true }` where a stream stands, and its links say
// nothing. That is a callable all the same, until no such document
// remains.
const listing = await list(
atPiece(),
cellHolding({ "add-reply": { $stream: true } }),
);
expect(listing.rows[0].kind).toBe("callable");
});
it("asks which keys are callables of exactly the keys it lists", async () => {
const asked: string[][] = [];
const listing = await list(atPiece(), {
...cellHolding({ a: 1, b: [1] }),
listCallableKeys: (_config, _path, keys) => {
asked.push([...keys]);
return Promise.resolve(new Set());
},
});
expect(asked).toEqual([["a", "b"]]);
expect(listing.rows.map((row) => row.kind)).toEqual([
"value",
"container",
]);
});
it("returns an array's indices as rows of what each element is", async () => {
const listing = await list(atPiece(), cellHolding([{ a: 1 }, "b"]));
expect(listing.rows.map((row) => [row.name, row.kind]))
.toEqual([["0", "container"], ["1", "value"]]);
});
});
describe("the held connection", () => {
// Each read takes its connection through `deps.loadPieces`, and what
// each case reads back is what that returns: the one controller this
// process holds, rather than one the read would have opened.
it("hands the slug index the connection this process holds", async () => {
const held = heldConnection();
let loaded: PiecesController | undefined;
await listPlace(CONFIG, inFacet("slugs").place, held.connection, {
...READS_NOTHING,
listSpaceSlugs: async (config, deps) => {
loaded = await deps?.loadPieces?.(config);
return [];
},
});
expect(loaded).toBe(held.pieces);
});
it("hands the piece listing the connection this process holds", async () => {
const held = heldConnection();
let loaded: PiecesController | undefined;
await listPlace(CONFIG, inFacet("pieces").place, held.connection, {
...READS_NOTHING,
listPieces: async (config, deps) => {
loaded = await deps?.loadPieces?.(config);
return [];
},
});
expect(loaded).toBe(held.pieces);
});
it("hands the cell listing the connection this process holds", async () => {
const held = heldConnection();
let loaded: PiecesController | undefined;
await listPlace(CONFIG, atPiece().place, held.connection, {
...READS_NOTHING,
getCellValue: async (config, _path, _options, deps) => {
loaded = await deps?.loadPieces?.(config);
return {};
},
});
expect(loaded).toBe(held.pieces);
});
it("hands the callable listing the connection this process holds", async () => {
const held = heldConnection();
let loaded: PiecesController | undefined;
await listPlace(CONFIG, atPiece().place, held.connection, {
...READS_NOTHING,
getCellValue: () => Promise.resolve({ title: 1 }),
listCallableKeys: async (config, _path, _keys, _options, deps) => {
loaded = await deps?.loadPieces?.(config);
return new Set();
},
});
expect(loaded).toBe(held.pieces);
});
});
describe("a read that failed outright", () => {
it("raises what the read raised", async () => {
await expect(list(atPiece(), READS_NOTHING)).rejects.toThrow(
"The cell was read.",
);
});
});
});
describe("listingLines()", () => {
// A row's line opens with the handle a person types to name it again and
// the name comes next, so every expectation here reads the two together.
// What the handle is written as is `handleFor`'s, read from the module
// rather than restated, so a case pins the layout and not the spelling.
/**
* Helper for the cases below, which is the row lines `listing` prints as.
* The bound comes back beside them and the cases that care ask for it.
*/
function lines(listing: Listing): readonly string[] {
return listingLines(listing).rows;
}
/** Helper for the cases below, which is a row of `kind` called `name`. */
function row(
name: string,
kind: RowKind = "value",
rest: Partial = {},
): ListingRow {
return { name, kind, operand: name, ...rest };
}
it("returns one line per row", () => {
expect(lines({ rows: [row("title"), row("body")] }))
.toEqual(["%1 title", "%2 body"]);
});
it("returns the rows numbered from one, in the order listed", () => {
// The numbers are written out rather than read back from `handleFor`,
// which is what keeps the case able to fail: a numbering that started
// at zero, or ran backwards, would move both sides of an assertion
// that asked the module what it numbers by.
const listing = { rows: ["a", "b", "c"].map((name) => row(name)) };
expect(lines(listing).map((line) => line.split(" ")[0]))
.toEqual(["%1", "%2", "%3"]);
});
it("numbers a row by the handle `handleFor` spells", () => {
// The other half, and the one that may read the module: what a listing
// numbers with is the spelling B2 reads `%n` back through, so the two
// are held to one another rather than each to a literal of its own.
const listing = { rows: [row("a")] };
expect(lines(listing)[0]).toBe(`${handleFor(1)} a`);
});
it("returns every handle in a column the widest of them sets", () => {
// Ten rows is where the widest handle grows a character, so the names
// of the first nine start one column further right than they would in
// a listing of nine. A column measured off the count rather than off
// each handle is what puts them all in one place.
const listing = { rows: Array.from({ length: 10 }, () => row("a")) };
expect(lines(listing)[0]).toBe(" %1 a");
expect(lines(listing)[9]).toBe("%10 a");
});
it("returns the operand as the name, not the name itself", () => {
expect(lines({
rows: [{
name: "..",
kind: "value",
operand: "./..",
}],
})).toEqual(["%1 ./.."]);
});
it("returns a marker in place of a name for a row with no operand", () => {
expect(lines({ rows: [{ name: "#b", kind: "value" }] }))
.toEqual(["%1 "]);
});
it("returns a marker that writes no name where the name holds a line break", () => {
expect(lines({ rows: [{ name: "a\nb", kind: "value" }] }))
.toEqual(["%1 "]);
});
it("returns a callable row annotated as callable, after its name", () => {
expect(lines({ rows: [row("add-reply", "callable")] }))
.toEqual(["%1 add-reply "]);
});
it("returns a row's own name as a marker, after the operand", () => {
// The operand keeps the column a reader copies out of, and the name
// stands beside it as shuttle's own words about the row. A name written
// in the operand's place would be a spelling `cd` does not take, printed
// where every other row prints one it does.
expect(lines({
rows: [row(HANDLE, "piece", { ownName: "Thermostat" })],
})).toEqual([`%1 ${HANDLE} `]);
});
it("returns a name holding an acted-on character shown as its glyph", () => {
// A name is a value a read served rather than one this module made, so
// it answers to the one-line rule an error answers to: the break stays
// one row, and the escape prints as the glyph naming it.
const line = lines({
rows: [row(HANDLE, "piece", { ownName: "two\nlines\u001b[31m" })],
})[0];
expect(line).toBe(`%1 ${HANDLE} `);
expect(/\p{Cc}/u.test(line)).toBe(false);
});
it("returns no annotation on a row of any other kind", () => {
// A projection over every kind rather than a spot check: the module
// annotates from a table closed against `RowKind`, and this is the
// reading of that table from outside. A kind added without a decision
// reds the module; a kind that started being annotated reds this.
const annotated: Record = {
container: false,
value: false,
callable: true,
piece: false,
slug: false,
watch: false,
};
for (const [kind, marked] of Object.entries(annotated)) {
expect(lines({ rows: [row("a", kind as RowKind)] })[0])
.toBe(marked ? "%1 a " : "%1 a");
}
});
it("returns a row's error with each acted-on character shown as its glyph", () => {
// A message is read rather than typed back, so it arrives whole and
// merely inert: nothing is dropped and nothing is described away. This
// is the live one — an error is the fabric's text, not shuttle's.
const line = lines({
rows: [row("board", "slug", {
error: "gone\u001b[31m: \u007f and \u009b too",
})],
})[0];
expect(line).toBe("%1 board ");
expect(/\p{Cc}/u.test(line)).toBe(false);
});
it("returns a bound with one shown the same way", () => {
// No bound the module builds can hold one — `SLUG_INDEX_BOUND` is a
// constant — so this case is constructed rather than found. What it
// guards is `listingLines`'s contract, which takes any listing a caller
// hands it, rather than the one call the module makes.
expect(listingLines({ rows: [], bound: "412 items\u001b[31m" }).bound)
.toBe("<412 items␛[31m>");
});
it("returns an error's angle brackets as they stand", () => {
// Escaping the acted-on class is not the marker's own decision, which
// is that brackets delimit for a reader and not for a parser. A payload
// may hold one, and this stays true beside the escaping.
expect(lines({ rows: [row("board", "slug", { error: "" })] }))
.toEqual(["%1 board >"]);
});
it("returns a message holding a line break with it written as a space", () => {
// The other rewrite, and a different decision: a break becomes a space
// so a message stays one row, where the rest become glyphs so a message
// cannot instruct the terminal.
expect(lines({ rows: [row("board", "slug", { error: "two\nlines" })] }))
.toEqual(["%1 board "]);
});
it("returns a marker that writes no name where the name holds a control character", () => {
// The doors refuse such a name, so no operand reaches the row — and the
// marker is then the one place left where it would still be written.
// Writing it there would put back on the screen exactly what refusing
// the name kept off it.
const line =
lines({ rows: [{ name: "ti\u001b[31mtle", kind: "value" }] })[0];
expect(line).toBe("%1 ");
expect(line.includes("\u001b")).toBe(false);
});
it("returns a row's error after its name", () => {
expect(lines({
rows: [row("board", "slug", { error: "No piece there." })],
})).toEqual(["%1 board "]);
});
it("returns a bound's line breaks written as spaces", () => {
// Nothing reaches this through `listPlace`, whose only bound is a module
// constant holding no break. `Listing` is a public type and this a
// public door, so the case drives the door rather than the path, and
// what it holds is that a bound answers to the same one-line rule an
// error does.
expect(listingLines({ rows: [], bound: "Two lines.\nboard" }).bound)
.toBe("");
});
it("returns the bound beside the rows rather than among them", () => {
// Beside rather than among, because a page treats the two differently:
// it cuts rows and always shows the bound, and `--limit` counts rows
// and never counts the bound. A bound folded in with the rows was one
// a limit of one spent its whole allowance on.
expect(listingLines({
rows: [row("board", "slug")],
bound: "these are the ones the index names",
})).toEqual({
bound: "",
rows: ["%1 board"],
});
});
it("returns no bound where the listing carries none", () => {
expect(listingLines({ rows: [row("board", "slug")] }))
.toEqual({ rows: ["%1 board"] });
});
});
describe("naming a row back", () => {
// The property, over a construction rather than a list: what a listing
// prints as a name, `cd` takes back to the row it was printed for; and a
// row it has no operand for prints no name at all, so nothing on the
// surface invites a reader to type a string that reaches somewhere else.
//
// A named row's name column never opens with `<`, which is what keeps a
// name and a marker apart on one surface. `quoteToken` is the whole of that
// mechanism and it lives a module away: `<` is one of the characters the
// grammar reserves, so a name holding one is printed quoted and can never
// open with it. Nothing in this module would notice if that stopped being
// true, which is why the assertion is here and the mutation that reds it
// is in `line.ts`.
//
// The property is over the name column of a printed line and not over the
// whole of it. A line opens with the row's handle, and a row that carries
// an error prints a marker after the name, so the name is the whole of the
// line for no row at all; and an error is text the fabric wrote, which may
// hold an odd quote and leave the line as a whole refusing to split. What
// holds of every named row is that the name column holds one token `cd`
// takes back to the row, and that anything after it is separated from it —
// which is what "copied out of the name column" means and all it can
// mean.
//
// A name is read twice on the way back, and both readings are asked. The
// option grammar reads it first — a token opening with `-` reaches a verb
// as a flag and never as an operand — and the place reads what is left. A
// name the first reading takes therefore fails the property however well
// it moves, which is why the operand is held against `readsAsOption`
// beside being moved with.
//
// What varies is as load-bearing as the property. Both kinds of row a
// listing can name vary — the piece a facet lists and the key a piece
// holds — and each candidate is driven through the read that produces that
// kind, so a rule that held for one kind and not the other cannot pass.
// The marks are the characters a reading is spelled with, at each of the
// three places within a name where one can sit, and alone.
const MARKS = [
" ",
"\t",
"\n",
"\r",
"\u00a0",
"\u2028",
"\u2029",
"/",
"~",
"#",
"@",
"-",
".",
"<",
">",
"%",
"!",
"|",
"'",
'"',
"\\",
];
/**
* Helper for the cases below, which is every awkward spelling of a name
* whose ordinary spelling is `head` followed by `tail`.
*/
function candidates(head: string, tail: string): string[] {
// The digit spellings sit here rather than among the marks: what they
// exercise is the conversion a path segment goes through, where a
// canonical index becomes a number and everything else stays a string.
// `-x y` is the one that separates the two readings of a token: it
// needs quoting, and its quoted form opens with a quote where the token
// itself opens with a dash. A row offered by name would be eaten by the
// option grammar, so the reference is what must be offered — and only an
// assertion about the *decoded* token can tell the two apart.
const values = [
"",
"3",
"0",
"01",
"1e21",
"-1",
"1.5",
"..",
"-",
"-x y",
];
for (const mark of MARKS) {
values.push(
mark + head + tail,
head + tail + mark,
head + mark + tail,
mark,
);
}
return values;
}
/**
* Helper for the case below, which is what a listing reports against the
* candidate at `index`, and nothing where it reports nothing.
*
* A row carrying one is the only shape where the printed line and the
* operand are different strings, so a construction with none of them says
* nothing about the rows the naming rule has most to say about. Two of
* these are chosen for what they do to a line rather than for what they
* say: one holds an odd quote, so the line as a whole does not split and
* only its front can be copied, and one holds a line break, which would
* open a second line with a name on it if the renderer wrote it as it
* stands.
*
* The break is written as an escape, and deliberately.
* `check-control-characters` governs a literal control codepoint below
* `0x20` other than the newline, so a literal break passes it; and the
* characters `MARKS` above spells — a no-break space and the Unicode line
* and paragraph separators — sit outside that range altogether. An awkward
* character in a fixture is written as an escape on its own account rather
* than on a gate's.
*/
const REPORTED = [
undefined,
"No piece carries that name.",
"It's gone.",
"Two lines.\nboard",
];
/**
* Helper for the case below, which is the place one level inside `place`
* called `name` — the row's own cell, built from the levels a position
* names rather than from any operand.
*/
function childOf(place: Place, name: string): Place {
const position = place.position;
switch (position.kind) {
case "root":
return {
...place,
position: { kind: "facet", space: SPACE, facet: name as Facet },
};
case "facet":
return {
...place,
position: { kind: "piece", space: SPACE, piece: name, path: [] },
};
case "piece":
return {
...place,
position: {
...position,
path: [
...position.path,
linkPathSegmentToCellPathSegment(name),
],
},
};
}
}
it("prints a name `cd` takes back to the row, and no name for a row it has no operand for", async () => {
let named = 0;
let unnamed = 0;
let reported = 0;
let reportedWithOperand = 0;
let reportedWithoutOperand = 0;
/**
* Helper for this case, which holds the property over one row and the
* line it printed, `standing` being a place at the row's own level.
*/
function check(
standing: CurrentPlace,
row: ListingRow,
line: string,
handle: string,
) {
// Everything true of every line goes above the branch. A row's error
// is printed whether or not the row has an operand, and this branch
// has twice been where a dimension reached one arm and not the other:
// the arm that returns early reads whatever someone remembered to add
// to it. Asserting the common half first is what stops the next
// dimension from landing on one side.
expect(/ $/.test(line)).toBe(row.error !== undefined);
if (row.error !== undefined) reported++;
// The handle column comes off first, and the property is over what is
// left. Reading it off the line rather than assuming its width is what
// makes the case say the same thing at every listing length.
expect(line.startsWith(`${handle} `)).toBe(true);
const printed = line.slice(handle.length + 1);
if (row.operand === undefined) {
expect(printed.startsWith("<")).toBe(true);
unnamed++;
if (row.error !== undefined) reportedWithoutOperand++;
return;
}
expect(printed.startsWith("<")).toBe(false);
// The row carries the operand `cd` reads and the line carries the
// token that spells it, so the property runs through the pair rather
// than around it: what is printed splits back to what the row holds,
// and what the row holds reaches the row. A name needing quotes is
// where the two differ, and where a walk of the printed characters
// would look for a key whose name holds the quotes.
// The line opens with the row's own token, and the markers follow it.
// Only that token is meant to be typed back — the rest is prose for a
// reader — so the properties below are asked of it alone.
const token = quoteToken(row.operand);
expect(printed.startsWith(token)).toBe(true);
const rest = printed.slice(token.length);
expect(rest === "").toBe(row.error === undefined);
if (rest !== "") expect(rest.startsWith(" <")).toBe(true);
// And they are asked of what the token *becomes*, not of how it looks.
// A line is split before its options are read (`runLine`), so what the
// option grammar receives is the decoded token: asking `readsAsOption`
// about the quoted spelling would pass a name like `-x`, whose quoted
// form opens with a quote, while the token the parser gets opens with
// the dash and is eaten.
const from = standing.place;
const split = splitLine(token);
expect(split.kind).toBe("split");
const tokens = split.kind === "split" ? split.tokens : [];
expect(tokens.length).toBe(1);
expect(tokens[0]).toBe(row.operand);
expect(readsAsOption(tokens[0])).toBe(false);
expect(moved(standing, tokens[0]).kind).toBe("moved");
expect(standing.place).toEqual(childOf(from, row.name));
named++;
if (row.error !== undefined) reportedWithOperand++;
}
const sources: {
standing: () => CurrentPlace;
deps: (names: string[]) => ListingDeps;
names: string[];
}[] = [
{
standing: () => inFacet("slugs"),
deps: (names) =>
slugIndex(names.map((slug, index) => ({
slug,
error: REPORTED[index % REPORTED.length],
}))),
names: candidates("boa", "rd"),
},
{
standing: () => inFacet("pieces"),
deps: (names) =>
spacePieces(names.map((id, index) => ({
id,
error: REPORTED[index % REPORTED.length],
}))),
names: candidates(HANDLE.slice(0, 10), HANDLE.slice(10)),
},
{
// A cell holds each name once, so the candidates are deduplicated
// for the two sources that stand names in as a cell's keys. The
// property is over each name rather than over the list, so a name
// written twice adds nothing to it either way.
standing: () => atPiece(),
deps: (names) => cellKeys(names),
names: unique(candidates("b", "c")),
},
{
standing: () => atPiece("topics"),
deps: (names) => cellKeys(names),
names: unique(candidates("b", "c")),
},
];
for (const source of sources) {
const listing = await list(
source.standing(),
source.deps(source.names),
);
const lines = listingLines(listing).rows;
expect(new Set(listing.rows.map((row) => row.name)))
.toEqual(new Set(source.names));
expect(listing.rows.length).toBe(source.names.length);
expect(lines.length).toBe(listing.rows.length);
const column = handleFor(listing.rows.length).length;
for (const [index, row] of listing.rows.entries()) {
check(
source.standing(),
row,
lines[index],
handleFor(index + 1).padStart(column),
);
}
}
// Every outcome has to occur, or the property holds for want of anything
// to hold over. The error clause above the branch is the strong one: it
// holds of every line, named or not, so a row that prints an error it
// does not carry, or drops one it does, reds it in either arm. What the
// counts add is that both arms are reached and that an error reaches
// each — `named` and `unnamed` for the arms, `reported` for an error
// anywhere, and one guard per arm for an error inside it, since the
// arms are where a row and its error are printed together.
//
// Two arms means two guards, and neither stands for the other: an
// error counted in the marker arm leaves the strong clause free to hold
// over a set with no named row carrying an error in it, and a clause
// with nothing to hold over is the want these counts exist to refuse.
expect(named).toBeGreaterThan(0);
expect(unnamed).toBeGreaterThan(0);
expect(reported).toBeGreaterThan(0);
expect(reportedWithOperand).toBeGreaterThan(0);
expect(reportedWithoutOperand).toBeGreaterThan(0);
});
});
});