Notation like Home Assistant, each screen in its own language, and the language in every build
From the end-to-end test on the bench boards and an independent review: - Units spaced as Home Assistant does (54% / 54 % in German and French, 18°, 21.5 °C), in the firmware and the app. - Grouping from five digits where the language does (1234 but 12.345 in Spanish, Polish, Portuguese): the layout message carries group_min and percent_space; an unknown number style no longer refuses a layout. - Automatic follows Home Assistant's own language for the clock and numbers, also without a translation (Swedish: 24 hours, 1 234,5; pt-BR: 1.234,5): regions.json from the CLDR for all 63 languages Home Assistant offers. - 12-hour clock: the top bar says 3:48 PM, the clock card 3:48 (not 03:48), Last runs follow the clock. - The climate setpoint font has the decimal comma; the value fonts have ², ³ and µ (m³ was a box). - Weather days: at most three letters without the dot (mié, sáb, czw); the screen took three bytes. - Texts fall back per key through the base language (pt-BR -> pt -> en) in the firmware and the app. - Each screen gets the app's words in the language its firmware speaks; firmware from before the languages gets English and its own letters, not boxes. - set_language re-reads what it writes (indentation, comments, quoted keys, CRLF, !include) and every build writes the language first; the 24-hour pin no longer hits a fresh install and waits for the old clock switches. - Tests: region and notation, plural parity with the C++ rules, set_language edge cases, the pin, PUT /api/language. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,13 @@
|
||||
#pragma once
|
||||
#include "components/smart_display/screen_text.h"
|
||||
|
||||
static_assert(screen_text::KEYS_HASH == 0xC6B1A3EDu && screen_text::KEY_COUNT == 305,
|
||||
static_assert(screen_text::KEYS_HASH == 0x5DBB4605u && screen_text::KEY_COUNT == 306,
|
||||
"screen_text_keys.h does not match screen_manager/translations/en.json: run tools/i18n.py header");
|
||||
const char *const screen_text::TABLE[] = {
|
||||
".",
|
||||
",",
|
||||
"1",
|
||||
"%",
|
||||
"Just now",
|
||||
"{n} min ago",
|
||||
"1 hour ago | {n} hours ago",
|
||||
|
||||
@@ -304,11 +304,19 @@ class Editor(unittest.IsolatedAsyncioTestCase):
|
||||
i18n.set_screens('xx', 'point')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
|
||||
# Firmware from before the languages speaks English, whatever the screens' language is.
|
||||
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
|
||||
self.assertEqual(answer.status, 200)
|
||||
action, data = ha.calls[0]
|
||||
self.assertEqual((action, data['title'], data['subtitle'], data['button_text']),
|
||||
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK'))
|
||||
# Firmware that says it speaks the screens' language gets the card in it.
|
||||
ha.registry = ha.registry + [{'entity_id': 'sensor.language', 'platform': 'esphome', 'original_name': 'Screen language',
|
||||
'device_id': 'd1'}]
|
||||
ha.states['sensor.language'] = {'state': 'xx'}
|
||||
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
|
||||
self.assertEqual(answer.status, 200)
|
||||
texts = [(action, data['title'], data['subtitle'], data['button_text']) for action, data in ha.calls]
|
||||
self.assertEqual(texts, [('esphome.office_1_show_alert', 'This is Office 1', 'Identify, from ESP Screens', 'OK'),
|
||||
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK')])
|
||||
i18n.set_screens('en', 'point')
|
||||
|
||||
async def test_a_tile_event_answers_home_assistant_in_english(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
|
||||
|
||||
@@ -38,7 +38,7 @@ int main() {
|
||||
assert(number_text(128) == "128");
|
||||
assert(number_text(12.5f) == "12.5");
|
||||
assert(number_text(0) == "0");
|
||||
assert(percent_text(50) == "50 %");
|
||||
assert(percent_text(50) == "50%"); // "50 %" where the language writes it so (screen_text::percent_sign)
|
||||
|
||||
// The roller's text and the row that is chosen in it.
|
||||
std::vector<std::string> names = {"Solid", "Akemi", "TV Simulator"};
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Settings -> Language & region (app 0.2.90): how the screens write the clock and numbers, the language each screen's
|
||||
messages go in, the language in the screens' YAML and the 24 hours an app from before it kept."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import i18n # noqa: E402
|
||||
import yaml # noqa: E402
|
||||
from firmware import Firmware, LenientLoader # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
PROFILE_TAIL = 'packages:\n display: !include /r/packages/core.yaml\n'
|
||||
|
||||
|
||||
def region(tmp, ha='en', **stored):
|
||||
path = Path(tmp) / 'language.json'
|
||||
if stored:
|
||||
path.write_text(json.dumps({'version': 1, **stored}))
|
||||
return i18n.Region(path, ha_language=lambda: ha)
|
||||
|
||||
|
||||
class NotationTests(unittest.TestCase):
|
||||
def test_every_home_assistant_language_has_a_notation(self):
|
||||
tool = importlib.util.spec_from_file_location('i18n_tool', ROOT / 'tools' / 'i18n.py')
|
||||
module = importlib.util.module_from_spec(tool)
|
||||
tool.loader.exec_module(module)
|
||||
self.assertEqual(set(module.HA_LANGUAGES), set(i18n.REGIONS) - {'_about'})
|
||||
for code, written in i18n.REGIONS.items():
|
||||
if code == '_about':
|
||||
continue
|
||||
self.assertIn(written['clock'], ('12', '24'), code)
|
||||
self.assertIn(written['numbers'], ('point', 'comma', 'space'), code)
|
||||
self.assertIn(written['group_min'], (1, 2), code)
|
||||
|
||||
def test_automatic_follows_home_assistants_own_language(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Swedish: English texts (no file yet), Swedish clock and numbers.
|
||||
sv = region(tmp, 'sv')
|
||||
self.assertEqual((sv.language(), sv.clock_24h(), sv.number_style(), sv.group_min(), sv.percent_space()),
|
||||
('en', True, 'space', 1, True))
|
||||
# Brazilian Portuguese: the Portuguese texts, Brazil's numbers.
|
||||
br = region(tmp, 'pt-BR')
|
||||
self.assertEqual((br.language(), br.number_style(), br.group_min()), ('pt', 'comma', 1))
|
||||
pt = region(tmp, 'pt')
|
||||
self.assertEqual((pt.number_style(), pt.group_min(), pt.percent_space()), ('space', 2, False))
|
||||
us, uk = region(tmp, 'en'), region(tmp, 'en-GB')
|
||||
self.assertEqual((us.clock_24h(), uk.clock_24h(), uk.language()), (False, True, 'en-GB'))
|
||||
de = region(tmp, 'de')
|
||||
self.assertEqual((de.number_style(), de.percent_space()), ('comma', True))
|
||||
|
||||
def test_a_chosen_language_and_format_win(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
chosen = region(tmp, 'nl', language='es', clock='12', numbers='point')
|
||||
# A chosen format is Home Assistant's for it, which groups from four digits; the space before % stays the
|
||||
# language's.
|
||||
self.assertEqual((chosen.language(), chosen.clock_24h(), chosen.number_style(), chosen.group_min()),
|
||||
('es', False, 'point', 1))
|
||||
self.assertEqual(region(tmp, 'nl', language='es', clock='auto', numbers='auto').group_min(), 2)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
view = region(tmp, 'de').view()
|
||||
self.assertEqual({key: view[key] for key in ('group_min', 'percent_space', 'numbers_effective')},
|
||||
{'group_min': 1, 'percent_space': True, 'numbers_effective': 'comma'})
|
||||
|
||||
def test_numbers_and_units_as_home_assistant_writes_them(self):
|
||||
self.assertEqual(i18n.format_number('1234.5', 'comma', 2), '1234,5')
|
||||
self.assertEqual(i18n.format_number('12345.5', 'comma', 2), '12.345,5')
|
||||
self.assertEqual(i18n.format_number('1234', 'space'), '1 234')
|
||||
self.assertEqual(i18n.format_number('-1234567', 'point'), '-1,234,567')
|
||||
self.assertEqual(i18n.format_number('on', 'comma'), 'on')
|
||||
try:
|
||||
i18n.set_screens('de', 'comma', True, 1, True)
|
||||
self.assertEqual([i18n.unit_suffix(unit) for unit in ('%', '°', '°C', '', None)], [' %', '°', ' °C', '', ''])
|
||||
i18n.set_screens('nl', 'comma', True, 1, False)
|
||||
self.assertEqual(i18n.unit_suffix('%'), '%')
|
||||
finally:
|
||||
i18n.set_screens('en', 'point')
|
||||
|
||||
def test_plural_rules_match_the_firmware(self):
|
||||
"""Python's rules against the C++ ones a screen's build writes (screen_text_gen.PLURAL_RULES), n from -30."""
|
||||
compiler = shutil.which(os.environ.get('CXX', 'clang++')) or shutil.which('g++')
|
||||
if not compiler:
|
||||
self.skipTest('no C++ compiler')
|
||||
spec = importlib.util.spec_from_file_location('gen', ROOT / 'components/smart_display/screen_text_gen.py')
|
||||
gen = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(gen)
|
||||
numbers = list(range(-30, 131)) + [1001, 1011, 1021, 1112]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = Path(tmp) / 'rules.cpp'
|
||||
functions = '\n'.join(f'int rule_{name}(int n) {{ {body} }}' for name, body in gen.PLURAL_RULES.items())
|
||||
calls = '\n'.join(f' for (int n : ns) std::printf("{name} %d %d\\n", n, rule_{name}(n));' for name in gen.PLURAL_RULES)
|
||||
source.write_text('#include <cstdio>\n' + functions + '\nint main() {\n int ns[] = {' + ','.join(map(str, numbers)) +
|
||||
'};\n' + calls + '\n}\n')
|
||||
subprocess.run([compiler, '-std=c++17', str(source), '-o', str(Path(tmp) / 'rules')], check=True)
|
||||
out = subprocess.run([str(Path(tmp) / 'rules')], check=True, capture_output=True, text=True).stdout.split('\n')
|
||||
for line in filter(None, out):
|
||||
name, n, index = line.split()
|
||||
self.assertEqual(i18n.PLURAL[name](int(n)), int(index), f'{name}({n})')
|
||||
|
||||
|
||||
class ScreenContextTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
i18n.set_screens('en', 'point')
|
||||
|
||||
def test_each_screen_gets_the_words_its_firmware_speaks(self):
|
||||
import header_bar
|
||||
i18n.set_screens('nl', 'comma', True, 1, False)
|
||||
legacy = i18n.screen_context({'language': None, 'language_sensor': False})
|
||||
dutch = i18n.screen_context({'language': 'nl', 'language_sensor': True})
|
||||
restarting = i18n.screen_context({'language': None, 'language_sensor': True})
|
||||
self.assertEqual(i18n.screen_t('screen.ha.on'), 'Aan')
|
||||
for context, word, number, letters in ((legacy, 'On', '1,234.5', 'Waz'), (dutch, 'Aan', '1.234,5', 'Wąż'),
|
||||
(restarting, 'Aan', '1.234,5', 'Wąż')):
|
||||
token = i18n.SCREEN.set(context)
|
||||
try:
|
||||
self.assertEqual((i18n.screen_t('screen.ha.on'), i18n.screen_number('1234.5')), (word, number))
|
||||
# Firmware from before the languages lacks most accented letters: they fold to the base letter.
|
||||
self.assertEqual(header_bar.clean_text('Wąż'), letters)
|
||||
finally:
|
||||
i18n.SCREEN.reset(token)
|
||||
self.assertIsNone(i18n.screen_context(None))
|
||||
|
||||
|
||||
class ProfileLanguageTests(unittest.TestCase):
|
||||
CASES = {
|
||||
'plain': ('substitutions:\n DEVICE_NAME: "a"\n\n' + PROFILE_TAIL, True),
|
||||
'four spaces': ('substitutions:\n DEVICE_NAME: "a"\n FONT: "x"\n' + PROFILE_TAIL, True),
|
||||
'a comment inside': ('substitutions:\n DEVICE_NAME: "a"\n# a note\n LANGUAGE: "en"\n' + PROFILE_TAIL, True),
|
||||
'quoted key': ('substitutions:\n "LANGUAGE": "en"\n DEVICE_NAME: a\n' + PROFILE_TAIL, True),
|
||||
'no final newline': (PROFILE_TAIL + 'substitutions:\n DEVICE_NAME: "a"\n LANGUAGE: "de"', True),
|
||||
'document start': ('# mine\n---\n' + PROFILE_TAIL, True),
|
||||
'comments after the key': ('substitutions: # mine\n DEVICE_NAME: "a" # name\n\n\n# esphome\nesphome:\n name: a\n' + PROFILE_TAIL, True),
|
||||
'windows line ends': (('substitutions:\n DEVICE_NAME: "a"\n' + PROFILE_TAIL).replace('\n', '\r\n'), True),
|
||||
'include': ('substitutions: !include subs.yaml\n' + PROFILE_TAIL, False),
|
||||
'flow mapping': ('substitutions: {DEVICE_NAME: a}\n' + PROFILE_TAIL, False),
|
||||
'not ours': ('substitutions:\n A: b\nesphome:\n name: z\n', False),
|
||||
}
|
||||
|
||||
def test_the_language_line_changes_nothing_else(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
firmware = Firmware(tmp, tmp)
|
||||
for index, (name, (text, writes)) in enumerate(self.CASES.items()):
|
||||
with self.subTest(name):
|
||||
path = Path(tmp) / f'p{index}.yaml'
|
||||
path.write_bytes(text.encode())
|
||||
before = yaml.load(text.replace('\r\n', '\n'), Loader=LenientLoader)
|
||||
with self.assertLogs('screen_manager', 'WARNING') if name in ('include', 'flow mapping') else _quiet():
|
||||
self.assertEqual(firmware.set_language(path.name, 'nl'), writes)
|
||||
out = path.read_bytes().decode()
|
||||
self.assertEqual('\r\n' in out, '\r\n' in text, 'line ends stay as they were')
|
||||
after = yaml.load(out.replace('\r\n', '\n'), Loader=LenientLoader)
|
||||
if writes:
|
||||
self.assertEqual(after['substitutions']['LANGUAGE'], 'nl')
|
||||
strip = lambda data: ({k: v for k, v in data.items() if k != 'substitutions'},
|
||||
{k: v for k, v in (data.get('substitutions') or {}).items() if k != 'LANGUAGE'})
|
||||
self.assertEqual(strip(after), strip(before))
|
||||
self.assertFalse(firmware.set_language(path.name, 'nl'), 'nothing to do the second time')
|
||||
else:
|
||||
self.assertEqual(out, text)
|
||||
english = Path(tmp) / 'english.yaml'
|
||||
english.write_text(PROFILE_TAIL)
|
||||
self.assertFalse(firmware.set_language(english.name, 'en'), 'English is the default: no line needed')
|
||||
|
||||
def test_every_build_writes_the_language_first(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
firmware = Firmware(tmp, tmp)
|
||||
path = Path(tmp) / 'screen.yaml'
|
||||
path.write_text('substitutions:\n DEVICE_NAME: "screen"\n' + PROFILE_TAIL)
|
||||
firmware.language = lambda: 'de'
|
||||
firmware.run = lambda *args: asyncio.sleep(0)
|
||||
with unittest.mock.patch('shutil.which', return_value='/usr/bin/esphome'):
|
||||
async def start():
|
||||
firmware.start({'file': 'screen.yaml', 'action': 'validate'})
|
||||
self.assertNotIn('LANGUAGE', path.read_text(), 'validating changes nothing')
|
||||
await firmware.task
|
||||
firmware.start({'file': 'screen.yaml', 'action': 'build'})
|
||||
await firmware.task
|
||||
asyncio.run(start())
|
||||
self.assertIn('LANGUAGE: "de"', path.read_text())
|
||||
|
||||
|
||||
class _quiet:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class ClockPinTests(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, path, switches=None):
|
||||
import test_scaling
|
||||
from server import Manager
|
||||
ha = test_scaling.fake_ha(firmware='0.2.75')
|
||||
for index, state in enumerate(switches or []):
|
||||
ha.registry.append({'entity_id': f'switch.clock_{index}', 'platform': 'esphome', 'original_name': '24-hour clock',
|
||||
'device_id': 'd1'})
|
||||
if state is not None:
|
||||
ha.states[f'switch.clock_{index}'] = {'state': state}
|
||||
ha.ha_language = 'en'
|
||||
ha.services_changed = asyncio.Event()
|
||||
return Manager(ha, path)
|
||||
|
||||
async def test_a_new_install_follows_the_language_from_the_start(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
first = self.manager(path)
|
||||
self.assertTrue((Path(tmp) / 'language.json').exists(), 'stored at once')
|
||||
first.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a'}]})
|
||||
again = self.manager(path, ['on'])
|
||||
self.assertEqual((again.region.clock, again.region.pinned, again.region.clock_24h()), ('auto', False, False))
|
||||
|
||||
async def test_an_older_app_keeps_its_24_hours_until_the_switches_tell(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'screens.json'
|
||||
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': {'title': 'Home', 'tiles': [{'entity': 'light.a'}]}}}))
|
||||
waiting = self.manager(path, ['off', None])
|
||||
self.assertEqual((waiting.region.clock, waiting.region.pinned), ('24', True))
|
||||
self.assertFalse(waiting.resolve_clock_pin(), 'a switch without a state: ask again later')
|
||||
self.assertTrue(json.loads((Path(tmp) / 'language.json').read_text())['pinned'], 'the question survives a restart')
|
||||
twelve = self.manager(path, ['off', 'off'])
|
||||
self.assertTrue(twelve.resolve_clock_pin())
|
||||
self.assertEqual((twelve.region.clock, twelve.region.pinned), ('12', False))
|
||||
(Path(tmp) / 'language.json').unlink()
|
||||
mixed = self.manager(path, ['off', 'on'])
|
||||
self.assertFalse(mixed.resolve_clock_pin())
|
||||
self.assertEqual((mixed.region.clock, mixed.region.pinned), ('24', False))
|
||||
(Path(tmp) / 'language.json').unlink()
|
||||
chosen = self.manager(path, ['off', None])
|
||||
chosen.region.change({'clock': 'auto'})
|
||||
self.assertFalse(chosen.region.pinned, 'the owner chose a clock')
|
||||
|
||||
async def test_language_and_region_over_http(self):
|
||||
from server import create_app
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
|
||||
answer = await client.put('/api/language', json={'setting': 'de', 'numbers': 'point'}, headers={'X-Screen-CSRF': csrf})
|
||||
view = (await answer.json())['language']
|
||||
self.assertEqual((view['effective'], view['numbers_effective'], view['group_min'], view['percent_space']),
|
||||
('de', 'point', 1, True))
|
||||
refused = await client.put('/api/language', json={'clock': '13'}, headers={'X-Screen-CSRF': csrf, 'X-ESP-Screens-Language': 'nl'})
|
||||
self.assertEqual(refused.status, 400)
|
||||
self.assertEqual((await refused.json())['error'], i18n.TRANSLATIONS.text('addon.errors.region_choice', 'nl'))
|
||||
self.assertEqual(json.loads((Path(tmp) / 'language.json').read_text())['language'], 'de')
|
||||
|
||||
|
||||
import unittest.mock # noqa: E402
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -129,11 +129,14 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
|
||||
self.assertEqual(path.read_bytes(),before)
|
||||
await fresh.sync_one('text.screen',saved)
|
||||
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
|
||||
# The clock is the one of Settings -> Language & region (app 0.2.90), the rest what the screen kept.
|
||||
self.assertEqual(fresh.ha.messages[0][1]['settings'],{**{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK},
|
||||
'clock_24h':fresh.region.clock_24h()})
|
||||
await fresh.sync_one('text.screen',saved)
|
||||
self.assertEqual(len(fresh.ha.messages),2)
|
||||
await fresh.sync_one('text.screen',saved,True)
|
||||
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
|
||||
self.assertEqual(fresh.ha.messages[2][1]['settings'],{**{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK},
|
||||
'clock_24h':fresh.region.clock_24h()})
|
||||
|
||||
async def test_reorder_aborts_old_batch(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
|
||||
@@ -42,6 +42,23 @@ int main() {
|
||||
number_style_of("space") == 3 && number_style_of("dot") == -1);
|
||||
number_style = 0;
|
||||
|
||||
// Grouping from five digits where the language does (CLDR: Spanish, Polish write 1234 but 12.345), as the app says.
|
||||
number_group_min = 2;
|
||||
assert(localize("1234") == "1234" && localize("12345") == "12,345" && localize("-1234.5") == "-1234.5");
|
||||
number_group_min = 1;
|
||||
assert(localize("1234") == "1,234");
|
||||
number_group_min = 0;
|
||||
|
||||
// Units spaced as Home Assistant spaces them: none before "%" or "°" in English, one before any other unit; German
|
||||
// and French put one before "%" too (the app sends it).
|
||||
assert(with_unit("54", "%") == "54%" && with_unit("18", "\u00B0") == "18\u00B0" && with_unit("21.5", "\u00B0C") == "21.5 \u00B0C");
|
||||
assert(with_unit("7", "") == "7" && unit_suffix("kWh") == " kWh" && percent(71) == "71%");
|
||||
number_percent = 2;
|
||||
assert(with_unit("54", "%") == "54 %" && percent(71) == "71 %");
|
||||
number_percent = 1;
|
||||
assert(percent(71) == "71%");
|
||||
number_percent = 0;
|
||||
|
||||
// Times the screen gets as "HH:MM" (the sun, the forecast), written for its clock.
|
||||
assert(clock_text("07:12", true) == "07:12" && clock_text("19:05", false) == "7:05 PM");
|
||||
assert(clock_text("00:00", false) == "12:00 AM" && clock_text("14:00", false, true) == "2 PM");
|
||||
|
||||
Reference in New Issue
Block a user