From c293746d4c34ccb7abb8af41f7d05940aa7e4076 Mon Sep 17 00:00:00 2001 From: Artyom Shalkhakov Date: Tue, 8 May 2018 16:03:24 +0600 Subject: Adding Selenium-based checking to tests. --- tests/DynChannel.py | 20 ++++++++++++++++++++ tests/Makefile | 17 +++++++++++++++++ tests/alert.py | 11 +++++++++++ tests/alert.ur | 2 +- tests/alert.urp | 3 --- tests/align.py | 11 +++++++++++ tests/appjs.py | 11 +++++++++++ tests/appjs.ur | 2 +- tests/ascdesc.py | 11 +++++++++++ tests/ascdesc.ur | 14 +++++++++++--- tests/ascdesc.urp | 3 +-- tests/attrMangle.py | 11 +++++++++++ tests/attrs_escape.py | 10 ++++++++++ tests/attrs_escape.ur | 10 ++++++---- tests/autocomp.py | 15 +++++++++++++++ tests/autocomp.ur | 8 ++++---- tests/base.py | 29 +++++++++++++++++++++++++++++ tests/bindpat.py | 9 +++++++++ tests/bindpat.ur | 7 +++++-- tests/driver.sh | 25 +++++++++++++++++++++++++ tests/entities.py | 14 ++++++++++++++ tests/entities.ur | 6 +++--- tests/fact.py | 10 ++++++++++ tests/filter.py | 9 +++++++++ tests/filter.ur | 17 ++++++++++++----- tests/jsonTest.py | 16 ++++++++++++++++ tests/jsonTest.ur | 4 ++-- 27 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 tests/DynChannel.py create mode 100644 tests/alert.py delete mode 100644 tests/alert.urp create mode 100644 tests/align.py create mode 100644 tests/appjs.py create mode 100644 tests/ascdesc.py create mode 100644 tests/attrMangle.py create mode 100644 tests/attrs_escape.py create mode 100644 tests/autocomp.py create mode 100644 tests/base.py create mode 100644 tests/bindpat.py create mode 100755 tests/driver.sh create mode 100644 tests/entities.py create mode 100644 tests/fact.py create mode 100644 tests/filter.py create mode 100644 tests/jsonTest.py (limited to 'tests') diff --git a/tests/DynChannel.py b/tests/DynChannel.py new file mode 100644 index 00000000..7af5ea78 --- /dev/null +++ b/tests/DynChannel.py @@ -0,0 +1,20 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start('DynChannel/main') + + # initial state: only Register is visible + reg = self.xpath('button') + reg.click() + # and we get two another state: either Register or Send visible + send = self.xpath('span/button') + send.click() + alert = self.driver.switch_to.alert + self.assertEqual("Got something from the channel", alert.text) + alert.accept() + # we got the message back + span = self.xpath('span/span') + self.assertEqual("blabla", span.text) diff --git a/tests/Makefile b/tests/Makefile index 5313d12d..63ae555e 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -2,3 +2,20 @@ all: test.o test.o: test.c gcc -c test.c -o test.o +### + +simple:: + ./driver.sh alert + ./driver.sh align + ./driver.sh appjs + ./driver.sh ascdesc + echo ./driver.sh attrMangle + ./driver.sh attrs_escape + echo ./driver.sh attrs + ./driver.sh autocomp + ./driver.sh bindpat + ./driver.sh DynChannel + ./driver.sh jsonTest + ./driver.sh entities + ./driver.sh fact + ./driver.sh filter diff --git a/tests/alert.py b/tests/alert.py new file mode 100644 index 00000000..4b783d50 --- /dev/null +++ b/tests/alert.py @@ -0,0 +1,11 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + el = self.xpath('a') + el.click() + alert = self.driver.switch_to.alert + self.assertEqual("You clicked it! That's some fancy shooting!", alert.text) diff --git a/tests/alert.ur b/tests/alert.ur index 3fe68d75..7a290921 100644 --- a/tests/alert.ur +++ b/tests/alert.ur @@ -1,3 +1,3 @@ fun main () : transaction page = return - Click Me! + alert "You clicked it! That's some fancy shooting!"}>Click Me! diff --git a/tests/alert.urp b/tests/alert.urp deleted file mode 100644 index 3976e9b0..00000000 --- a/tests/alert.urp +++ /dev/null @@ -1,3 +0,0 @@ -debug - -alert diff --git a/tests/align.py b/tests/align.py new file mode 100644 index 00000000..525ab4e6 --- /dev/null +++ b/tests/align.py @@ -0,0 +1,11 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + el = self.xpath('p[@align="left"]') + self.assertEqual("Left", el.text) + el = self.xpath('p[@align="right"]') + self.assertEqual("Right", el.text) diff --git a/tests/appjs.py b/tests/appjs.py new file mode 100644 index 00000000..02ac2193 --- /dev/null +++ b/tests/appjs.py @@ -0,0 +1,11 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + el = self.xpath('button') + el.click() + alert = self.driver.switch_to.alert + self.assertEqual("3", alert.text) diff --git a/tests/appjs.ur b/tests/appjs.ur index 01e9f345..403b0b4e 100644 --- a/tests/appjs.ur +++ b/tests/appjs.ur @@ -1,5 +1,5 @@ fun id n = if n = 0 then 0 else 1 + id (n - 1) fun main () : transaction page = return - +
{[v]}}/> / {[v]}}/> - +
diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 00000000..b9a026f2 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,29 @@ +# use pip install selenium first +# ensure you have both chome driver & chrome installed + +import unittest +from selenium import webdriver +from selenium.common.exceptions import NoSuchElementException + +class Base(unittest.TestCase): + """Include test cases on a given url""" + + def start(self, path='main'): + self.driver.get('http://localhost:8080/' + path) + def xpath(self, path): + return self.driver.find_element_by_xpath('/html/body/'+path) + def body_text(self): + return self.driver.find_element_by_xpath('/html/body').text + + def setUp(self): + """Start web driver""" + chrome_options = webdriver.ChromeOptions() + chrome_options.add_argument('--no-sandbox') + chrome_options.add_argument('--headless') + chrome_options.add_argument('--disable-gpu') + self.driver = webdriver.Chrome(options=chrome_options) + self.driver.implicitly_wait(10) + + def tearDown(self): + """Stop web driver""" + self.driver.quit() diff --git a/tests/bindpat.py b/tests/bindpat.py new file mode 100644 index 00000000..6c33f52f --- /dev/null +++ b/tests/bindpat.py @@ -0,0 +1,9 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.driver.get('http://localhost:8080/main') + el = self.driver.find_element_by_xpath('/html/body') + self.assertEqual("1, 2, hi, 2.34, 8, 9", el.text) diff --git a/tests/bindpat.ur b/tests/bindpat.ur index bca4bd41..8fd6eb39 100644 --- a/tests/bindpat.ur +++ b/tests/bindpat.ur @@ -1,6 +1,9 @@ fun main () : transaction page = (a, b) <- return (1, 2); {C = c, ...} <- return {C = "hi", D = False}; - d <- return 2.34; - {1 = e, 2 = f} <- return (8, 9); + let + val d = 2.34 + val {1 = e, 2 = f} = (8, 9) + in return {[a]}, {[b]}, {[c]}, {[d]}, {[e]}, {[f]} + end \ No newline at end of file diff --git a/tests/driver.sh b/tests/driver.sh new file mode 100755 index 00000000..cc62644b --- /dev/null +++ b/tests/driver.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +if [[ $# -eq 0 ]] ; then + echo 'Supply at least one argument' + exit 1 +fi + +TESTDB=/tmp/$1.db +TESTSQL=/tmp/$1.sql +TESTPID=/tmp/$1.pid +TESTSRV=./$1.exe + +rm -f $TESTDB $TESTSQL $TESTPID $TESTSRV +../bin/urweb -debug -boot -noEmacs -dbms sqlite -db $TESTDB -sql $TESTSQL "$1" || exit 1 + +if [ -e $TESTSQL ] +then + sqlite3 $TESTDB < $TESTSQL +fi + +$TESTSRV -q -a 127.0.0.1 & +echo $! >> $TESTPID +sleep 1 +python -m unittest $1.py +kill `cat $TESTPID` diff --git a/tests/entities.py b/tests/entities.py new file mode 100644 index 00000000..d9087cbf --- /dev/null +++ b/tests/entities.py @@ -0,0 +1,14 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + p = self.xpath('p[1]') + self.assertEqual('Hello world! & so on, © me today (8 €)', p.text) + p = self.xpath('p[2]') + self.assertEqual('♠ ♣ ♥ ♦', p.text) + p = self.xpath('p[3]') + self.assertEqual('† DANGER †', p.text) + diff --git a/tests/entities.ur b/tests/entities.ur index 8b78edbc..1f45520d 100644 --- a/tests/entities.ur +++ b/tests/entities.ur @@ -1,5 +1,5 @@ fun main () : transaction page = return - Hello world! & so on, © me today (8 €)
- ♠ ♣ ♥ ♦
- † DANGER † +

Hello world! & so on, © me today (8 €)

+

♠ ♣ ♥ ♦

+

† DANGER †

diff --git a/tests/fact.py b/tests/fact.py new file mode 100644 index 00000000..3dcd6f71 --- /dev/null +++ b/tests/fact.py @@ -0,0 +1,10 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + b = self.driver.find_element_by_xpath('/html/body') + self.assertEqual('3628800, 3628800', b.text) + diff --git a/tests/filter.py b/tests/filter.py new file mode 100644 index 00000000..f68f8f88 --- /dev/null +++ b/tests/filter.py @@ -0,0 +1,9 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start('Filter/main') + tx = self.body_text() + self.assertEqual("4, 4; 44, 4.4;", tx) diff --git a/tests/filter.ur b/tests/filter.ur index efd326c3..2691a939 100644 --- a/tests/filter.ur +++ b/tests/filter.ur @@ -1,9 +1,16 @@ -fun filter [fs ::: {Type}] [ks] (t : sql_table fs ks) (p : sql_exp [T = fs] [] [] bool) - : sql_query [T = fs] [] = +fun filter [fs ::: {Type}] [ks] (t : sql_table fs ks) (p : sql_exp [T = fs] [] [] bool) = (SELECT * FROM t WHERE {p}) table t : { A : int, B : float } -fun main () = - queryX (filter t (WHERE t.A > 3)) - (fn r => {[r.T.A]}, {[r.T.B]}) +task initialize = fn () => + dml (INSERT INTO t (A, B) VALUES (1, 2.0)); + dml (INSERT INTO t (A, B) VALUES (2, 1.0)); + dml (INSERT INTO t (A, B) VALUES (3, 3.0)); + dml (INSERT INTO t (A, B) VALUES (4, 4.0)); + dml (INSERT INTO t (A, B) VALUES (44, 4.4)) + +fun main () : transaction page = + r <- queryX (filter t (WHERE t.A > 3)) + (fn r => {[r.T.A]}, {[r.T.B]}; ); + return {r} diff --git a/tests/jsonTest.py b/tests/jsonTest.py new file mode 100644 index 00000000..d9147511 --- /dev/null +++ b/tests/jsonTest.py @@ -0,0 +1,16 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + + pre = self.xpath('pre[1]') + self.assertEqual('line 1\nline 2', pre.text) + + pre = self.xpath('pre[2]') + self.assertEqual('1 :: 2 :: 3 :: []', pre.text) + + pre = self.xpath('pre[3]') + self.assertEqual('["hi","bye\\"","hehe"]', pre.text) diff --git a/tests/jsonTest.ur b/tests/jsonTest.ur index 1be6e7b5..38d0d201 100644 --- a/tests/jsonTest.ur +++ b/tests/jsonTest.ur @@ -2,6 +2,6 @@ open Json fun main () : transaction page = return
{[ fromJson "\"line 1\\nline 2\"" : string ]}

