docs(start): tested CultNet first-call snippets (C#, TypeScript, Rust, Python) #11
5 changed files with 549 additions and 4 deletions
|
|
@ -16,10 +16,10 @@ this page.
|
|||
|
||||
| | CultCache | CultMath | Geometry | CultNet | CultMesh |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
|
||||
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet |
|
||||
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet |
|
||||
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
|
||||
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
|
||||
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
|
||||
|
||||
For the whole
|
||||
picture of what each piece is for, see the
|
||||
|
|
@ -222,6 +222,244 @@ def first_call() -> Item:
|
|||
return reopened.get_required(item_doc, "item:potion")
|
||||
```
|
||||
|
||||
## CultNet first calls
|
||||
|
||||
CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same
|
||||
shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack
|
||||
payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each
|
||||
block is verbatim from the test that runs it; a second test in the same file fails if the block stops
|
||||
matching.
|
||||
|
||||
### C# CultNet first call
|
||||
|
||||
Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs),
|
||||
run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`.
|
||||
|
||||
```csharp
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
|
||||
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class Note
|
||||
{
|
||||
[Key(0)] [CultName] public string Title = string.Empty;
|
||||
[Key(1)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
public static class CultNetFirstCall
|
||||
{
|
||||
public static async Task<Note?> Run()
|
||||
{
|
||||
var inbox = new CultCache(); // the receiver's cache
|
||||
var documents = new CultNetDocumentRegistry();
|
||||
var received = new TaskCompletionSource<bool>();
|
||||
|
||||
// Receive: a loopback listener that applies each raw document put to the inbox.
|
||||
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
|
||||
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
|
||||
{
|
||||
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
|
||||
received.TrySetResult(true);
|
||||
});
|
||||
|
||||
// Send: a typed document as a cultnet.document_put_raw.v0 message.
|
||||
using var client = new TcpFramedCultNetSchemaClient();
|
||||
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
|
||||
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
|
||||
client.SendCultNet(documents.CreateRawDocumentPutMessage(
|
||||
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
|
||||
|
||||
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
return inbox.Get<Note>(handle.Key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
|
||||
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
|
||||
from `../src` where the block reads `cultnet-ts`. More: `packages/cultnet-ts/README.md`.
|
||||
|
||||
```ts
|
||||
import { createServer, connect } from "node:net";
|
||||
import { z } from "zod";
|
||||
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
createTcpFramedCultNetPeer,
|
||||
defineCultNetDocumentBinding,
|
||||
} from "cultnet-ts";
|
||||
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schemaId: "gamecult.first_call_note.v1",
|
||||
schemaName: "gamecult.first_call_note",
|
||||
schemaVersion: "gamecult.first_call_note.v1",
|
||||
schema: z.object({ title: z.string(), body: z.string() }),
|
||||
name: "title",
|
||||
});
|
||||
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
|
||||
|
||||
async function firstCall() {
|
||||
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
|
||||
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
|
||||
|
||||
// Receiver: a loopback listener whose peer applies each document put to the inbox.
|
||||
const received = new Promise<void>((resolve, reject) => {
|
||||
const server = createServer((socket) => {
|
||||
const peer = createTcpFramedCultNetPeer(socket, options);
|
||||
peer.on("invalidMessage", reject);
|
||||
peer.on("message", async (message) => {
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
|
||||
await documents.applyRawDocumentPutMessage(inbox, message);
|
||||
peer.close();
|
||||
server.close();
|
||||
resolve();
|
||||
});
|
||||
}).listen(0, "127.0.0.1", () => {
|
||||
// Sender: connect, build a typed put message, send it.
|
||||
const { port } = server.address() as { port: number };
|
||||
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
|
||||
sender.send(
|
||||
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
|
||||
title: "Hello",
|
||||
body: "sent over CultNet",
|
||||
}),
|
||||
);
|
||||
received.finally(() => sender.close());
|
||||
});
|
||||
});
|
||||
await received;
|
||||
return inbox.get(noteDocument, "note:hello");
|
||||
}
|
||||
```
|
||||
|
||||
### Rust CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
|
||||
`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
|
||||
|
||||
```rust
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
use cultnet_rs::{
|
||||
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
|
||||
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
|
||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "note")]
|
||||
struct Note {
|
||||
#[cultcache(key = 0)]
|
||||
title: String,
|
||||
#[cultcache(key = 1)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn first_call() -> anyhow::Result<Note> {
|
||||
let mut documents = CultNetDocumentRegistry::new();
|
||||
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
))?;
|
||||
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
|
||||
|
||||
// Receiver: a loopback listener that applies the document put it receives to its own cache.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let receiver = thread::spawn({
|
||||
let (documents, profile) = (documents.clone(), profile("receiver"));
|
||||
move || -> anyhow::Result<Note> {
|
||||
let (stream, _) = listener.accept()?;
|
||||
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
|
||||
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let mut inbox = CultCache::new();
|
||||
inbox.register_entry_type::<Note>()?;
|
||||
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
|
||||
}
|
||||
});
|
||||
|
||||
// Sender: build a typed put message, encode it for the wire, send one frame.
|
||||
let put = documents.create_raw_document_put_message(
|
||||
"put-1",
|
||||
"note:hello",
|
||||
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
|
||||
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
|
||||
.send("schema", &bytes)?;
|
||||
|
||||
receiver.join().expect("receiver thread")
|
||||
}
|
||||
```
|
||||
|
||||
### Python CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by
|
||||
`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on
|
||||
`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`.
|
||||
|
||||
```python
|
||||
import socket
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from cultcache_py import CultCache, define_document_type
|
||||
from cultnet_py import (
|
||||
apply_raw_document_record,
|
||||
document_put_raw,
|
||||
encode_frame,
|
||||
parse_message,
|
||||
read_frame,
|
||||
schema_document_map,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
note_doc = define_document_type(
|
||||
"note",
|
||||
encode=lambda note: asdict(note),
|
||||
decode=lambda raw: Note(**raw),
|
||||
name="title",
|
||||
)
|
||||
|
||||
|
||||
def first_call() -> Note:
|
||||
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
|
||||
|
||||
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
|
||||
put = document_put_raw(
|
||||
message_id="put-1",
|
||||
key="note:hello",
|
||||
schema_id=note_doc.catalog_entry().schema_id,
|
||||
stored_at="2026-01-01T00:00:00Z",
|
||||
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
|
||||
)
|
||||
sender.sendall(encode_frame(put.to_bytes()))
|
||||
|
||||
# Receive: read the frame, parse the message, apply its record to a second cache.
|
||||
message = parse_message(read_frame(receiver.makefile("rb")))
|
||||
assert message.schema_version == "cultnet.document_put_raw.v0"
|
||||
inbox = CultCache.builder().register_document_type(note_doc).build()
|
||||
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
|
||||
sender.close()
|
||||
receiver.close()
|
||||
return inbox.get_required(note_doc, "note:hello")
|
||||
```
|
||||
|
||||
## Where next
|
||||
|
||||
- Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.
|
||||
|
|
|
|||
72
packages/cultnet-py/tests/test_first_call.py
Normal file
72
packages/cultnet-py/tests/test_first_call.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""The Python CultNet first call from docs/start/README.md. The docs show the region between the
|
||||
`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# snippet:begin
|
||||
import socket
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from cultcache_py import CultCache, define_document_type
|
||||
from cultnet_py import (
|
||||
apply_raw_document_record,
|
||||
document_put_raw,
|
||||
encode_frame,
|
||||
parse_message,
|
||||
read_frame,
|
||||
schema_document_map,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
note_doc = define_document_type(
|
||||
"note",
|
||||
encode=lambda note: asdict(note),
|
||||
decode=lambda raw: Note(**raw),
|
||||
name="title",
|
||||
)
|
||||
|
||||
|
||||
def first_call() -> Note:
|
||||
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
|
||||
|
||||
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
|
||||
put = document_put_raw(
|
||||
message_id="put-1",
|
||||
key="note:hello",
|
||||
schema_id=note_doc.catalog_entry().schema_id,
|
||||
stored_at="2026-01-01T00:00:00Z",
|
||||
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
|
||||
)
|
||||
sender.sendall(encode_frame(put.to_bytes()))
|
||||
|
||||
# Receive: read the frame, parse the message, apply its record to a second cache.
|
||||
message = parse_message(read_frame(receiver.makefile("rb")))
|
||||
assert message.schema_version == "cultnet.document_put_raw.v0"
|
||||
inbox = CultCache.builder().register_document_type(note_doc).build()
|
||||
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
|
||||
sender.close()
|
||||
receiver.close()
|
||||
return inbox.get_required(note_doc, "note:hello")
|
||||
# snippet:end
|
||||
|
||||
|
||||
class FirstCallTests(unittest.TestCase):
|
||||
def test_a_typed_document_crosses_a_pipe_into_a_second_cache(self) -> None:
|
||||
self.assertEqual(first_call(), Note("Hello", "sent over CultNet"))
|
||||
|
||||
def test_docs_show_the_tested_snippet(self) -> None:
|
||||
source = Path(__file__).read_text(encoding="utf-8")
|
||||
snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0]
|
||||
docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8")
|
||||
self.assertIn("```python\n" + snippet + "```", docs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
77
packages/cultnet-rs/tests/first_call.rs
Normal file
77
packages/cultnet-rs/tests/first_call.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! The Rust CultNet first call from docs/start/README.md. The docs show the region between the
|
||||
//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ.
|
||||
|
||||
use cultcache_rs::{CultCache, DatabaseEntry};
|
||||
|
||||
// snippet:begin
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
use cultnet_rs::{
|
||||
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
|
||||
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
|
||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "note")]
|
||||
struct Note {
|
||||
#[cultcache(key = 0)]
|
||||
title: String,
|
||||
#[cultcache(key = 1)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
fn first_call() -> anyhow::Result<Note> {
|
||||
let mut documents = CultNetDocumentRegistry::new();
|
||||
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
"gamecult.first_call_note.v1".to_string(),
|
||||
))?;
|
||||
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
|
||||
|
||||
// Receiver: a loopback listener that applies the document put it receives to its own cache.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let receiver = thread::spawn({
|
||||
let (documents, profile) = (documents.clone(), profile("receiver"));
|
||||
move || -> anyhow::Result<Note> {
|
||||
let (stream, _) = listener.accept()?;
|
||||
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
|
||||
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let mut inbox = CultCache::new();
|
||||
inbox.register_entry_type::<Note>()?;
|
||||
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
|
||||
}
|
||||
});
|
||||
|
||||
// Sender: build a typed put message, encode it for the wire, send one frame.
|
||||
let put = documents.create_raw_document_put_message(
|
||||
"put-1",
|
||||
"note:hello",
|
||||
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
|
||||
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
|
||||
.send("schema", &bytes)?;
|
||||
|
||||
receiver.join().expect("receiver thread")
|
||||
}
|
||||
// snippet:end
|
||||
|
||||
#[test]
|
||||
fn a_typed_document_crosses_loopback_into_a_second_cache() {
|
||||
let note = first_call().unwrap();
|
||||
assert_eq!(note, Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_show_the_tested_snippet() {
|
||||
let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap();
|
||||
let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len();
|
||||
let end = source.find("// snippet:end\n").unwrap();
|
||||
let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap();
|
||||
let block = format!("```rust\n{}```", &source[begin..end]);
|
||||
assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim");
|
||||
}
|
||||
78
packages/cultnet-ts/test/first-call.test.ts
Normal file
78
packages/cultnet-ts/test/first-call.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it
|
||||
// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show
|
||||
// the region between the `snippet` markers verbatim, except that the package import reads
|
||||
// "cultnet-ts" where this file reads "../src"; the docs test below fails when they differ.
|
||||
// snippet:begin
|
||||
import { createServer, connect } from "node:net";
|
||||
import { z } from "zod";
|
||||
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
createTcpFramedCultNetPeer,
|
||||
defineCultNetDocumentBinding,
|
||||
} from "../src";
|
||||
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schemaId: "gamecult.first_call_note.v1",
|
||||
schemaName: "gamecult.first_call_note",
|
||||
schemaVersion: "gamecult.first_call_note.v1",
|
||||
schema: z.object({ title: z.string(), body: z.string() }),
|
||||
name: "title",
|
||||
});
|
||||
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
|
||||
|
||||
async function firstCall() {
|
||||
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
|
||||
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
|
||||
|
||||
// Receiver: a loopback listener whose peer applies each document put to the inbox.
|
||||
const received = new Promise<void>((resolve, reject) => {
|
||||
const server = createServer((socket) => {
|
||||
const peer = createTcpFramedCultNetPeer(socket, options);
|
||||
peer.on("invalidMessage", reject);
|
||||
peer.on("message", async (message) => {
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
|
||||
await documents.applyRawDocumentPutMessage(inbox, message);
|
||||
peer.close();
|
||||
server.close();
|
||||
resolve();
|
||||
});
|
||||
}).listen(0, "127.0.0.1", () => {
|
||||
// Sender: connect, build a typed put message, send it.
|
||||
const { port } = server.address() as { port: number };
|
||||
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
|
||||
sender.send(
|
||||
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
|
||||
title: "Hello",
|
||||
body: "sent over CultNet",
|
||||
}),
|
||||
);
|
||||
received.finally(() => sender.close());
|
||||
});
|
||||
});
|
||||
await received;
|
||||
return inbox.get(noteDocument, "note:hello");
|
||||
}
|
||||
// snippet:end
|
||||
|
||||
test("send a typed document over loopback and receive it into a second cache", async () => {
|
||||
const note = await firstCall();
|
||||
assert.deepEqual(note, { title: "Hello", body: "sent over CultNet" });
|
||||
});
|
||||
|
||||
test("docs show the tested snippet", async () => {
|
||||
// Compiled to dist-test/test/first-call.test.js, so the package root is two levels up.
|
||||
const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8");
|
||||
const snippet = source
|
||||
.split("// snippet:begin\n")[1]
|
||||
.split("// snippet:end\n")[0]
|
||||
.replace('"../src"', '"cultnet-ts"');
|
||||
const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n");
|
||||
assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts");
|
||||
});
|
||||
80
tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs
Normal file
80
tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
|
||||
// snippet:begin
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
|
||||
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class Note
|
||||
{
|
||||
[Key(0)] [CultName] public string Title = string.Empty;
|
||||
[Key(1)] public string Body = string.Empty;
|
||||
}
|
||||
|
||||
public static class CultNetFirstCall
|
||||
{
|
||||
public static async Task<Note?> Run()
|
||||
{
|
||||
var inbox = new CultCache(); // the receiver's cache
|
||||
var documents = new CultNetDocumentRegistry();
|
||||
var received = new TaskCompletionSource<bool>();
|
||||
|
||||
// Receive: a loopback listener that applies each raw document put to the inbox.
|
||||
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
|
||||
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
|
||||
{
|
||||
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
|
||||
received.TrySetResult(true);
|
||||
});
|
||||
|
||||
// Send: a typed document as a cultnet.document_put_raw.v0 message.
|
||||
using var client = new TcpFramedCultNetSchemaClient();
|
||||
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
|
||||
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
|
||||
client.SendCultNet(documents.CreateRawDocumentPutMessage(
|
||||
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
|
||||
|
||||
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
return inbox.Get<Note>(handle.Key);
|
||||
}
|
||||
}
|
||||
// snippet:end
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
// The C# CultNet first-call snippet that docs/start/README.md shows verbatim: define a typed document, send it
|
||||
// over a loopback TCP pipe, receive it into a second cache. DocsShowTheTestedSnippet fails when they differ.
|
||||
public class CultNetFirstCallTests
|
||||
{
|
||||
[Test]
|
||||
public async Task SendATypedDocumentOverLoopbackAndReceiveItIntoASecondCache()
|
||||
{
|
||||
var note = await CultNetFirstCall.Run();
|
||||
Assert.That(note, Is.Not.Null);
|
||||
Assert.That(note!.Title, Is.EqualTo("Hello"));
|
||||
Assert.That(note.Body, Is.EqualTo("sent over CultNet"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DocsShowTheTestedSnippet()
|
||||
{
|
||||
var source = File.ReadAllText(SourcePath());
|
||||
const string begin = "// snippet:begin\n";
|
||||
var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length;
|
||||
var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)];
|
||||
var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md"));
|
||||
Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"),
|
||||
"docs/start/README.md no longer shows tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs verbatim");
|
||||
}
|
||||
|
||||
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue