Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license

This commit is contained in:
ack committed 2026-10-10 19:01:20 +02:00
1 parent 95c80d04c9
commit 1d087af633
32 files changed
+4917 -890

No files matched your search

+1
View File
@@ -0,0 +1 @@
qrcodegen>=1.8
+825 -161
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -22,6 +22,9 @@ import kokkoja # noqa: E402
TOKEN = "test-token"
PAGE = b'<svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1"/>\n'
PNG = b"\x89PNG\r\n\x1a\n" + b"the rest of a picture"
TEXT_ONLY_LAYER = (b'<g data-layer="abc" data-name="Andy"><g data-text="t1" data-x="10" data-y="10">'
b'<desc>hello</desc><text><tspan>hello</tspan></text></g></g>')
WRITTEN_PAGE = b'<svg data-kokkoja="1"><g data-layer="a"><g data-stroke="s"/></g></svg>\n'
@@ -64,6 +67,16 @@ class LocalProgramTest(unittest.TestCase):
self.assertEqual(self.json("GET", "/config.json", token=None),
(200, {"relay": "ws://relay.example:8770", "storage": "files", "site": ""}))
def test_the_relay_is_remembered(self):
config = pathlib.Path(self.directory.name) / "relay_config"
self.assertEqual(kokkoja.remembered_relay(config), (None, False))
self.assertEqual(kokkoja.remembered_relay(config, "wss://kokkoja.example/relay"),
("wss://kokkoja.example/relay", False))
self.assertEqual(kokkoja.remembered_relay(config), ("wss://kokkoja.example/relay", True))
self.assertEqual(kokkoja.remembered_relay(config, " ws://127.0.0.1:8770 "),
("ws://127.0.0.1:8770", False))
self.assertEqual((config / "relay").read_text(), "ws://127.0.0.1:8770\n")
def test_site_comes_from_a_kokkoja_server_relay_or_is_given(self):
self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay", ""), "https://kokkoja.example/")
self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay/", ""), "https://kokkoja.example/")
@@ -149,6 +162,10 @@ class LocalProgramTest(unittest.TestCase):
self.assertEqual(self.json("GET", "/api/notebooks/Andy")[1]["pages"],
[f"{today}_01.svg", f"{today}_02.svg", "Current.svg"])
# A page with typed text and nothing else is not blank either.
self.request("PUT", current, body=b'<svg data-kokkoja="1">' + TEXT_ONLY_LAYER + b"</svg>\n")
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_03.svg", "new": True}))
def test_a_kept_page_is_dated_when_it_was_last_changed(self):
notebook = self.notebooks / "Andy"
notebook.mkdir(parents=True)
+85
View File
@@ -0,0 +1,85 @@
"""The client's QR encoder against Nayuki's reference encoder (qrcodegen).
Every version from 1 to 40 at every level, and the invitation link's
shape, must give the same modules, mask choice included. Needs node
(the type checker's) and qrcodegen (tests/requirements.txt). Two other
encoders were tried as the reference: python-qrcode agrees with ours at
a given mask but chooses masks by its own reading of the penalty rules,
and segno's modules differ even at the same mask.
"""
import json
import os
import subprocess
import unittest
from qrcodegen import QrCode, QrSegment
CLIENT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "js")
LEVELS = {"L": QrCode.Ecc.LOW, "M": QrCode.Ecc.MEDIUM, "Q": QrCode.Ecc.QUARTILE, "H": QrCode.Ecc.HIGH}
HARNESS = """
import { qrModules } from %s;
const texts = JSON.parse(await new Promise((resolve) => {
let data = '';
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
}));
console.log(JSON.stringify(texts.map(([text, level]) =>
qrModules(text, level).map((row) => row.map((dark) => dark ? 1 : 0)))));
"""
def encode_in_client(texts):
"""[(text, level)] -> list of module matrices (lists of 0/1 rows)."""
harness = HARNESS % json.dumps(os.path.join(CLIENT, "qr_code.js"))
result = subprocess.run(["node", "--input-type=module", "-e", harness],
input=json.dumps(texts), capture_output=True, text=True, check=True)
return json.loads(result.stdout)
def reference(data, level):
"""Nayuki's code for those bytes in byte mode: (version, mask, matrix)."""
code = QrCode.encode_segments([QrSegment.make_bytes(data)], LEVELS[level], boostecl=False)
size = code.get_size()
matrix = [[1 if code.get_module(x, y) else 0 for x in range(size)] for y in range(size)]
return code.get_version(), code.get_mask(), matrix
def longest_text(version, level):
"""The longest text of that version at that level in byte mode."""
bits = QrCode._get_num_data_codewords(version, LEVELS[level]) * 8
length = (bits - 4 - (8 if version < 10 else 16)) // 8
assert reference(b"x" * length, level)[0] == version
return "".join(chr(33 + (i * 7) % 90) for i in range(length))
class QrCodeTest(unittest.TestCase):
def check(self, texts):
matrices = encode_in_client(texts)
for (text, level), matrix in zip(texts, matrices):
version, mask, expected = reference(text.encode("utf-8"), level)
with self.subTest(level=level, length=len(text.encode("utf-8")), version=version, mask=mask):
self.assertEqual(matrix, expected)
def test_invitation_link(self):
link = ("https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23"
"AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
("Übung 1: äöü", "M")])
def test_every_version(self):
texts = []
for level in "LMQH":
for version in range(1, 41):
# The longest text of that version, so that the block
# structure is filled, and a short one with much padding.
full = longest_text(version, level)
texts.append((full, level))
texts.append((full[: len(full) // 2 + 1], level))
self.check(texts)
if __name__ == "__main__":
unittest.main()