- {[fromJson "[1, 2, 3]" : list int]}
- {[toJson ("hi" :: "bye\"" :: "hehe" :: [])]} +
{[fromJson "[1, 2, 3]" : list int]}

+
{[toJson ("hi" :: "bye\"" :: "hehe" :: [])]}
-- cgit v1.2.3 From 1078553f5a8de2a5e85dbd49058370afeefa68c7 Mon Sep 17 00:00:00 2001 From: Artyom Shalkhakov Date: Tue, 8 May 2018 17:25:29 +0600 Subject: Adding jsbspace for #121. --- tests/Makefile | 1 + tests/jsbspace.py | 11 +++++++++++ tests/jsbspace.ur | 12 ++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 tests/jsbspace.py create mode 100644 tests/jsbspace.ur (limited to 'tests') diff --git a/tests/Makefile b/tests/Makefile index 63ae555e..250a2ece 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -19,3 +19,4 @@ simple:: ./driver.sh entities ./driver.sh fact ./driver.sh filter + ./driver.sh jsbspace diff --git a/tests/jsbspace.py b/tests/jsbspace.py new file mode 100644 index 00000000..b29d44b9 --- /dev/null +++ b/tests/jsbspace.py @@ -0,0 +1,11 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + el = self.xpath('button') + el.click() + alert = self.driver.switch_to.alert + self.assertEqual('Some \btext', alert.text) diff --git a/tests/jsbspace.ur b/tests/jsbspace.ur new file mode 100644 index 00000000..bf4b824f --- /dev/null +++ b/tests/jsbspace.ur @@ -0,0 +1,12 @@ +fun main () : transaction page = +let + fun onclick (): transaction unit = + (* this function runs on the client *) + alert "Some \btext" +in +return + + + + +end \ No newline at end of file -- cgit v1.2.3 From 4c01511f5bf2229da7b146943444278d714ed7d6 Mon Sep 17 00:00:00 2001 From: Artyom Shalkhakov Date: Tue, 15 May 2018 21:48:15 +0600 Subject: More tests. --- tests/Makefile | 8 ++++++++ tests/aborter.py | 11 +++++++++++ tests/aborter.urp | 1 + tests/aborter2.py | 11 +++++++++++ tests/active.py | 14 ++++++++++++++ tests/activeBlock.py | 20 ++++++++++++++++++++ tests/activeBlock.ur | 2 +- tests/activeEmpty.py | 12 ++++++++++++ tests/activeFocus.py | 18 ++++++++++++++++++ tests/activeFocus.ur | 2 +- tests/agg.py | 8 ++++++++ tests/agg.ur | 20 +++++++++++++++----- tests/ahead.py | 15 +++++++++++++++ tests/babySpawn.py | 12 ++++++++++++ 14 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 tests/aborter.py create mode 100644 tests/aborter2.py create mode 100644 tests/active.py create mode 100644 tests/activeBlock.py create mode 100644 tests/activeEmpty.py create mode 100644 tests/activeFocus.py create mode 100644 tests/agg.py create mode 100644 tests/ahead.py create mode 100644 tests/babySpawn.py (limited to 'tests') diff --git a/tests/Makefile b/tests/Makefile index 250a2ece..ecf5557b 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -5,6 +5,13 @@ test.o: test.c ### simple:: + ./driver.sh aborter2 + ./driver.sh aborter + ./driver.sh activeBlock + ./driver.sh activeFocus + ./driver.sh active + ./driver.sh agg + ./driver.sh ahead ./driver.sh alert ./driver.sh align ./driver.sh appjs @@ -13,6 +20,7 @@ simple:: ./driver.sh attrs_escape echo ./driver.sh attrs ./driver.sh autocomp + ./driver.sh babySpawn ./driver.sh bindpat ./driver.sh DynChannel ./driver.sh jsonTest diff --git a/tests/aborter.py b/tests/aborter.py new file mode 100644 index 00000000..8379c656 --- /dev/null +++ b/tests/aborter.py @@ -0,0 +1,11 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start('Aborter/main') + self.assertEqual("Fatal Error", self.driver.title) + txt = self.body_text() + self.assertEqual("Fatal error: :0:0-0:0: No way, Jose!", txt) + diff --git a/tests/aborter.urp b/tests/aborter.urp index fc1925ae..8c971440 100644 --- a/tests/aborter.urp +++ b/tests/aborter.urp @@ -1,4 +1,5 @@ database dbname=aborter sql aborter.sql +safeGet Aborter/main aborter diff --git a/tests/aborter2.py b/tests/aborter2.py new file mode 100644 index 00000000..c3f1e10e --- /dev/null +++ b/tests/aborter2.py @@ -0,0 +1,11 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start('Aborter2/main') + self.assertEqual("", self.driver.title) + txt = self.body_text() + self.assertEqual("Result: 0", txt) + diff --git a/tests/active.py b/tests/active.py new file mode 100644 index 00000000..08846ac5 --- /dev/null +++ b/tests/active.py @@ -0,0 +1,14 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + b1 = self.xpath('span[1]/button') + b2 = self.xpath('span[2]/button') + for _ in range(3): + b1.click() + for _ in range(5): + b2.click() + self.assertEqual("3\n5", self.body_text()) diff --git a/tests/activeBlock.py b/tests/activeBlock.py new file mode 100644 index 00000000..d0e43fdb --- /dev/null +++ b/tests/activeBlock.py @@ -0,0 +1,20 @@ +import unittest +import base +import time + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + alert = self.driver.switch_to.alert + self.assertEqual("Error: May not 'sleep' in main thread of 'code' for ", alert.text) + alert.accept() + time.sleep(0.1) + alert = self.driver.switch_to.alert + self.assertEqual("Hi!", alert.text) + alert.accept() + button = self.xpath('span[1]/button') + button.click() + txt = self.body_text() + self.assertEqual("Hi! Click me! Success", txt) + diff --git a/tests/activeBlock.ur b/tests/activeBlock.ur index 5560edda..bced4af3 100644 --- a/tests/activeBlock.ur +++ b/tests/activeBlock.ur @@ -1,7 +1,7 @@ fun main () : transaction page = return - }/> Hi!}/> diff --git a/tests/activeEmpty.py b/tests/activeEmpty.py new file mode 100644 index 00000000..8872833a --- /dev/null +++ b/tests/activeEmpty.py @@ -0,0 +1,12 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + alert = self.driver.switch_to.alert + self.assertEqual("Howdy, neighbor!", alert.text) + alert.accept() + txt = self.body_text() + self.assertEqual("This one ain't empty.", txt) diff --git a/tests/activeFocus.py b/tests/activeFocus.py new file mode 100644 index 00000000..47b9a921 --- /dev/null +++ b/tests/activeFocus.py @@ -0,0 +1,18 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + uw0 = self.xpath('input[2]') + active = self.driver.switch_to.active_element + self.assertEqual(uw0, active) + def test_2(self): + """Test case 2""" + self.start('dynamic') + btn = self.xpath('button') + btn.click() + uw1 = self.xpath('span/input[2]') + active = self.driver.switch_to.active_element + self.assertEqual(uw1, active) diff --git a/tests/activeFocus.ur b/tests/activeFocus.ur index 94d465e9..82d2c0c9 100644 --- a/tests/activeFocus.ur +++ b/tests/activeFocus.ur @@ -14,5 +14,5 @@ fun dynamic () : transaction page = Done}/> - }/> + }>Click diff --git a/tests/agg.py b/tests/agg.py new file mode 100644 index 00000000..0b421d37 --- /dev/null +++ b/tests/agg.py @@ -0,0 +1,8 @@ +import unittest +import base + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start('Agg/main') + self.assertEqual("0;1;2;\na, 50;", self.body_text()) diff --git a/tests/agg.ur b/tests/agg.ur index 19a8644b..2d8eed43 100644 --- a/tests/agg.ur +++ b/tests/agg.ur @@ -1,13 +1,23 @@ table t1 : {A : int, B : string, C : float} table t2 : {A : float, D : int, E : option string} -val q1 : sql_query [] _ _ = (SELECT COUNT( * ) FROM t1) -val q2 : sql_query [] _ _ = (SELECT AVG(t1.A) FROM t1) -val q3 : sql_query [] _ _ = (SELECT SUM(t1.C) FROM t1) -val q4 : sql_query [] _ _ = (SELECT MIN(t1.B), MAX(t1.A) FROM t1) -val q5 : sql_query [] _ _ = (SELECT SUM(t1.A) FROM t1 GROUP BY t1.B) +val q1 : sql_query [] [] _ _ = (SELECT COUNT( * ) FROM t1) +val q2 : sql_query [] [] _ _ = (SELECT AVG(t1.A) FROM t1) +val q3 : sql_query [] [] _ _ = (SELECT SUM(t1.C) FROM t1) +val q4 : sql_query [] [] _ _ = (SELECT MIN(t1.B), MAX(t1.A) FROM t1) +val q5 : sql_query [] [] _ _ = (SELECT SUM(t1.A) FROM t1 GROUP BY t1.B) val q6 = (SELECT COUNT(t2.E) FROM t2 GROUP BY t2.D) +task initialize = fn () => + dml (INSERT INTO t1 (A, B, C) VALUES (1, 'a', 1.0)); + dml (INSERT INTO t1 (A, B, C) VALUES (2, 'b', 2.0)); + dml (INSERT INTO t1 (A, B, C) VALUES (50, 'c', 99.0)); + dml (INSERT INTO t2 (A, D, E) VALUES (1.0, 1, NULL)); + dml (INSERT INTO t2 (A, D, E) VALUES (1.0, 2, {[Some "a"]})); + dml (INSERT INTO t2 (A, D, E) VALUES (1.0, 3, NULL)); + dml (INSERT INTO t2 (A, D, E) VALUES (1.0, 3, {[Some "b"]})); + dml (INSERT INTO t2 (A, D, E) VALUES (1.0, 3, {[Some "c"]})) + fun main () : transaction page = xml <- queryX q6 (fn r => {[r.1]};); xml2 <- queryX q4 (fn r => {[r.1]}, {[r.2]};); diff --git a/tests/ahead.py b/tests/ahead.py new file mode 100644 index 00000000..6e767948 --- /dev/null +++ b/tests/ahead.py @@ -0,0 +1,15 @@ +import unittest +import base +import time + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + alert = self.driver.switch_to.alert + self.assertEqual("Hi!", alert.text) + alert.accept() + time.sleep(0.1) + alert = self.driver.switch_to.alert + self.assertEqual("Bye!", alert.text) + alert.accept() diff --git a/tests/babySpawn.py b/tests/babySpawn.py new file mode 100644 index 00000000..6693e969 --- /dev/null +++ b/tests/babySpawn.py @@ -0,0 +1,12 @@ +import unittest +import base +import time + +class Suite(base.Base): + def test_1(self): + """Test case 1""" + self.start() + btn = self.xpath('button') + btn.click() + alert = self.driver.switch_to.alert + self.assertEqual("Hi", alert.text) -- cgit v1.2.3