Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license
This commit is contained in:
1 parent
95c80d04c9
commit
1d087af633
32 files changed
+4917
-890
No files matched your search
@@ -0,0 +1 @@
|
||||
qrcodegen>=1.8
|
||||
+825
-161
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user