Merge branch 'phase/45-presence-talk'
# Conflicts: # docs/phases/README.md # src/HSchool.Server/Api/PeopleModels.cs # src/HSchool.Server/Game/PersonCardReader.cs
This commit is contained in:
@@ -11,18 +11,18 @@
|
||||
|
||||
## Задачи
|
||||
|
||||
- [ ] Кадр присутствия: у человека, который в кружке, список id участников и id темы.
|
||||
- [x] Кадр присутствия: у человека, который в кружке, список id участников и id темы.
|
||||
Имена не строкой. Клиент собирает «говорит с Машей о футболе» из справочника и локали
|
||||
- [ ] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите
|
||||
- [ ] Не в кружке — пустой список, как сейчас только id/узел/состояние
|
||||
- [ ] Вкладка «Сейчас» на карточке согласована с тем же составом
|
||||
- [ ] Старый клиент отваливается Hello, как обычно
|
||||
- [x] Версия протокола +1. `ProtocolCodec.cs`, `protocol.ts`, `docs/protocol.md` в одном коммите
|
||||
- [x] Не в кружке — пустой список, как сейчас только id/узел/состояние
|
||||
- [x] Вкладка «Сейчас» на карточке согласована с тем же составом
|
||||
- [x] Старый клиент отваливается Hello, как обычно
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах
|
||||
- [ ] Имя человека не встречается в кадре присутствия
|
||||
- [ ] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра
|
||||
- [x] Круглый трип и байтовая раскладка человека с кружком и без — на обеих сторонах
|
||||
- [x] Имя человека не встречается в кадре присутствия
|
||||
- [x] Клиентский тест: локация показывает тему и имя напарника из directory, не из кадра
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
| Фаза | Статус | Зачем |
|
||||
| --- | --- | --- |
|
||||
| [44. Ссора и драка](44-quarrel-fight.md) | 🔄 | Жертва задиры, заступник, извинение, двор/физкультура |
|
||||
| [45. Кружок в присутствии](45-presence-talk.md) | 🔄 | Id участников и темы в кадре, протокол +1 |
|
||||
| [45. Кружок в присутствии](45-presence-talk.md) | ✅ | Id участников и темы в кадре, протокол +1 |
|
||||
|
||||
44 и 45 стоят на 42, можно параллельно.
|
||||
|
||||
|
||||
+13
-2
@@ -1,4 +1,4 @@
|
||||
# Wire protocol v8
|
||||
# Wire protocol v9
|
||||
|
||||
The client talks to the server two ways:
|
||||
|
||||
@@ -316,6 +316,9 @@ grid fetches `GET .../timetable?classId=` with it.
|
||||
|
||||
`activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel`
|
||||
is that def in the request locale. HTTP JSON is additive — no protocol version bump.
|
||||
`talkCircleMemberIds` is the live circle (including self, sorted) or `[]`; `talkTopicId` is the
|
||||
topic def id or `null` when not talking. Same ids as the presence frame; names are not repeated
|
||||
here either — the Now tab uses the directory and locale, like the location panel.
|
||||
|
||||
`skills` lists only keys the person has, not every `SkillDef` in the catalog. A first-year has
|
||||
no Chemistry; a related tongue from the name set may sit beside the native at a low value.
|
||||
@@ -358,6 +361,8 @@ compute thresholds. There is no school-wide opinions endpoint.
|
||||
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
|
||||
"activity": null,
|
||||
"activityLabel": null,
|
||||
"talkCircleMemberIds": [],
|
||||
"talkTopicId": null,
|
||||
"family": {
|
||||
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
|
||||
"children": [],
|
||||
@@ -983,6 +988,12 @@ Each person:
|
||||
| string | person id |
|
||||
| string | node id they occupy |
|
||||
| `u8` | `1` here, `2` walking |
|
||||
| `u8` | talk-circle member count, then that many person-id strings |
|
||||
| string | topic id (empty when not in a circle) |
|
||||
|
||||
Member ids are the live circle, including self, sorted by id. Count `0` and an empty topic mean
|
||||
the person is not talking — the same id/node/state as before the circle fields. Names are not
|
||||
on this frame; the client builds «говорит с Машей о футболе» from the HTTP directory and locale.
|
||||
|
||||
## Guarantees and limits
|
||||
|
||||
@@ -997,7 +1008,7 @@ Each person:
|
||||
the oldest, because a stale clock is worthless once a newer one exists.
|
||||
- The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out.
|
||||
|
||||
## Not in v8 yet
|
||||
## Not in v9 yet
|
||||
|
||||
Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server —
|
||||
the tree and the location panel are filtered on the client from the snapshot plus presence.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { PresenceState, type PresencePerson } from '../net/protocol.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { locationPersonLine, talkCircleText } from './talkCircle.ts';
|
||||
|
||||
const initial = getLocale();
|
||||
|
||||
afterEach(() => setLocale(initial));
|
||||
|
||||
describe('location talk line', () => {
|
||||
it('shows the partner name from the directory and the topic from locale, not from the frame', () => {
|
||||
setLocale('ru');
|
||||
const person: PresencePerson = {
|
||||
id: 'f0.c0',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
};
|
||||
const names = new Map([
|
||||
['f0.c0', 'Иванова Мария'],
|
||||
['f0.c1', 'Петрова Маша'],
|
||||
]);
|
||||
|
||||
const line = locationPersonLine(person, names);
|
||||
|
||||
expect(JSON.stringify(person)).not.toContain('Маша');
|
||||
expect(JSON.stringify(person)).not.toContain('Иванова');
|
||||
expect(line).toContain('Петрова Маша');
|
||||
expect(line).toContain(t('talkTopicTopicSport'));
|
||||
expect(line).toBe(`Иванова Мария — ${t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') })}`);
|
||||
});
|
||||
|
||||
it('omits talk text when the circle list is empty', () => {
|
||||
const person: PresencePerson = {
|
||||
id: 'f0.c0',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: [],
|
||||
talkTopicId: '',
|
||||
};
|
||||
const names = new Map([['f0.c0', 'Иванова Мария']]);
|
||||
|
||||
expect(locationPersonLine(person, names)).toBe('Иванова Мария');
|
||||
expect(talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PresenceState, type PresencePerson } from '../net/protocol.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
|
||||
const TOPIC_KEYS: Record<string, MessageKey> = {
|
||||
TopicStudy: 'talkTopicTopicStudy',
|
||||
TopicGames: 'talkTopicTopicGames',
|
||||
TopicFood: 'talkTopicTopicFood',
|
||||
TopicFamily: 'talkTopicTopicFamily',
|
||||
TopicSport: 'talkTopicTopicSport',
|
||||
TopicGossip: 'talkTopicTopicGossip',
|
||||
TopicRude: 'talkTopicTopicRude',
|
||||
TopicAppearance: 'talkTopicTopicAppearance',
|
||||
};
|
||||
|
||||
export function talkTopicLabel(topicId: string): string {
|
||||
const key = TOPIC_KEYS[topicId];
|
||||
return key === undefined ? topicId : t(key);
|
||||
}
|
||||
|
||||
export function talkPartnersLabel(
|
||||
selfId: string,
|
||||
memberIds: readonly string[],
|
||||
names: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const others = memberIds
|
||||
.filter((id) => id !== selfId)
|
||||
.map((id) => names.get(id) ?? id);
|
||||
if (others.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (others.length === 1) {
|
||||
return others[0] ?? '';
|
||||
}
|
||||
|
||||
const last = others[others.length - 1] ?? '';
|
||||
const head = others.slice(0, -1).join(', ');
|
||||
return t('talkPartnersJoin', { head, last });
|
||||
}
|
||||
|
||||
export function talkCircleText(
|
||||
selfId: string,
|
||||
memberIds: readonly string[],
|
||||
topicId: string,
|
||||
names: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
if (memberIds.length === 0 || topicId.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const partners = talkPartnersLabel(selfId, memberIds, names);
|
||||
if (partners.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return t('locationTalking', { partners, topic: talkTopicLabel(topicId) });
|
||||
}
|
||||
|
||||
export function locationPersonLine(person: PresencePerson, names: ReadonlyMap<string, string>): string {
|
||||
const name = names.get(person.id) ?? person.id;
|
||||
const base = person.state === PresenceState.Walking ? t('locationWalking', { name }) : name;
|
||||
const talk = talkCircleText(person.id, person.talkMemberIds, person.talkTopicId, names);
|
||||
return talk.length > 0 ? `${base} — ${talk}` : base;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ describe('t', () => {
|
||||
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
|
||||
.toBe('Класс 101 (18 · Математика · 5А)');
|
||||
expect(t('locationWalking', { name: 'Иванов' })).toBe('Иванов (walking)');
|
||||
expect(t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') }))
|
||||
.toBe('talking with Петрова Маша about sport');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -339,6 +339,16 @@ const ru = {
|
||||
presenceWalking: 'в пути ({name})',
|
||||
presenceAway: 'вне школы',
|
||||
locationWalking: '{name} (в пути)',
|
||||
locationTalking: 'говорит с {partners} о {topic}',
|
||||
talkPartnersJoin: '{head} и {last}',
|
||||
talkTopicTopicStudy: 'учёбе',
|
||||
talkTopicTopicGames: 'играх',
|
||||
talkTopicTopicFood: 'еде',
|
||||
talkTopicTopicFamily: 'семье',
|
||||
talkTopicTopicSport: 'спорте',
|
||||
talkTopicTopicGossip: 'сплетнях',
|
||||
talkTopicTopicRude: 'грубом',
|
||||
talkTopicTopicAppearance: 'внешности',
|
||||
timetableTitle: 'Расписание',
|
||||
timetableClass: 'Класс',
|
||||
timetableEmpty: 'Нет уроков.',
|
||||
@@ -700,6 +710,16 @@ const en: Messages = {
|
||||
presenceWalking: 'walking ({name})',
|
||||
presenceAway: 'off campus',
|
||||
locationWalking: '{name} (walking)',
|
||||
locationTalking: 'talking with {partners} about {topic}',
|
||||
talkPartnersJoin: '{head} and {last}',
|
||||
talkTopicTopicStudy: 'schoolwork',
|
||||
talkTopicTopicGames: 'games',
|
||||
talkTopicTopicFood: 'food',
|
||||
talkTopicTopicFamily: 'family',
|
||||
talkTopicTopicSport: 'sport',
|
||||
talkTopicTopicGossip: 'gossip',
|
||||
talkTopicTopicRude: 'rough talk',
|
||||
talkTopicTopicAppearance: 'looks',
|
||||
timetableTitle: 'Timetable',
|
||||
timetableClass: 'Class',
|
||||
timetableEmpty: 'No lessons.',
|
||||
|
||||
@@ -335,6 +335,8 @@ export interface PersonCard {
|
||||
readonly needs: readonly NeedStat[];
|
||||
readonly activity: string | null;
|
||||
readonly activityLabel: string | null;
|
||||
readonly talkCircleMemberIds: readonly string[];
|
||||
readonly talkTopicId: string | null;
|
||||
readonly family: {
|
||||
readonly parents: readonly PersonRel[];
|
||||
readonly children: readonly PersonRel[];
|
||||
|
||||
@@ -262,7 +262,9 @@ describe('decodeServerMessage', () => {
|
||||
+ 2
|
||||
+ 2 + personId.length
|
||||
+ 2 + personNode.length
|
||||
+ 1,
|
||||
+ 1
|
||||
+ 1
|
||||
+ 2,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
@@ -296,6 +298,10 @@ describe('decodeServerMessage', () => {
|
||||
new Uint8Array(buffer).set(personNode, offset);
|
||||
offset += personNode.length;
|
||||
view.setUint8(offset, PresenceState.Here);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint16(offset, 0, true);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'presence',
|
||||
@@ -308,7 +314,123 @@ describe('decodeServerMessage', () => {
|
||||
activityClass: '5А',
|
||||
},
|
||||
],
|
||||
people: [{ id: 'f0.c0', nodeId: 'classroom-101', state: PresenceState.Here }],
|
||||
people: [
|
||||
{
|
||||
id: 'f0.c0',
|
||||
nodeId: 'classroom-101',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: [],
|
||||
talkTopicId: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a presence person with talk-circle member ids and topic id', () => {
|
||||
const encoder = new TextEncoder();
|
||||
const putString = (target: DataView, at: number, text: Uint8Array): number => {
|
||||
target.setUint16(at, text.length, true);
|
||||
new Uint8Array(target.buffer).set(text, at + 2);
|
||||
return at + 2 + text.length;
|
||||
};
|
||||
|
||||
const corridor = encoder.encode('corridor-1');
|
||||
const selfId = encoder.encode('f0.c0');
|
||||
const partnerId = encoder.encode('f0.c1');
|
||||
const topic = encoder.encode('TopicSport');
|
||||
const buffer = new ArrayBuffer(
|
||||
7
|
||||
+ 2
|
||||
+ 2
|
||||
+ 2 + selfId.length
|
||||
+ 2 + corridor.length
|
||||
+ 1
|
||||
+ 1
|
||||
+ 2 + selfId.length
|
||||
+ 2 + partnerId.length
|
||||
+ 2 + topic.length,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
view.setInt32(1, 1, true);
|
||||
view.setUint16(5, 0, true);
|
||||
view.setUint16(7, 1, true);
|
||||
let offset = 9;
|
||||
offset = putString(view, offset, selfId);
|
||||
offset = putString(view, offset, corridor);
|
||||
view.setUint8(offset, PresenceState.Here);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 2);
|
||||
offset += 1;
|
||||
offset = putString(view, offset, selfId);
|
||||
offset = putString(view, offset, partnerId);
|
||||
putString(view, offset, topic);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'presence',
|
||||
schoolId: 1,
|
||||
nodes: [],
|
||||
people: [
|
||||
{
|
||||
id: 'f0.c0',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not carry a person display name in a presence frame', () => {
|
||||
const encoder = new TextEncoder();
|
||||
const putString = (target: DataView, at: number, text: Uint8Array): number => {
|
||||
target.setUint16(at, text.length, true);
|
||||
new Uint8Array(target.buffer).set(text, at + 2);
|
||||
return at + 2 + text.length;
|
||||
};
|
||||
|
||||
const corridor = encoder.encode('corridor-1');
|
||||
const selfId = encoder.encode('f0.c0');
|
||||
const topic = encoder.encode('TopicSport');
|
||||
const buffer = new ArrayBuffer(
|
||||
7 + 2 + 2 + selfId.length + 2 + corridor.length + 1 + 1 + 2 + selfId.length + 2 + topic.length,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
view.setInt32(1, 1, true);
|
||||
view.setUint16(5, 0, true);
|
||||
view.setUint16(7, 1, true);
|
||||
let offset = 9;
|
||||
offset = putString(view, offset, selfId);
|
||||
offset = putString(view, offset, corridor);
|
||||
view.setUint8(offset, PresenceState.Here);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 1);
|
||||
offset += 1;
|
||||
offset = putString(view, offset, selfId);
|
||||
putString(view, offset, topic);
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
expect(text).not.toContain('Мария');
|
||||
expect(text).not.toContain('Иванова');
|
||||
expect(text).not.toContain('Маша');
|
||||
expect(text).toContain('f0.c0');
|
||||
expect(text).toContain('TopicSport');
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'presence',
|
||||
schoolId: 1,
|
||||
nodes: [],
|
||||
people: [
|
||||
{
|
||||
id: 'f0.c0',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0'],
|
||||
talkTopicId: 'TopicSport',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 8;
|
||||
export const PROTOCOL_VERSION = 9;
|
||||
|
||||
export const MessageType = {
|
||||
ClientHello: 0x01,
|
||||
@@ -122,6 +122,8 @@ export interface PresencePerson {
|
||||
readonly id: string;
|
||||
readonly nodeId: string;
|
||||
readonly state: number;
|
||||
readonly talkMemberIds: readonly string[];
|
||||
readonly talkTopicId: string;
|
||||
}
|
||||
|
||||
export interface PresenceMessage {
|
||||
@@ -375,7 +377,24 @@ function decodePresence(view: DataView): PresenceMessage {
|
||||
offset = nodeId.next;
|
||||
const state = readU8(view, offset);
|
||||
offset += 1;
|
||||
people.push({ id: id.text, nodeId: nodeId.text, state });
|
||||
const memberCount = readU8(view, offset);
|
||||
offset += 1;
|
||||
const talkMemberIds: string[] = [];
|
||||
for (let member = 0; member < memberCount; member++) {
|
||||
const memberId = readString(view, offset);
|
||||
offset = memberId.next;
|
||||
talkMemberIds.push(memberId.text);
|
||||
}
|
||||
|
||||
const topic = readString(view, offset);
|
||||
offset = topic.next;
|
||||
people.push({
|
||||
id: id.text,
|
||||
nodeId: nodeId.text,
|
||||
state,
|
||||
talkMemberIds,
|
||||
talkTopicId: topic.text,
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'presence', schoolId, nodes, people };
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PresenceState } from '../net/protocol.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { GameScreen } from './gameScreen.ts';
|
||||
|
||||
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchDirectory: async () => [],
|
||||
};
|
||||
});
|
||||
|
||||
const initial = getLocale();
|
||||
|
||||
afterEach(() => setLocale(initial));
|
||||
|
||||
describe('GameScreen speed buttons', () => {
|
||||
it('shows five speed buttons ×½ ×1 ×2 ×5 ×10', () => {
|
||||
const onSetSpeed = vi.fn();
|
||||
@@ -47,3 +62,68 @@ describe('GameScreen speed buttons', () => {
|
||||
expect((screen.element.querySelector('.clock__controls') as HTMLElement).hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameScreen location talk', () => {
|
||||
it('shows talk partners from the directory on the location list, not from the frame', () => {
|
||||
setLocale('ru');
|
||||
const screen = new GameScreen({
|
||||
onLeave: () => {},
|
||||
onSetRunning: () => {},
|
||||
onSetSpeed: () => {},
|
||||
onSkip: () => {},
|
||||
});
|
||||
document.body.append(screen.element);
|
||||
screen.show({
|
||||
id: 1,
|
||||
name: 'Test',
|
||||
gameTime: '2012-03-31T10:00:00.000Z',
|
||||
running: true,
|
||||
speedIndex: 1,
|
||||
seed: 1,
|
||||
mine: true,
|
||||
});
|
||||
screen.applyMap(1, [
|
||||
{
|
||||
kind: 3,
|
||||
id: 'corridor-1',
|
||||
parentId: '',
|
||||
name: 'Коридор',
|
||||
pupilSlots: 0,
|
||||
items: [],
|
||||
positions: [],
|
||||
},
|
||||
]);
|
||||
const presence = {
|
||||
type: 'presence' as const,
|
||||
schoolId: 1,
|
||||
nodes: [{ id: 'corridor-1', count: 2, activitySubject: '', activityClass: '' }],
|
||||
people: [
|
||||
{
|
||||
id: 'f0.c0',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
},
|
||||
{
|
||||
id: 'f0.c1',
|
||||
nodeId: 'corridor-1',
|
||||
state: PresenceState.Here,
|
||||
talkMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(JSON.stringify(presence)).not.toContain('Маша');
|
||||
screen.applyPresence(1, presence);
|
||||
screen.applyDirectory([
|
||||
{ id: 'f0.c0', fullName: 'Иванова Мария' },
|
||||
{ id: 'f0.c1', fullName: 'Петрова Маша' },
|
||||
]);
|
||||
|
||||
const items = [...screen.element.querySelectorAll('.panel__list li')].map((item) => item.textContent ?? '');
|
||||
expect(items.some((text) => text.includes('Петрова Маша') && text.includes(t('talkTopicTopicSport')))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fetchDirectory, type School } from '../net/api.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { ManagementPanel } from './managementPanel.ts';
|
||||
import { PeoplePanel } from './peoplePanel.ts';
|
||||
import { formatPersonPlace } from './personCard.ts';
|
||||
import { locationPersonLine } from '../format/talkCircle.ts';
|
||||
|
||||
interface GameScreenOptions {
|
||||
readonly onLeave: () => void;
|
||||
@@ -204,8 +204,7 @@ export class GameScreen {
|
||||
|
||||
this.paintSkip();
|
||||
this.paintSeed();
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
this.bindPeople();
|
||||
}
|
||||
|
||||
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
|
||||
@@ -234,8 +233,7 @@ export class GameScreen {
|
||||
this.rebuildTree();
|
||||
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null);
|
||||
this.people.show(school.id);
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
this.bindPeople();
|
||||
this.showTab('map');
|
||||
this.inspect('location');
|
||||
this.showMode('overview');
|
||||
@@ -267,13 +265,19 @@ export class GameScreen {
|
||||
this.presence = message;
|
||||
this.paintTreeLabels();
|
||||
this.paintSelection();
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
this.bindPeople();
|
||||
if (message.people.some((person) => !this.directory.has(person.id))) {
|
||||
void this.loadDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
/** Names for the presence stream. The frame itself never carries display names. */
|
||||
applyDirectory(people: readonly { id: string; fullName: string }[]): void {
|
||||
this.directory = new Map(people.map((person) => [person.id, person.fullName]));
|
||||
this.paintSelection();
|
||||
this.bindPeople();
|
||||
}
|
||||
|
||||
update(clock: ClockMessage): void {
|
||||
this.applyClock(
|
||||
clock.gameTime,
|
||||
@@ -458,10 +462,7 @@ export class GameScreen {
|
||||
|
||||
const names = this.presence.people
|
||||
.filter((person) => person.nodeId === id)
|
||||
.map((person) => {
|
||||
const name = this.directory.get(person.id) ?? person.id;
|
||||
return person.state === PresenceState.Walking ? t('locationWalking', { name }) : name;
|
||||
});
|
||||
.map((person) => locationPersonLine(person, this.directory));
|
||||
names.sort((left, right) => left.localeCompare(right));
|
||||
return names;
|
||||
}
|
||||
@@ -476,6 +477,13 @@ export class GameScreen {
|
||||
return formatPersonPlace(person.state === PresenceState.Walking ? 'walking' : 'here', nodeName);
|
||||
}
|
||||
|
||||
private bindPeople(): void {
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
this.people.setNames(this.directory);
|
||||
this.management.setNames(this.directory);
|
||||
}
|
||||
|
||||
private async loadDirectory(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
@@ -489,10 +497,7 @@ export class GameScreen {
|
||||
return;
|
||||
}
|
||||
|
||||
this.directory = new Map(people.map((person) => [person.id, person.fullName]));
|
||||
this.paintSelection();
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
this.applyDirectory(people);
|
||||
} catch {
|
||||
// Names stay as ids until the next presence frame retries.
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ function personCard(): PersonCard {
|
||||
needs: [],
|
||||
activity: null,
|
||||
activityLabel: null,
|
||||
talkCircleMemberIds: [],
|
||||
talkTopicId: null,
|
||||
family: { parents: [], children: [], siblings: [], partners: [] },
|
||||
worn: [],
|
||||
carried: [],
|
||||
|
||||
@@ -194,6 +194,10 @@ export class ManagementPanel {
|
||||
this.cardHost.relocate((id) => this.placeOf(id));
|
||||
}
|
||||
|
||||
setNames(names: ReadonlyMap<string, string>): void {
|
||||
this.cardHost.setNames(names);
|
||||
}
|
||||
|
||||
private async reload(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
|
||||
@@ -184,6 +184,10 @@ export class PeoplePanel {
|
||||
this.cardHost.relocate((id) => this.placeOf(id));
|
||||
}
|
||||
|
||||
setNames(names: ReadonlyMap<string, string>): void {
|
||||
this.cardHost.setNames(names);
|
||||
}
|
||||
|
||||
private onFilterChange(): void {
|
||||
this.page = 1;
|
||||
void this.reload();
|
||||
|
||||
@@ -33,6 +33,8 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
||||
needs: [],
|
||||
activity: null,
|
||||
activityLabel: null,
|
||||
talkCircleMemberIds: [],
|
||||
talkTopicId: null,
|
||||
family: { parents: [], children: [], siblings: [], partners: [] },
|
||||
worn: [
|
||||
{
|
||||
@@ -351,4 +353,26 @@ describe('renderPersonCard', () => {
|
||||
expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome'));
|
||||
expect(root.querySelector('.people__now-activity')?.textContent).not.toBe('');
|
||||
});
|
||||
|
||||
it('shows the same talk-circle partners on Now as the location line uses', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
const names = new Map([
|
||||
['f0.c0', 'Иванова Мария'],
|
||||
['f0.c1', 'Петрова Маша'],
|
||||
]);
|
||||
renderPersonCard(
|
||||
root,
|
||||
card({
|
||||
talkCircleMemberIds: ['f0.c0', 'f0.c1'],
|
||||
talkTopicId: 'TopicSport',
|
||||
}),
|
||||
() => {},
|
||||
{ tab: 'now', personNames: names },
|
||||
);
|
||||
|
||||
expect(root.querySelector('.people__now-activity')?.textContent).toBe(
|
||||
t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '../net/api.ts';
|
||||
import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts';
|
||||
import { formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||
import { talkCircleText } from '../format/talkCircle.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
|
||||
@@ -46,6 +47,8 @@ export interface RenderPersonCardOptions {
|
||||
readonly swarmConfigured?: boolean;
|
||||
/** null while checking or when SwarmUI is not configured. */
|
||||
readonly swarmConnected?: boolean | null;
|
||||
/** id→fullName from the presence directory; used to label the talk circle on Now. */
|
||||
readonly personNames?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export function roleLabels(roles: readonly string[]): string {
|
||||
@@ -67,7 +70,21 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
|
||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
export function nowActivityText(card: PersonCard, away: boolean): string {
|
||||
export function nowActivityText(
|
||||
card: PersonCard,
|
||||
away: boolean,
|
||||
names: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
const talk = talkCircleText(
|
||||
card.id,
|
||||
card.talkCircleMemberIds,
|
||||
card.talkTopicId ?? '',
|
||||
names,
|
||||
);
|
||||
if (talk.length > 0) {
|
||||
return talk;
|
||||
}
|
||||
|
||||
if (away && (card.activityLabel === null || card.activityLabel.length === 0)) {
|
||||
return t('peopleAtHome');
|
||||
}
|
||||
@@ -324,7 +341,7 @@ function fillNow(
|
||||
log: PersonLogPage | null | undefined,
|
||||
options: RenderPersonCardOptions,
|
||||
): void {
|
||||
const activity = nowActivityText(card, away);
|
||||
const activity = nowActivityText(card, away, options.personNames);
|
||||
if (activity.length > 0) {
|
||||
parent.append(el('p', { class: 'people__now-activity', text: activity }));
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export class PersonCardHost {
|
||||
private container: HTMLElement | null = null;
|
||||
private onRelative: (id: string) => void = () => {};
|
||||
private placeOf: (id: string) => string = () => '';
|
||||
private personNames: ReadonlyMap<string, string> = new Map();
|
||||
private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void;
|
||||
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
|
||||
private logPage: PersonLogPage | null = null;
|
||||
@@ -172,6 +173,7 @@ export class PersonCardHost {
|
||||
onShowPortraitPrompt: (kind) => void this.togglePortraitPrompt(kind),
|
||||
swarmConfigured: this.swarmConfigured,
|
||||
swarmConnected: this.swarmConnected,
|
||||
personNames: this.personNames,
|
||||
});
|
||||
const overview = container.querySelector('[data-card-tab="overview"]');
|
||||
if (overview instanceof HTMLElement) {
|
||||
@@ -301,7 +303,20 @@ export class PersonCardHost {
|
||||
line.textContent = place;
|
||||
const now = this.container?.querySelector('.people__now-activity');
|
||||
if (now instanceof HTMLElement) {
|
||||
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'));
|
||||
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'), this.personNames);
|
||||
}
|
||||
}
|
||||
|
||||
setNames(names: ReadonlyMap<string, string>): void {
|
||||
this.personNames = names;
|
||||
if (this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.container?.querySelector('.people__now-activity');
|
||||
if (now instanceof HTMLElement) {
|
||||
const place = this.placeOf(this.painted.id);
|
||||
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'), names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +98,22 @@ public sealed record PresenceNode(
|
||||
string ActivitySubject = "",
|
||||
string ActivityClass = "");
|
||||
|
||||
/// <summary>One on-campus person. Names are resolved over HTTP, not on this frame.</summary>
|
||||
public sealed record PresencePerson(string Id, string NodeId, byte State);
|
||||
/// <summary>
|
||||
/// One on-campus person. Names are resolved over HTTP, not on this frame. Talk members and
|
||||
/// topic are ids; an empty list means the person is not in a circle.
|
||||
/// </summary>
|
||||
public sealed record PresencePerson(
|
||||
string Id,
|
||||
string NodeId,
|
||||
byte State,
|
||||
IReadOnlyList<string> TalkMemberIds,
|
||||
string TalkTopicId)
|
||||
{
|
||||
public PresencePerson(string id, string nodeId, byte state)
|
||||
: this(id, nodeId, state, [], "")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live occupancy of an open school, about twice a second. Counts and people cover the whole
|
||||
|
||||
@@ -202,6 +202,14 @@ public static class ProtocolCodec
|
||||
foreach (var person in message.People)
|
||||
{
|
||||
size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte);
|
||||
var members = person.TalkMemberIds ?? [];
|
||||
size += sizeof(byte);
|
||||
foreach (var memberId in members)
|
||||
{
|
||||
size += StringSize(memberId);
|
||||
}
|
||||
|
||||
size += StringSize(person.TalkTopicId ?? "");
|
||||
}
|
||||
|
||||
return size;
|
||||
@@ -242,9 +250,22 @@ public static class ProtocolCodec
|
||||
writer.WriteUInt16((ushort)message.People.Count);
|
||||
foreach (var person in message.People)
|
||||
{
|
||||
var members = person.TalkMemberIds ?? [];
|
||||
if (members.Count > byte.MaxValue)
|
||||
{
|
||||
throw new ProtocolException($"Presence person {person.Id} has {members.Count} talk members; u8 count cannot hold it.");
|
||||
}
|
||||
|
||||
writer.WriteString(person.Id);
|
||||
writer.WriteString(person.NodeId);
|
||||
writer.WriteByte(person.State);
|
||||
writer.WriteByte((byte)members.Count);
|
||||
foreach (var memberId in members)
|
||||
{
|
||||
writer.WriteString(memberId);
|
||||
}
|
||||
|
||||
writer.WriteString(person.TalkTopicId ?? "");
|
||||
}
|
||||
|
||||
return writer.Position;
|
||||
@@ -403,7 +424,18 @@ public static class ProtocolCodec
|
||||
var people = new PresencePerson[personCount];
|
||||
for (var i = 0; i < personCount; i++)
|
||||
{
|
||||
people[i] = new PresencePerson(reader.ReadString(), reader.ReadString(), reader.ReadByte());
|
||||
var id = reader.ReadString();
|
||||
var nodeId = reader.ReadString();
|
||||
var state = reader.ReadByte();
|
||||
var memberCount = reader.ReadByte();
|
||||
var members = new string[memberCount];
|
||||
for (var member = 0; member < memberCount; member++)
|
||||
{
|
||||
members[member] = reader.ReadString();
|
||||
}
|
||||
|
||||
var topicId = reader.ReadString();
|
||||
people[i] = new PresencePerson(id, nodeId, state, members, topicId);
|
||||
}
|
||||
|
||||
return new ServerPresenceMessage(schoolId, nodes, people);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
||||
public const byte Version = 8;
|
||||
public const byte Version = 9;
|
||||
|
||||
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
||||
public const int MaxMessageSize = 8 * 1024;
|
||||
|
||||
@@ -83,7 +83,10 @@ internal sealed record PersonCardResponse(
|
||||
bool HasFullBody = false,
|
||||
string? CustomPortraitPrompt = null,
|
||||
PersonConnectionsResponse? Connections = null,
|
||||
DefLabelResponse? Orientation = null);
|
||||
DefLabelResponse? Orientation = null,
|
||||
IReadOnlyList<string>? TalkCircleMemberIds = null,
|
||||
string? TalkTopicId = null);
|
||||
|
||||
|
||||
internal sealed record WornItemResponse(
|
||||
string DefName,
|
||||
|
||||
@@ -60,6 +60,8 @@ internal static class PersonCardReader
|
||||
activityLabel = activityId;
|
||||
}
|
||||
|
||||
var circle = school.TalkCircleOf(personId);
|
||||
|
||||
return new PersonCardResponse(
|
||||
person.Id,
|
||||
person.Name.Full,
|
||||
@@ -90,7 +92,9 @@ internal static class PersonCardReader
|
||||
|| person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)),
|
||||
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)),
|
||||
Connections: Connections(roster, person, catalog, locale),
|
||||
Orientation: OrientationOf(person, catalog, locale));
|
||||
Orientation: OrientationOf(person, catalog, locale),
|
||||
TalkCircleMemberIds: circle?.MemberIds ?? [],
|
||||
TalkTopicId: circle?.TopicId);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
|
||||
|
||||
@@ -25,10 +25,13 @@ internal static class PresenceFrame
|
||||
}
|
||||
|
||||
var walking = row.Path.Count > 0 || row.RemainingMinutes > 0;
|
||||
var circle = school.TalkCircleOf(row.PersonId);
|
||||
people.Add(new PresencePerson(
|
||||
row.PersonId,
|
||||
row.NodeId,
|
||||
walking ? PresenceState.Walking : PresenceState.Here));
|
||||
walking ? PresenceState.Walking : PresenceState.Here,
|
||||
circle?.MemberIds ?? [],
|
||||
circle?.TopicId ?? ""));
|
||||
counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -662,3 +662,6 @@ public sealed record PresenceSnapshot(
|
||||
string? GoalId = null,
|
||||
float GoalWeight = 0f,
|
||||
string? GoalAction = null);
|
||||
|
||||
/// <summary>Ids of an active talk circle. Names are resolved by the client, not here.</summary>
|
||||
public sealed record TalkCirclePresence(string TopicId, IReadOnlyList<string> MemberIds);
|
||||
|
||||
@@ -218,6 +218,23 @@ public sealed class School : IDisposable
|
||||
|
||||
public IReadOnlyList<PresenceSnapshot> CapturePresence() => PresenceSystem.Capture(this);
|
||||
|
||||
/// <summary>
|
||||
/// Live circle for the presence frame and the person card. Null when this person is not talking.
|
||||
/// Member ids include self and are sorted; names stay off the wire.
|
||||
/// </summary>
|
||||
public TalkCirclePresence? TalkCircleOf(string personId)
|
||||
{
|
||||
if (!TalkCircleByPerson.TryGetValue(personId, out var circleId)
|
||||
|| !TalkCirclesById.TryGetValue(circleId, out var circle))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var members = circle.Members.ToArray();
|
||||
Array.Sort(members, StringComparer.Ordinal);
|
||||
return new TalkCirclePresence(circle.TopicId, members);
|
||||
}
|
||||
|
||||
public void RestorePresence(IReadOnlyList<PresenceSnapshot>? saved) => PresenceSystem.Restore(this, saved);
|
||||
|
||||
public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this);
|
||||
|
||||
@@ -246,7 +246,120 @@ public class ProtocolCodecTests
|
||||
Assert.Equal("f0.c0", read.People[0].Id);
|
||||
Assert.Equal("classroom-101", read.People[0].NodeId);
|
||||
Assert.Equal(PresenceState.Here, read.People[0].State);
|
||||
Assert.Empty(read.People[0].TalkMemberIds);
|
||||
Assert.Equal("", read.People[0].TalkTopicId);
|
||||
Assert.Equal(PresenceState.Walking, read.People[1].State);
|
||||
Assert.Empty(read.People[1].TalkMemberIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_PersonWithoutCircle_WritesEmptyMemberListAndEmptyTopic()
|
||||
{
|
||||
var message = new ServerPresenceMessage(
|
||||
1,
|
||||
[],
|
||||
[new PresencePerson("f0.c0", "corridor-1", PresenceState.Here)]);
|
||||
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
|
||||
|
||||
var length = ProtocolCodec.WritePresence(buffer, message);
|
||||
|
||||
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
|
||||
Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
|
||||
Assert.Equal((ushort)0, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
|
||||
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(7, 2)));
|
||||
|
||||
var offset = 9;
|
||||
offset = AssertWireString(buffer, offset, "f0.c0");
|
||||
offset = AssertWireString(buffer, offset, "corridor-1");
|
||||
Assert.Equal(PresenceState.Here, buffer[offset]);
|
||||
offset += 1;
|
||||
Assert.Equal(0, buffer[offset]);
|
||||
offset += 1;
|
||||
offset = AssertWireString(buffer, offset, "");
|
||||
Assert.Equal(length, offset);
|
||||
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
|
||||
|
||||
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
|
||||
Assert.Empty(read.People[0].TalkMemberIds);
|
||||
Assert.Equal("", read.People[0].TalkTopicId);
|
||||
Assert.Equal(message.People[0].Id, read.People[0].Id);
|
||||
Assert.Equal(message.People[0].NodeId, read.People[0].NodeId);
|
||||
Assert.Equal(message.People[0].State, read.People[0].State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_PersonWithCircle_RoundTripsMemberIdsAndTopic()
|
||||
{
|
||||
var members = new[] { "f0.c0", "f0.c1" };
|
||||
var message = new ServerPresenceMessage(
|
||||
1,
|
||||
[new PresenceNode("corridor-1", 2)],
|
||||
[new PresencePerson("f0.c0", "corridor-1", PresenceState.Here, members, "TopicSport")]);
|
||||
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
|
||||
|
||||
var length = ProtocolCodec.WritePresence(buffer, message);
|
||||
|
||||
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
|
||||
Assert.Equal(1, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
|
||||
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
|
||||
|
||||
var offset = 7;
|
||||
offset = AssertWireString(buffer, offset, "corridor-1");
|
||||
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
|
||||
offset += 2;
|
||||
Assert.Equal(0, buffer[offset]);
|
||||
offset += 1;
|
||||
Assert.Equal((ushort)1, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
|
||||
offset += 2;
|
||||
offset = AssertWireString(buffer, offset, "f0.c0");
|
||||
offset = AssertWireString(buffer, offset, "corridor-1");
|
||||
Assert.Equal(PresenceState.Here, buffer[offset]);
|
||||
offset += 1;
|
||||
Assert.Equal(2, buffer[offset]);
|
||||
offset += 1;
|
||||
offset = AssertWireString(buffer, offset, "f0.c0");
|
||||
offset = AssertWireString(buffer, offset, "f0.c1");
|
||||
offset = AssertWireString(buffer, offset, "TopicSport");
|
||||
Assert.Equal(length, offset);
|
||||
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
|
||||
|
||||
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
|
||||
Assert.Equal(["f0.c0", "f0.c1"], read.People[0].TalkMemberIds);
|
||||
Assert.Equal("TopicSport", read.People[0].TalkTopicId);
|
||||
Assert.Equal(message.SchoolId, read.SchoolId);
|
||||
Assert.Equal(message.People[0].Id, read.People[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_DoesNotContainPersonDisplayName()
|
||||
{
|
||||
var message = new ServerPresenceMessage(
|
||||
1,
|
||||
[],
|
||||
[new PresencePerson(
|
||||
"f0.c0",
|
||||
"corridor-1",
|
||||
PresenceState.Here,
|
||||
["f0.c0", "f0.c1"],
|
||||
"TopicSport")]);
|
||||
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
|
||||
|
||||
ProtocolCodec.WritePresence(buffer, message);
|
||||
var text = System.Text.Encoding.UTF8.GetString(buffer);
|
||||
|
||||
Assert.DoesNotContain("Мария", text);
|
||||
Assert.DoesNotContain("Иванова", text);
|
||||
Assert.DoesNotContain("Маша", text);
|
||||
Assert.Contains("f0.c0", text);
|
||||
Assert.Contains("TopicSport", text);
|
||||
}
|
||||
|
||||
private static int AssertWireString(byte[] buffer, int offset, string expected)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(expected);
|
||||
Assert.Equal((ushort)bytes.Length, BitConverter.ToUInt16(buffer.AsSpan(offset, 2)));
|
||||
Assert.Equal(bytes, buffer.AsSpan(offset + 2, bytes.Length).ToArray());
|
||||
return offset + 2 + bytes.Length;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -26,6 +26,13 @@ public class TalkCircleTests
|
||||
var rows = school.CapturePresence();
|
||||
Assert.Equal(TalkActions.Chat, rows.Single(row => row.PersonId == first.Id).ActionId);
|
||||
Assert.Equal(TalkActions.Chat, rows.Single(row => row.PersonId == second.Id).ActionId);
|
||||
var circle = school.TalkCircleOf(first.Id);
|
||||
Assert.NotNull(circle);
|
||||
Assert.Contains(first.Id, circle.MemberIds);
|
||||
Assert.Contains(second.Id, circle.MemberIds);
|
||||
Assert.False(string.IsNullOrEmpty(circle.TopicId));
|
||||
Assert.Equal(circle.MemberIds, school.TalkCircleOf(second.Id)?.MemberIds);
|
||||
Assert.Null(school.TalkCircleOf("missing"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user