feat: implement outbound message status utilities for icon and title resolution, add tests for LXMF state and method reporting

This commit is contained in:
Ivan 2026-07-21 16:32:08 -05:00
parent 4f6bccea18
commit 9f75e3db18
No known key found for this signature in database
5 changed files with 421 additions and 41 deletions

Binary file not shown.

View file

@ -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 });

View 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";
}

View 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")

View 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");
});
});