mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: implement outbound message status utilities for icon and title resolution, add tests for LXMF state and method reporting
This commit is contained in:
parent
4f6bccea18
commit
9f75e3db18
5 changed files with 421 additions and 41 deletions
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -1837,6 +1837,11 @@ import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from
|
|||
import { applyRelayShareLink, findRelayUriInContent, parseMeshchatRelayUri } from "../../js/relayLinkUtils.js";
|
||||
import { LXMF_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions";
|
||||
import { createOutboundQueue } from "../../js/outboundSendQueue";
|
||||
import {
|
||||
isOpportunisticDeferredDelivery as isOpportunisticDeferredDeliveryStatus,
|
||||
outboundBubbleStatusIconName as resolveOutboundBubbleStatusIconName,
|
||||
outboundBubbleStatusTitleKey as resolveOutboundBubbleStatusTitleKey,
|
||||
} from "../../js/outboundMessageStatus.js";
|
||||
import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
|
||||
import "emoji-picker-element";
|
||||
import StickerView from "../stickers/StickerView.vue";
|
||||
|
|
@ -4971,45 +4976,11 @@ export default {
|
|||
return this.outboundBubbleStatusTitle(lxmfMessage);
|
||||
},
|
||||
outboundBubbleStatusIconName(lxmfMessage) {
|
||||
if (!lxmfMessage) {
|
||||
return "check";
|
||||
}
|
||||
const state = lxmfMessage.state;
|
||||
const method = lxmfMessage.method;
|
||||
if (state === "delivered") {
|
||||
if (method === "propagated") {
|
||||
return "email-check-outline";
|
||||
}
|
||||
if (method === "paper") {
|
||||
return "note-check-outline";
|
||||
}
|
||||
return "check-all";
|
||||
}
|
||||
if (["sent", "propagated", "unknown"].includes(state)) {
|
||||
if (method === "propagated") {
|
||||
return "email-outline";
|
||||
}
|
||||
if (method === "paper") {
|
||||
return "note-outline";
|
||||
}
|
||||
return "check";
|
||||
}
|
||||
return "check";
|
||||
return resolveOutboundBubbleStatusIconName(lxmfMessage);
|
||||
},
|
||||
outboundBubbleStatusTitle(lxmfMessage) {
|
||||
if (!lxmfMessage) {
|
||||
return "";
|
||||
}
|
||||
if (lxmfMessage.state === "delivered") {
|
||||
if (lxmfMessage.method === "propagated") {
|
||||
return this.$t("messages.outbound_delivered_propagated");
|
||||
}
|
||||
return this.$t("messages.outbound_delivered");
|
||||
}
|
||||
if (lxmfMessage.method === "propagated") {
|
||||
return this.$t("messages.outbound_on_propagation_node");
|
||||
}
|
||||
return this.$t("messages.outbound_sent_network");
|
||||
const key = resolveOutboundBubbleStatusTitleKey(lxmfMessage);
|
||||
return key ? this.$t(key) : "";
|
||||
},
|
||||
isOutboundWaitingBubble(chatItem) {
|
||||
return Boolean(chatItem?.is_outbound && chatItem?.lxmf_message?._pendingPathfinding);
|
||||
|
|
@ -5302,10 +5273,7 @@ export default {
|
|||
return this.$t("messages.failed_waiting_announce_tooltip");
|
||||
},
|
||||
isOpportunisticDeferredDelivery(lxmfMessage) {
|
||||
if (!lxmfMessage) {
|
||||
return false;
|
||||
}
|
||||
return lxmfMessage.method === "opportunistic" && lxmfMessage.state === "failed";
|
||||
return isOpportunisticDeferredDeliveryStatus(lxmfMessage);
|
||||
},
|
||||
async warmPathToPeer() {
|
||||
await this.refreshPeerPath({ warm: true });
|
||||
|
|
|
|||
71
meshchatx/src/frontend/js/outboundMessageStatus.js
Normal file
71
meshchatx/src/frontend/js/outboundMessageStatus.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// SPDX-License-Identifier: 0BSD AND MIT
|
||||
/**
|
||||
* Outbound LXMF bubble status icons and i18n title keys.
|
||||
*
|
||||
* Backend emits API strings from LXMF.LXMessage via convert_lxmf_state_to_string
|
||||
* and convert_lxmf_method_to_string. Propagation is a method, never a state.
|
||||
*/
|
||||
|
||||
const SENT_LIKE_STATES = new Set(["sent", "propagated", "unknown"]);
|
||||
|
||||
/**
|
||||
* @param {{ state?: string, method?: string } | null | undefined} lxmfMessage
|
||||
* @returns {string} MDI kebab icon name
|
||||
*/
|
||||
export function outboundBubbleStatusIconName(lxmfMessage) {
|
||||
if (!lxmfMessage) {
|
||||
return "check";
|
||||
}
|
||||
const state = lxmfMessage.state;
|
||||
const method = lxmfMessage.method;
|
||||
if (state === "delivered") {
|
||||
if (method === "propagated") {
|
||||
return "email-check-outline";
|
||||
}
|
||||
if (method === "paper") {
|
||||
return "note-check-outline";
|
||||
}
|
||||
return "check-all";
|
||||
}
|
||||
if (SENT_LIKE_STATES.has(state)) {
|
||||
if (method === "propagated") {
|
||||
return "email-outline";
|
||||
}
|
||||
if (method === "paper") {
|
||||
return "note-outline";
|
||||
}
|
||||
return "check";
|
||||
}
|
||||
return "check";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ state?: string, method?: string } | null | undefined} lxmfMessage
|
||||
* @returns {string | null} i18n key under messages.*, or null when no title
|
||||
*/
|
||||
export function outboundBubbleStatusTitleKey(lxmfMessage) {
|
||||
if (!lxmfMessage) {
|
||||
return null;
|
||||
}
|
||||
if (lxmfMessage.state === "delivered") {
|
||||
if (lxmfMessage.method === "propagated") {
|
||||
return "messages.outbound_delivered_propagated";
|
||||
}
|
||||
return "messages.outbound_delivered";
|
||||
}
|
||||
if (lxmfMessage.method === "propagated") {
|
||||
return "messages.outbound_on_propagation_node";
|
||||
}
|
||||
return "messages.outbound_sent_network";
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunistic FAILED is deferred wait in the UI, not a hard failure badge.
|
||||
* @param {{ state?: string, method?: string } | null | undefined} lxmfMessage
|
||||
*/
|
||||
export function isOpportunisticDeferredDelivery(lxmfMessage) {
|
||||
if (!lxmfMessage) {
|
||||
return false;
|
||||
}
|
||||
return lxmfMessage.method === "opportunistic" && lxmfMessage.state === "failed";
|
||||
}
|
||||
200
tests/backend/test_lxmf_status_oracle.py
Normal file
200
tests/backend/test_lxmf_status_oracle.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
"""Oracle tests for LXMF state and method reporting.
|
||||
|
||||
Guarantee: MeshChatX API state and method strings match LXMF.LXMessage
|
||||
constants. Propagation is a delivery method, never a lifecycle state.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import LXMF
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from meshchatx.src.backend.lxmf_utils import (
|
||||
convert_lxmf_message_to_dict,
|
||||
convert_lxmf_method_to_string,
|
||||
convert_lxmf_state_to_string,
|
||||
)
|
||||
|
||||
# Independent contract: derived from LXMF constant names, not from MeshChatX
|
||||
# if/elif branches. If LXMF adds a state, this table must be updated deliberately.
|
||||
LXMF_STATE_ORACLE = {
|
||||
LXMF.LXMessage.GENERATING: "generating",
|
||||
LXMF.LXMessage.OUTBOUND: "outbound",
|
||||
LXMF.LXMessage.SENDING: "sending",
|
||||
LXMF.LXMessage.SENT: "sent",
|
||||
LXMF.LXMessage.DELIVERED: "delivered",
|
||||
LXMF.LXMessage.REJECTED: "rejected",
|
||||
LXMF.LXMessage.CANCELLED: "cancelled",
|
||||
LXMF.LXMessage.FAILED: "failed",
|
||||
}
|
||||
|
||||
LXMF_METHOD_ORACLE = {
|
||||
LXMF.LXMessage.OPPORTUNISTIC: "opportunistic",
|
||||
LXMF.LXMessage.DIRECT: "direct",
|
||||
LXMF.LXMessage.PROPAGATED: "propagated",
|
||||
LXMF.LXMessage.PAPER: "paper",
|
||||
}
|
||||
|
||||
API_LIFECYCLE_STATES = frozenset(LXMF_STATE_ORACLE.values()) | {"unknown"}
|
||||
API_DELIVERY_METHODS = frozenset(LXMF_METHOD_ORACLE.values()) | {"unknown"}
|
||||
|
||||
# LXMF has no lifecycle state for "on propagation node". That meaning is
|
||||
# method=PROPAGATED + state=SENT (or later DELIVERED).
|
||||
assert "propagated" not in LXMF_STATE_ORACLE.values()
|
||||
|
||||
|
||||
def _msg(**attrs):
|
||||
return SimpleNamespace(**attrs)
|
||||
|
||||
|
||||
def _mock_lxmessage(*, state, method):
|
||||
mock_msg = MagicMock(spec=LXMF.LXMessage)
|
||||
mock_msg.hash = b"m" * 16
|
||||
mock_msg.source_hash = b"s" * 16
|
||||
mock_msg.destination_hash = b"d" * 16
|
||||
mock_msg.incoming = False
|
||||
mock_msg.state = state
|
||||
mock_msg.progress = 1.0 if state == LXMF.LXMessage.DELIVERED else 0.0
|
||||
mock_msg.method = method
|
||||
mock_msg.delivery_attempts = 0
|
||||
mock_msg.title = b""
|
||||
mock_msg.content = b"hello"
|
||||
mock_msg.timestamp = 1_700_000_000
|
||||
mock_msg.rssi = None
|
||||
mock_msg.snr = None
|
||||
mock_msg.q = None
|
||||
mock_msg.get_fields.return_value = {}
|
||||
mock_msg.outbound_ticket = None
|
||||
mock_msg.stamp_cost = None
|
||||
mock_msg.stamp = None
|
||||
mock_msg.defer_stamp = False
|
||||
return mock_msg
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("lxmf_state", "expected"), list(LXMF_STATE_ORACLE.items()))
|
||||
def test_oracle_state_constant_maps_to_api_string(lxmf_state, expected):
|
||||
assert convert_lxmf_state_to_string(_msg(state=lxmf_state)) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("lxmf_method", "expected"), list(LXMF_METHOD_ORACLE.items()))
|
||||
def test_oracle_method_constant_maps_to_api_string(lxmf_method, expected):
|
||||
assert convert_lxmf_method_to_string(_msg(method=lxmf_method)) == expected
|
||||
|
||||
|
||||
@given(state=st.sampled_from(list(LXMF_STATE_ORACLE.keys())))
|
||||
@settings(max_examples=64, deadline=None)
|
||||
def test_oracle_known_states_never_unknown(state):
|
||||
reported = convert_lxmf_state_to_string(_msg(state=state))
|
||||
assert reported == LXMF_STATE_ORACLE[state]
|
||||
assert reported != "unknown"
|
||||
|
||||
|
||||
@given(method=st.sampled_from(list(LXMF_METHOD_ORACLE.keys())))
|
||||
@settings(max_examples=32, deadline=None)
|
||||
def test_oracle_known_methods_never_unknown(method):
|
||||
reported = convert_lxmf_method_to_string(_msg(method=method))
|
||||
assert reported == LXMF_METHOD_ORACLE[method]
|
||||
assert reported != "unknown"
|
||||
|
||||
|
||||
@given(
|
||||
state=st.integers(min_value=0, max_value=512).filter(
|
||||
lambda v: v not in LXMF_STATE_ORACLE
|
||||
)
|
||||
)
|
||||
@settings(max_examples=80, deadline=None)
|
||||
def test_oracle_unknown_state_ints_report_unknown(state):
|
||||
assert convert_lxmf_state_to_string(_msg(state=state)) == "unknown"
|
||||
|
||||
|
||||
@given(
|
||||
method=st.integers(min_value=0, max_value=64).filter(
|
||||
lambda v: v not in LXMF_METHOD_ORACLE
|
||||
)
|
||||
)
|
||||
@settings(max_examples=40, deadline=None)
|
||||
def test_oracle_unknown_method_ints_report_unknown(method):
|
||||
assert convert_lxmf_method_to_string(_msg(method=method)) == "unknown"
|
||||
|
||||
|
||||
@given(
|
||||
state=st.sampled_from(list(LXMF_STATE_ORACLE.keys())),
|
||||
method=st.sampled_from(list(LXMF_METHOD_ORACLE.keys())),
|
||||
)
|
||||
@settings(max_examples=64, deadline=None)
|
||||
def test_oracle_message_dict_reports_lxmf_status_pair(state, method):
|
||||
"""API dict state/method must match converters and stay in closed vocabularies."""
|
||||
payload = convert_lxmf_message_to_dict(_mock_lxmessage(state=state, method=method))
|
||||
expected_state = LXMF_STATE_ORACLE[state]
|
||||
expected_method = LXMF_METHOD_ORACLE[method]
|
||||
assert payload["state"] == expected_state
|
||||
assert payload["method"] == expected_method
|
||||
assert payload["state"] in API_LIFECYCLE_STATES
|
||||
assert payload["method"] in API_DELIVERY_METHODS
|
||||
# Propagation is method-only. Lifecycle must never be labeled "propagated".
|
||||
assert payload["state"] != "propagated"
|
||||
|
||||
|
||||
def test_oracle_propagated_sent_is_on_node_not_delivered():
|
||||
"""Parked on a propagation node is SENT + PROPAGATED, not DELIVERED."""
|
||||
payload = convert_lxmf_message_to_dict(
|
||||
_mock_lxmessage(state=LXMF.LXMessage.SENT, method=LXMF.LXMessage.PROPAGATED)
|
||||
)
|
||||
assert payload["state"] == "sent"
|
||||
assert payload["method"] == "propagated"
|
||||
|
||||
|
||||
def test_oracle_propagated_delivered_means_recipient_got_mail():
|
||||
payload = convert_lxmf_message_to_dict(
|
||||
_mock_lxmessage(
|
||||
state=LXMF.LXMessage.DELIVERED,
|
||||
method=LXMF.LXMessage.PROPAGATED,
|
||||
)
|
||||
)
|
||||
assert payload["state"] == "delivered"
|
||||
assert payload["method"] == "propagated"
|
||||
|
||||
|
||||
def test_oracle_direct_delivered_is_not_propagated():
|
||||
payload = convert_lxmf_message_to_dict(
|
||||
_mock_lxmessage(state=LXMF.LXMessage.DELIVERED, method=LXMF.LXMessage.DIRECT)
|
||||
)
|
||||
assert payload["state"] == "delivered"
|
||||
assert payload["method"] == "direct"
|
||||
|
||||
|
||||
def test_oracle_opportunistic_failed_still_reports_failed_state():
|
||||
"""Backend keeps LXMF FAILED. UI may treat opportunistic+failed as deferred wait."""
|
||||
payload = convert_lxmf_message_to_dict(
|
||||
_mock_lxmessage(
|
||||
state=LXMF.LXMessage.FAILED,
|
||||
method=LXMF.LXMessage.OPPORTUNISTIC,
|
||||
)
|
||||
)
|
||||
assert payload["state"] == "failed"
|
||||
assert payload["method"] == "opportunistic"
|
||||
|
||||
|
||||
def test_oracle_paper_method_preserved_across_states():
|
||||
for state, state_name in (
|
||||
(LXMF.LXMessage.SENT, "sent"),
|
||||
(LXMF.LXMessage.DELIVERED, "delivered"),
|
||||
):
|
||||
payload = convert_lxmf_message_to_dict(
|
||||
_mock_lxmessage(state=state, method=LXMF.LXMessage.PAPER)
|
||||
)
|
||||
assert payload["state"] == state_name
|
||||
assert payload["method"] == "paper"
|
||||
|
||||
|
||||
def test_lxmf_status_oracle_proved():
|
||||
"""PROVED marker: full known state/method tables convert correctly."""
|
||||
for lxmf_state, expected in LXMF_STATE_ORACLE.items():
|
||||
assert convert_lxmf_state_to_string(_msg(state=lxmf_state)) == expected
|
||||
for lxmf_method, expected in LXMF_METHOD_ORACLE.items():
|
||||
assert convert_lxmf_method_to_string(_msg(method=lxmf_method)) == expected
|
||||
print("LXMF_STATUS_REPORTING_PROVED")
|
||||
141
tests/frontend/outboundMessageStatus.oracle.test.js
Normal file
141
tests/frontend/outboundMessageStatus.oracle.test.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// SPDX-License-Identifier: 0BSD AND MIT
|
||||
/**
|
||||
* Oracle tests for outbound LXMF status icons and titles.
|
||||
*
|
||||
* Guarantee: UI status glyphs and title keys follow the API state/method
|
||||
* strings that backend convert_lxmf_* emits from LXMF.LXMessage. Propagation
|
||||
* is method-only. Opportunistic+failed is deferred wait, not a hard fail badge.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isOpportunisticDeferredDelivery,
|
||||
outboundBubbleStatusIconName,
|
||||
outboundBubbleStatusTitleKey,
|
||||
} from "@/js/outboundMessageStatus.js";
|
||||
|
||||
/** Closed vocabulary aligned with meshchatx/src/backend/lxmf_utils.py converters. */
|
||||
const API_STATES = [
|
||||
"generating",
|
||||
"outbound",
|
||||
"sending",
|
||||
"sent",
|
||||
"delivered",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
"failed",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown"];
|
||||
|
||||
/**
|
||||
* Independent UI oracle. Must stay in sync with product intent, not by
|
||||
* importing the implementation under test into the expected table.
|
||||
*/
|
||||
function oracleIconName({ state, method }) {
|
||||
if (state === "delivered") {
|
||||
if (method === "propagated") return "email-check-outline";
|
||||
if (method === "paper") return "note-check-outline";
|
||||
return "check-all";
|
||||
}
|
||||
if (state === "sent" || state === "propagated" || state === "unknown") {
|
||||
if (method === "propagated") return "email-outline";
|
||||
if (method === "paper") return "note-outline";
|
||||
return "check";
|
||||
}
|
||||
return "check";
|
||||
}
|
||||
|
||||
function oracleTitleKey({ state, method }) {
|
||||
if (state === "delivered") {
|
||||
if (method === "propagated") return "messages.outbound_delivered_propagated";
|
||||
return "messages.outbound_delivered";
|
||||
}
|
||||
if (method === "propagated") return "messages.outbound_on_propagation_node";
|
||||
return "messages.outbound_sent_network";
|
||||
}
|
||||
|
||||
function oracleDeferred({ state, method }) {
|
||||
return method === "opportunistic" && state === "failed";
|
||||
}
|
||||
|
||||
describe("outboundMessageStatus LXMF oracle", () => {
|
||||
it("maps every API state x method pair to the independent icon oracle", () => {
|
||||
for (const state of API_STATES) {
|
||||
for (const method of API_METHODS) {
|
||||
const msg = { state, method };
|
||||
expect(outboundBubbleStatusIconName(msg)).toBe(oracleIconName(msg));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("maps every API state x method pair to the independent title-key oracle", () => {
|
||||
for (const state of API_STATES) {
|
||||
for (const method of API_METHODS) {
|
||||
const msg = { state, method };
|
||||
expect(outboundBubbleStatusTitleKey(msg)).toBe(oracleTitleKey(msg));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("treats only opportunistic+failed as deferred wait", () => {
|
||||
for (const state of API_STATES) {
|
||||
for (const method of API_METHODS) {
|
||||
const msg = { state, method };
|
||||
expect(isOpportunisticDeferredDelivery(msg)).toBe(oracleDeferred(msg));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("propagated sent means on-node mailbox icon, not delivered checkmarks", () => {
|
||||
const msg = { state: "sent", method: "propagated" };
|
||||
expect(outboundBubbleStatusIconName(msg)).toBe("email-outline");
|
||||
expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_on_propagation_node");
|
||||
expect(outboundBubbleStatusIconName(msg)).not.toBe("check-all");
|
||||
expect(outboundBubbleStatusIconName(msg)).not.toBe("email-check-outline");
|
||||
});
|
||||
|
||||
it("propagated delivered means recipient collected mail", () => {
|
||||
const msg = { state: "delivered", method: "propagated" };
|
||||
expect(outboundBubbleStatusIconName(msg)).toBe("email-check-outline");
|
||||
expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_delivered_propagated");
|
||||
});
|
||||
|
||||
it("direct and opportunistic use WhatsApp-style checks for sent/delivered", () => {
|
||||
for (const method of ["direct", "opportunistic"]) {
|
||||
expect(outboundBubbleStatusIconName({ state: "sent", method })).toBe("check");
|
||||
expect(outboundBubbleStatusIconName({ state: "delivered", method })).toBe("check-all");
|
||||
}
|
||||
});
|
||||
|
||||
it("paper uses note icons for sent and delivered", () => {
|
||||
expect(outboundBubbleStatusIconName({ state: "sent", method: "paper" })).toBe("note-outline");
|
||||
expect(outboundBubbleStatusIconName({ state: "delivered", method: "paper" })).toBe("note-check-outline");
|
||||
});
|
||||
|
||||
it("backend never emits state=propagated but UI still treats it as sent-like", () => {
|
||||
expect(outboundBubbleStatusIconName({ state: "propagated", method: "direct" })).toBe("check");
|
||||
expect(outboundBubbleStatusIconName({ state: "propagated", method: "propagated" })).toBe("email-outline");
|
||||
});
|
||||
|
||||
it("null message has safe defaults", () => {
|
||||
expect(outboundBubbleStatusIconName(null)).toBe("check");
|
||||
expect(outboundBubbleStatusTitleKey(null)).toBeNull();
|
||||
expect(isOpportunisticDeferredDelivery(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("LXMF_STATUS_UI_ORACLE_PROVED", () => {
|
||||
let pairs = 0;
|
||||
for (const state of API_STATES) {
|
||||
for (const method of API_METHODS) {
|
||||
const msg = { state, method };
|
||||
expect(outboundBubbleStatusIconName(msg)).toBe(oracleIconName(msg));
|
||||
expect(outboundBubbleStatusTitleKey(msg)).toBe(oracleTitleKey(msg));
|
||||
expect(isOpportunisticDeferredDelivery(msg)).toBe(oracleDeferred(msg));
|
||||
pairs += 1;
|
||||
}
|
||||
}
|
||||
expect(pairs).toBe(API_STATES.length * API_METHODS.length);
|
||||
console.log("LXMF_STATUS_UI_ORACLE_PROVED");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue